Compare commits

..

3 Commits

Author SHA1 Message Date
sudacode c6328eef09 chore: prepare 0.15.0-beta.4 prerelease 2026-05-21 20:57:31 -07:00
sudacode dc52bc2fba feat(config): add configuration window (#70) 2026-05-21 04:16:21 -07:00
sudacode a54f03f0cd Fix Jellyfin Login (#76) 2026-05-20 00:46:11 -07:00
220 changed files with 7765 additions and 7194 deletions
+43 -93
View File
@@ -4,7 +4,7 @@
# SubMiner
Look up words with Yomitan, export to Anki in one key, track your immersion — all without leaving mpv.
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)
@@ -23,7 +23,7 @@ Look up words with Yomitan, export to Anki in one key, track your immersion —
### 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">
<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
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">
<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
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">
<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>
<td><b>alass / ffsubsync</b></td>
<td>Automatic subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
</tr>
<tr>
<td><b>WebSocket</b></td>
<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>
</table>
@@ -110,65 +110,36 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
## Requirements
| | Required | Recommended | Optional |
| -------------- | --------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| **Player** | [`mpv`](https://mpv.io) with IPC socket | — | — |
| **Processing** | — | `ffmpeg` (audio clips & screenshots) | `mecab` + `mecab-ipadic` (annotation POS filtering), `guessit` (AniSkip), `alass` / `ffsubsync` (subtitle sync) |
| **Media** | — | — | `yt-dlp`, `chafa`, `ffmpegthumbnailer` |
| **Selection** | — | — | `fzf` / `rofi` |
Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but enhances the experience.
> [!TIP]
> `ffmpeg` is not strictly required to run SubMiner, but without it audio clips and screenshots will not be attached to Anki cards. Most users will want it installed.
> [!NOTE]
> [`bun`](https://bun.sh) is required if building from source or using the CLI wrapper: `subminer`. Pre-built releases (AppImage, DMG, installer) do not require it.
**Platform-specific:**
| Linux | macOS | Windows |
| ------------------------------------------------------------ | ------------------------ | ------------- |
| Hyprland (`hyprctl`) · X11/Xwayland (`xdotool` + `xwininfo`) | Accessibility permission | No extra deps |
> [!NOTE]
> **Wayland support is compositor-specific.** Wayland has no universal API for window positioning and each compositor exposes its own IPC, so SubMiner needs a dedicated backend per compositor. Hyprland is the only native Wayland backend supported currenlty. All other Linux compositors require both mpv and SubMiner to run under X11 or Xwayland. The launcher detects your compositor and configures this automatically.
| Dependency | Status | What it does |
| -------------------- | ----------- | ---------------------------------------- |
| mpv | Required | The video player SubMiner overlays on |
| Anki + AnkiConnect | Required | Card creation from the Yomitan popup |
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
| yt-dlp | Optional | YouTube playback |
| fzf / rofi | Optional | Video picker in the launcher |
| alass / ffsubsync | Optional | Subtitle sync |
<details>
<summary><b>Arch Linux</b></summary>
<summary><b>Platform-specific install commands</b></summary>
**Arch Linux:**
```bash
paru -S --needed mpv ffmpeg
# Optional
paru -S --needed mecab-git mecab-ipadic yt-dlp fzf rofi chafa ffmpegthumbnailer xdotool xorg-xwininfo
# Optional: subtitle sync (install at least one for subtitle syncing to work)
paru -S --needed alass python-ffsubsync
# X11 / Xwayland (required for non-Hyprland compositors)
paru -S --needed xdotool xorg-xwininfo
sudo pacman -S --needed mpv ffmpeg mecab mecab-ipadic
```
</details>
<details>
<summary><b>macOS</b></summary>
**macOS:**
```bash
brew install mpv ffmpeg
# Optional
brew install mecab mecab-ipadic yt-dlp fzf rofi chafa ffmpegthumbnailer
# Optional: subtitle sync (install at least one for subtitle syncing to work)
brew install alass
pip install ffsubsync
brew install mpv ffmpeg mecab mecab-ipadic
```
Grant Accessibility permission to SubMiner in **System Settings > Privacy & Security > Accessibility**.
**Windows:** Install [mpv](https://mpv.io/installation/) and [ffmpeg](https://ffmpeg.org/download.html) and ensure both are on `PATH`.
</details>
<details>
<summary><b>Windows</b></summary>
Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on your `PATH`.
Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary for additional metadata enrichment.
See the [full requirements list](https://docs.subminer.moe/installation#1-install-requirements) for optional dependencies.
</details>
@@ -176,7 +147,7 @@ Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download
## Quick Start
### 1. Install
### 1. Install SubMiner
<details>
<summary><b>Arch Linux (AUR)</b></summary>
@@ -185,12 +156,6 @@ Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download
paru -S subminer-bin
```
Or manually:
```bash
git clone https://aur.archlinux.org/subminer-bin.git && cd subminer-bin && makepkg -si
```
</details>
<details>
@@ -199,40 +164,24 @@ git clone https://aur.archlinux.org/subminer-bin.git && cd subminer-bin && makep
```bash
mkdir -p ~/.local/bin
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
&& chmod +x ~/.local/bin/SubMiner.AppImage
&& chmod +x ~/.local/bin/SubMiner.AppImage
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
&& chmod +x ~/.local/bin/subminer
&& chmod +x ~/.local/bin/subminer
```
> [!NOTE]
> The `subminer` wrapper uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory.
</details>
<details>
<summary><b>macOS</b></summary>
<summary><b>macOS (DMG)</b></summary>
Download the latest DMG or ZIP from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
Also download the `subminer` launcher (recommended):
```bash
mkdir -p ~/.local/bin
curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o ~/.local/bin/subminer \
&& chmod +x ~/.local/bin/subminer
```
> [!NOTE]
> The `subminer` launcher uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory. Make sure `~/.local/bin` is on your PATH before installing there.
Download the latest DMG from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
</details>
<details>
<summary><b>Windows</b></summary>
Download the latest installer (`.exe`) [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Make sure `mpv` is on your `PATH`.
**Note:** On Windows the recommended way to run playback is with the **SubMiner mpv** shortcut created during first-run setup. First-run setup can also optionally install Bun and a `subminer.cmd` command shim to your user PATH, so new terminals can run `subminer` without adding `SubMiner.exe` to PATH.
Download and run the latest installer (`.exe`) from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest).
</details>
@@ -243,28 +192,29 @@ See the [build-from-source guide](https://docs.subminer.moe/installation#from-so
</details>
### 2. First Launch
### 2. Launch & Set Up
Run SubMiner and the first-run setup wizard will guide you through importing Yomitan dictionaries and optionally installing the `subminer` command-line launcher.
```bash
subminer app --setup # launch the first-run setup wizard
# Linux
subminer app --setup
# macOS — open SubMiner.app, or:
subminer app --setup
```
SubMiner creates a default config, starts in the system tray, and opens a setup popup that walks you through installing Yomitan dictionaries. The setup popup can also optionally install Bun and the `subminer` command-line launcher; those choices do not block setup completion.
> [!NOTE]
> On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch.
On **Windows**, just run `SubMiner.exe` and the setup will open automatically on first launch.
### 3. Mine
```bash
subminer video.mkv # play video with overlay
subminer --start video.mkv # explicit overlay start
subminer stats # open immersion dashboard
subminer stats -b # stats daemon in background
subminer stats -s # stop background stats daemon
subminer video.mkv # launch mpv with SubMiner
subminer /path/to/dir # pick a file with fzf
subminer -R /path/to/dir # pick a file with rofi (Linux only)
```
On **Windows**, use the **SubMiner mpv** shortcut created during first-run setup — double-click it to open mpv, or drag a video file onto it. You can also run `SubMiner.exe --launch-mpv` from a terminal.
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
## Documentation
-193
View File
@@ -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
-11
View File
@@ -15,7 +15,6 @@
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"vscode-json-languageservice": "^5.7.2",
"ws": "^8.19.0",
},
"devDependencies": {
@@ -189,8 +188,6 @@
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
@@ -725,14 +722,6 @@
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
"vscode-json-languageservice": ["vscode-json-languageservice@5.7.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w=="],
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
"vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Fixed the Jellyfin setup popup login path on Windows by using an IPC bridge, showing immediate login progress, and timing out unreachable server login attempts with an inline error.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- 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 -1
View File
@@ -1,4 +1,4 @@
type: changed
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 -1
View File
@@ -1,4 +1,4 @@
type: fixed
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.
+5 -3
View File
@@ -1,6 +1,8 @@
type: added
area: config
- Added a dedicated Configuration window with launcher entry points via `subminer --config` and `subminer config`.
- Fixed the Configuration 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.
- Added a dedicated Settings window with launcher entry points via `subminer --settings` and `subminer settings`.
- Fixed the Settings window preload so launcher-opened windows can initialize even when Electron sandboxing is active.
- Kept settings-window startup lightweight by skipping AniList token refresh and automatic update polling.
- 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 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.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Primed the first startup subtitle before autoplay resumes so the overlay can render text before video playback begins.
@@ -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
area: launcher
- Suppressed Electron macOS menu diagnostics from `subminer config` launcher output.
- Suppressed Electron macOS menu diagnostics from `subminer settings` launcher output.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: shortcuts
- Disabled native mpv menu shortcuts during managed macOS playback so configured SubMiner shortcuts also work while mpv has focus.
@@ -0,0 +1,4 @@
type: fixed
area: playback
- Fixed managed mpv startup so launcher-owned videos quit SubMiner when playback ends, background/tray sessions stay alive, and pause-until-ready waits for the overlay and tokenization readiness before playback resumes.
+4
View File
@@ -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.
+5
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Wired configured session shortcuts, including `stats.markWatchedKey`, through mpv so custom add/remove changes work while mpv has focus.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: config
- Config: Preserved N+1 subtitle highlighting for existing configs that already enabled known-word highlighting, while keeping N+1 disabled by default for new configs unless `ankiConnect.nPlusOne.enabled` is set.
+4
View File
@@ -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,4 @@
type: changed
area: jellyfin
- Removed the Jellyfin setup server presets dropdown; setup now shows a single editable server URL field.
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: subtitles
- Subsync now always opens the manual picker and the `subsync.defaultMode` config/settings option has been removed.
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed
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.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: windows
- Windows startup failures now show a native error dialog and write fatal details to the SubMiner app log instead of exiting silently.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updates
- Windows automatic updates now keep the native `electron-updater`/NSIS install path enabled while routing updater HTTP through main-process fetch, avoiding the delayed app exit seen shortly after launch without requiring `curl.exe`.
+10 -7
View File
@@ -46,6 +46,7 @@
// Logging
// Controls logging verbosity.
// Set to debug for full runtime diagnostics.
// Hot-reload: logging.level applies live while SubMiner is running.
// ==========================================
"logging": {
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
@@ -154,7 +155,7 @@
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false
"jellyfinRemoteSession": false // Warm up Jellyfin remote session at startup. Values: true | false
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// ==========================================
@@ -335,11 +336,11 @@
}, // Dual subtitle track options.
// ==========================================
// Auto Subtitle Sync
// Subtitle Sync
// Subsync engine and executable paths.
// Hot-reload: subsync changes apply to the next subtitle sync run.
// ==========================================
"subsync": {
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
"alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.
"ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.
"ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.
@@ -376,13 +377,13 @@
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
"--subtitle-hover-token-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle hover token background color setting.
"--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting.
}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. 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
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
@@ -437,7 +438,7 @@
"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
"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
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
@@ -470,7 +471,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running.
// Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart.
// ==========================================
@@ -550,6 +551,7 @@
// ==========================================
// Jimaku
// Jimaku API configuration and defaults.
// Hot-reload: Jimaku changes apply to the next Jimaku request.
// ==========================================
"jimaku": {
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
@@ -560,6 +562,7 @@
// ==========================================
// YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
// ==========================================
"youtube": {
"primarySubLanguages": [
+3 -3
View File
@@ -88,7 +88,7 @@ Name matching runs inside Yomitan's scanning pipeline during subtitle tokenizati
1. Yomitan receives subtitle text and scans for dictionary matches.
2. Entries from "SubMiner Character Dictionary" are checked with exact primary-source matching — the token must match the entry's `originalText` with `isPrimary: true` and `matchType: 'exact'`.
3. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer.
4. The renderer applies the name-match highlight color (default: `#f5bde6`).
4. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`).
Name matches are visually distinct from [N+1 targeting, frequency highlighting, and JLPT tags](/subtitle-annotations) so you can tell at a glance whether a highlighted word is a character name or a vocabulary target.
@@ -96,7 +96,7 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting,
| Option | Default | Description |
| -------------------------------- | --------- | ---------------------------------- |
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting |
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
## Dictionary Entries
@@ -228,7 +228,7 @@ merged.zip
| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting in subtitles |
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting in subtitles |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
## Reference Implementation
+35 -15
View File
@@ -37,7 +37,7 @@ Then customize as needed using the sections below.
## 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:
@@ -99,7 +99,28 @@ Hot-reloadable fields:
- `keybindings`
- `shortcuts`
- `secondarySub.defaultMode`
- `ankiConnect.ai`
- `stats.toggleKey`
- `stats.markWatchedKey`
- `logging.level`
- `youtube.primarySubLanguages`
- `jimaku.*`
- `subsync.*`
- `ankiConnect.ai.enabled`
- `ankiConnect.behavior.autoUpdateNewCards`
- `ankiConnect.knownWords.highlightEnabled`
- `ankiConnect.knownWords.refreshMinutes`
- `ankiConnect.knownWords.addMinedWordsImmediately`
- `ankiConnect.knownWords.matchMode`
- `ankiConnect.knownWords.decks`
- `ankiConnect.nPlusOne.enabled`
- `ankiConnect.nPlusOne.minSentenceWords`
- `ankiConnect.fields.word`
- `ankiConnect.fields.audio`
- `ankiConnect.fields.image`
- `ankiConnect.fields.sentence`
- `ankiConnect.fields.miscInfo`
- `ankiConnect.isLapis.sentenceCardModel`
- `ankiConnect.isKiku.fieldGrouping`
When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
@@ -107,6 +128,7 @@ Restart-required changes:
- Any other config sections still require restart.
- Shared top-level `ai` provider settings still require restart.
- AnkiConnect transport/proxy/media/deck/tag fields still require restart unless listed above.
- SubMiner shows an on-screen/system notification listing restart-required sections when they change.
### Configuration Options Overview
@@ -149,7 +171,7 @@ The configuration file includes several main sections:
**External Integrations**
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**AniList**](#anilist) - Optional post-watch progress updates
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath`
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
@@ -236,7 +258,7 @@ Control which startup warmups run in the background versus deferring to first re
"mecab": true,
"yomitanExtension": true,
"subtitleDictionaries": true,
"jellyfinRemoteSession": true
"jellyfinRemoteSession": false
}
}
```
@@ -249,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 |
| `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
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).
@@ -368,8 +390,8 @@ See `config.example.jsonc` for detailed configuration options.
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`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) |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight; `hoverBackground` is accepted as an alias |
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) |
| `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 (`false` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
@@ -421,7 +443,7 @@ Configure the parsed-subtitle sidebar modal.
"autoOpen": false,
"layout": "overlay",
"toggleKey": "Backslash",
"pauseVideoOnHover": false,
"pauseVideoOnHover": true,
"autoScroll": true,
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
"fontSize": 16
@@ -435,7 +457,7 @@ Configure the parsed-subtitle sidebar modal.
| `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 |
| `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 |
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
@@ -1089,17 +1111,16 @@ Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry dela
Set `openBrowser` to `false` to only print the URL without opening a browser.
### Auto Subtitle Sync
### Subtitle Sync
Sync the active subtitle track using `alass` (preferred) or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below). Subtitle syncing is silently skipped if neither is found.
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
- [`alass`](https://github.com/kaegi/alass) — fast, audio-independent sync using a secondary subtitle as reference
- [`ffsubsync`](https://github.com/smacke/ffsubsync) — audio-based sync using the video file as reference (fallback)
- [`ffsubsync`](https://github.com/smacke/ffsubsync) — audio-based sync using the video file as reference
```json
{
"subsync": {
"defaultMode": "auto",
"alass_path": "",
"ffsubsync_path": "",
"ffmpeg_path": "",
@@ -1110,7 +1131,6 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`. Both ar
| Option | Values | Description |
| ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `defaultMode` | `"auto"`, `"manual"` | `auto`: try `alass` against secondary subtitle, then fallback to `ffsubsync`; `manual`: open overlay picker |
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
+1 -1
View File
@@ -25,7 +25,7 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
## Subtitle Download & Sync
Search and download subtitles from Jimaku, then automatically synchronize them with alass or ffsubsync — all from within SubMiner.
Search and download subtitles from Jimaku, then retime them with alass or ffsubsync — all from within SubMiner.
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/subtitle-sync-poster.jpg?v=${v}`)">
<source :src="withBase(`/assets/demos/subtitle-sync.webm?v=${v}`)" type="video/webm" />
+1 -1
View File
@@ -66,7 +66,7 @@ features:
src: /assets/subtitle-download.svg
alt: Subtitle download icon
title: Subtitle Download & Sync
details: Search and pull subtitles from Jimaku, then auto-sync timing with alass or ffsubsync — all from the overlay.
details: Search and pull subtitles from Jimaku, then retime subtitles with alass or ffsubsync — all from the overlay.
link: /jimaku-integration
linkText: Jimaku integration
- icon:
+188 -309
View File
@@ -1,34 +1,33 @@
# Installation
## How the Pieces Fit Together
Three steps to get started:
SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IPC socket, renders subtitles as interactive text using a bundled Yomitan dictionary engine, and optionally creates Anki flashcards via AnkiConnect.
1. **Install requirements** — mpv and a few optional extras
2. **Install SubMiner** — from the AUR, or download from GitHub Releases
3. **Launch the app** — first-run setup walks you through dictionaries, the launcher, and everything else
To get a working setup you need:
## 1. Install Requirements
1. **mpv** launched with an IPC socket so SubMiner can read subtitle data
2. **SubMiner** (the Electron overlay app)
3. **Dictionaries** imported into the bundled Yomitan instance (lookups won't work without at least one)
4. **Anki + AnkiConnect** _(optional but recommended)_ for card creation and enrichment
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
The `subminer` launcher script handles step 1 automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or the equivalent named pipe on Windows) — without it the overlay will start but subtitles will never appear.
| Dependency | Status | What it does |
| -------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
| yt-dlp | Optional | YouTube playback and subtitle extraction. |
| fzf | Optional | Terminal-based video picker in the launcher. |
| rofi | Optional | GUI-based video picker (Linux). |
| chafa | Optional | Thumbnail previews in fzf. |
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
| guessit | Optional | Better AniSkip title/season/episode parsing. |
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
| ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
| fuse2 | Linux only | Required to run the AppImage. |
## Requirements
### Linux
### System Dependencies
| Dependency | Required | Notes |
| -------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| mpv | Yes | Must support IPC sockets (`--input-ipc-server`) |
| Bun | For wrapper | Required for `subminer` CLI wrapper and source builds. Pre-built releases (AppImage, DMG, installer) work without it — only the `subminer` wrapper script needs Bun on `PATH`. |
| ffmpeg | Recommended | Audio extraction and screenshot generation. Without it SubMiner still runs, but audio and image fields on Anki cards will be empty. |
| MeCab + mecab-ipadic | No | Adds part-of-speech data used to filter particles out of N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less precise. |
| fuse2 | Linux only | Required for AppImage |
| yt-dlp | No | Recommended for YouTube playback and subtitle extraction |
### Platform-Specific
**Linux** — one of the following window backends:
**Window backend** — you need one of these depending on your compositor:
- **Hyprland** — native Wayland support (uses `hyprctl`)
- **Sway** — native Wayland support (uses `swaymsg`)
@@ -43,8 +42,10 @@ Wayland has no universal API for window positioning — each compositor exposes
```bash
sudo pacman -S --needed mpv ffmpeg
# Recommended
sudo pacman -S --needed mecab mecab-ipadic
# Optional
sudo pacman -S --needed mecab mecab-ipadic yt-dlp fzf rofi chafa ffmpegthumbnailer
sudo pacman -S --needed yt-dlp fzf rofi chafa ffmpegthumbnailer
# Optional: subtitle sync (at least one needed for subtitle syncing)
paru -S --needed alass python-ffsubsync
# X11 / Xwayland (required for non-Hyprland/Sway compositors)
@@ -58,8 +59,10 @@ sudo pacman -S --needed xdotool xorg-xwininfo
```bash
sudo apt install mpv ffmpeg
# Recommended
sudo apt install mecab libmecab-dev mecab-ipadic-utf8
# Optional
sudo apt install mecab libmecab-dev mecab-ipadic-utf8 fzf rofi chafa ffmpegthumbnailer yt-dlp
sudo apt install yt-dlp fzf rofi chafa ffmpegthumbnailer
# X11 / Xwayland (required for non-Hyprland/Sway compositors)
sudo apt install xdotool x11-utils
# Optional: subtitle sync
@@ -74,8 +77,10 @@ pip install ffsubsync
```bash
sudo dnf install mpv ffmpeg
# Recommended
sudo dnf install mecab mecab-ipadic
# Optional
sudo dnf install mecab mecab-ipadic fzf rofi chafa ffmpegthumbnailer yt-dlp
sudo dnf install yt-dlp fzf rofi chafa ffmpegthumbnailer
# X11 / Xwayland (required for non-Hyprland/Sway compositors)
sudo dnf install xdotool xorg-x11-utils
# Optional: subtitle sync
@@ -85,38 +90,32 @@ pip install ffsubsync
</details>
**macOS** — macOS 10.13 or later. Accessibility permission required for window tracking.
### macOS
macOS 10.13 or later. Accessibility permission is required for window tracking (see [step 2](#macos-dmg)).
```bash
brew install mpv ffmpeg
# Optional but recommended for annotations
# Recommended
brew install mecab mecab-ipadic
# Optional
brew install yt-dlp fzf rofi chafa ffmpegthumbnailer
brew install yt-dlp fzf chafa ffmpegthumbnailer
# Optional: subtitle sync
brew install alass
pip install ffsubsync
```
**Windows** — Windows 10 or later. Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on `PATH`. Keep `mpv.exe` on `PATH` for auto-discovery or set `mpv.executablePath` in config if it lives elsewhere. SubMiner's packaged build handles window tracking directly. Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary.
### Windows
### Optional Tools
Windows 10 or later. Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on `PATH`. Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary.
| Tool | Purpose |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| fzf | Terminal-based video picker (default) |
| rofi | GUI-based video picker |
| chafa | Thumbnail previews in fzf |
| ffmpegthumbnailer | Generate video thumbnails for picker |
| guessit | Better AniSkip title/season/episode parsing for file playback |
| alass | Subtitle sync engine (preferred) — must be on `PATH` or set `subsync.alass_path` in config; subtitle syncing is disabled without it or ffsubsync |
| ffsubsync | Subtitle sync engine (fallback) — must be on `PATH` or set `subsync.ffsubsync_path` in config; subtitle syncing is disabled without it or alass |
No compositor tools or window helpers are needed — native window tracking is built in.
## Linux
## 2. Install SubMiner
### Arch Linux (AUR)
### Arch Linux (AUR) {#arch-aur}
Install [`subminer-bin`](https://aur.archlinux.org/packages/subminer-bin) from the AUR if you want the packaged Linux release managed by pacman. The package installs the official SubMiner AppImage plus the `subminer` wrapper.
Install [`subminer-bin`](https://aur.archlinux.org/packages/subminer-bin) from the AUR. The package includes the SubMiner AppImage and the `subminer` launcher.
```bash
paru -S subminer-bin
@@ -130,127 +129,73 @@ cd subminer-bin
makepkg -si
```
### AppImage (Recommended)
### Linux (AppImage) {#linux-appimage}
Download the latest AppImage and the `subminer` launcher from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest).
**Step 1 — Install Bun** (required for the launcher):
```bash
curl -fsSL https://bun.sh/install | bash
```
The `subminer` launcher uses a Bun shebang. The AppImage itself does **not** need Bun — only the launcher does. If you skip the launcher and run the AppImage directly (for example `SubMiner.AppImage --start`), you can skip this step, but you will need to configure `mpv.conf` with `input-ipc-server=/tmp/subminer-socket` manually.
**Step 2 — Download and install:**
Download the latest AppImage from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
```bash
mkdir -p ~/.local/bin
# Download and install AppImage
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage
chmod +x ~/.local/bin/SubMiner.AppImage
# Download and install the subminer launcher (recommended)
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
chmod +x ~/.local/bin/subminer
# Download the optional Linux rofi theme
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.local/share/SubMiner/themes
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
```
The `subminer` launcher is the recommended way to use SubMiner on Linux. It ensures mpv is launched with the correct IPC socket, SubMiner defaults, and the bundled runtime plugin so you don't need to configure `mpv.conf` or install a global mpv plugin.
::: tip Launcher install is optional
First-run setup can install [Bun](https://bun.sh) and the `subminer` command-line launcher for you automatically. You don't need to download the launcher separately.
The first-run setup window can also install Bun and the packaged `subminer` launcher into an existing writable PATH directory. Both steps are optional.
If you prefer to install it manually, see [manual launcher install](#manual-launcher-install-linux).
:::
To check for updates later:
### macOS (DMG) {#macos-dmg}
Download the DMG from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest), open it, and drag `SubMiner.app` into `/Applications`. A ZIP artifact is also available as a fallback.
**Gatekeeper:** If macOS blocks SubMiner on first launch, right-click the app and select **Open** to bypass the warning. Alternatively:
```bash
subminer -u
# or
subminer --update
xattr -d com.apple.quarantine /Applications/SubMiner.app
```
SubMiner verifies AppImage, launcher, and Linux rofi theme downloads against `SHA256SUMS.txt`. If the AppImage or launcher is installed in a protected path, SubMiner does not elevate itself; it shows the exact sudo command to run instead.
**Accessibility permission:** Grant accessibility permission so the overlay can track the mpv window:
On Linux, `subminer -u` performs the AppImage update from the launcher process, so it does not need to start or IPC into the tray app.
1. Open **System Settings****Privacy & Security****Accessibility**
2. Enable SubMiner in the list (add it if it does not appear)
::: tip Launcher install is optional
First-run setup can install [Bun](https://bun.sh) and the `subminer` command-line launcher for you automatically. You don't need to download the launcher separately.
If you prefer to install it manually, see [manual launcher install](#manual-launcher-install-macos).
:::
### Windows (Installer) {#windows-installer}
Download the latest installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
- `SubMiner-<version>.exe` — installer (recommended)
- `SubMiner-<version>.zip` — portable fallback
Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config during first-run setup.
### From Source
<details>
<summary><b>Linux</b></summary>
```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
cd SubMiner
# if you cloned without --recurse-submodules:
git submodule update --init --recursive
bun install
bun run build
# Optional packaged Linux artifact
# Optional: build AppImage
bun run build:appimage
```
Bundled Yomitan is built during `bun run build`.
If you prefer Make wrappers for local install flows, `make build-launcher` still generates `dist/launcher/subminer` and `make install` still installs the wrapper/theme/AppImage when those artifacts exist.
`make build` also builds the bundled Yomitan Chrome extension from the `vendor/subminer-yomitan` submodule into `build/yomitan` using Bun.
</details>
## macOS
### DMG (Recommended)
Download the **DMG** artifact from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Open it and drag `SubMiner.app` into `/Applications`.
A **ZIP** artifact is also available as a fallback — unzip and drag `SubMiner.app` into `/Applications`.
After the first updater-enabled install, tray update checks can update the macOS app automatically through Electron's standard macOS updater. The updater uses the release ZIP as its payload even when the DMG remains the normal first-install artifact.
Install dependencies using Homebrew:
```bash
brew install mpv ffmpeg
# Optional but recommended if you use N+1, JLPT, or frequency annotations
brew install mecab mecab-ipadic
```
#### Install the `subminer` launcher (recommended)
The `subminer` launcher is the recommended way to use SubMiner on macOS. It launches mpv with the correct IPC socket and SubMiner defaults so you don't need to set up an `mpv.conf` profile manually.
First-run setup can install Bun and the packaged launcher into a writable directory that is already on PATH. It does not edit shell profiles.
Download it from the same [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) page:
```bash
sudo wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O /usr/local/bin/subminer
sudo chmod +x /usr/local/bin/subminer
```
Or with curl:
```bash
sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer
sudo chmod +x /usr/local/bin/subminer
```
To check for updates later:
```bash
subminer -u
# or
subminer --update
```
SubMiner verifies launcher downloads against `SHA256SUMS.txt`. If `/usr/local/bin/subminer` is protected, SubMiner shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead of elevating itself.
::: warning Bun required for the launcher
The `subminer` launcher uses a Bun shebang (`#!/usr/bin/env bun`), so [Bun](https://bun.sh) must be installed and available on `PATH`. Install Bun if you haven't already: `curl -fsSL https://bun.sh/install | bash`.
:::
### From Source (macOS)
<details>
<summary><b>macOS</b></summary>
```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
@@ -259,122 +204,19 @@ git submodule update --init --recursive
make build-macos
```
The built app will be available in the `release` directory (`.dmg` and `.zip`).
The built app will be in the `release` directory (`.dmg` and `.zip`). For unsigned local builds: `bun run build:mac:unsigned`.
For unsigned local builds:
</details>
```bash
bun run build:mac:unsigned
```
Build and install the launcher alongside the app:
```bash
make install-macos
```
This builds the `subminer` launcher into `dist/launcher/subminer` and installs it to `~/.local/bin/subminer` along with the app bundle. To install to `/usr/local/bin` instead (already on the default macOS `PATH`):
```bash
sudo make install-macos PREFIX=/usr/local
```
### Gatekeeper
If macOS blocks SubMiner on first launch, right-click the app and select **Open** to bypass the warning. Alternatively, remove the quarantine attribute:
```bash
xattr -d com.apple.quarantine /Applications/SubMiner.app
```
### Accessibility Permission
After launching SubMiner for the first time, grant accessibility permission:
1. Open **System Settings****Privacy & Security****Accessibility**
2. Enable SubMiner in the list (add it if it does not appear)
Without this permission, window tracking will not work and the overlay won't follow the mpv window.
### macOS Usage Notes
**Launching with the `subminer` launcher (recommended):**
```bash
subminer video.mkv
```
The launcher handles the IPC socket and SubMiner defaults automatically. If you prefer to launch mpv manually:
```bash
mpv --input-ipc-server=/tmp/subminer-socket video.mkv
```
**Config location:** `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`).
**MeCab paths (Homebrew):**
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
- Intel: `/usr/local/bin/mecab`
Ensure `mecab` is available on your PATH when launching SubMiner.
**Fullscreen:** The overlay should appear correctly in fullscreen. If you encounter issues, check that accessibility permissions are granted.
**mpv plugin binary path:**
```ini
binary_path=/Applications/SubMiner.app/Contents/MacOS/subminer
```
## Windows
### Prerequisites
1. Install [`mpv`](https://mpv.io/installation/) and ensure `mpv.exe` is on `PATH`. If mpv is installed elsewhere, you can set `mpv.executablePath` in `config.jsonc` or use the first-run setup field to point at the executable.
2. Install [`ffmpeg`](https://ffmpeg.org/download.html) and add it to `PATH` — recommended for audio/screenshot extraction (without it, media fields on Anki cards will be empty).
3. _(Optional)_ Install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary for annotation POS filtering.
No compositor tools or window helpers are needed — native window tracking is built in on Windows.
### Installer (Recommended)
Download the latest Windows installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
- `SubMiner-<version>.exe` installs the app, Start menu shortcut, and default files under `Program Files`
- `SubMiner-<version>.zip` is available as a portable fallback
### Getting Started on Windows
1. **Run `SubMiner.exe` once** — first-run setup creates `%APPDATA%\SubMiner\config.jsonc` and opens Yomitan settings for dictionary import. The global mpv plugin install is optional for compatibility; the SubMiner mpv shortcut injects the bundled runtime plugin.
2. **Create the SubMiner mpv shortcut** _(recommended)_ — the setup popup offers to create a `SubMiner mpv` Start Menu and/or Desktop shortcut. This is the recommended way to launch playback on Windows.
3. **Optional: install the command-line launcher** — first-run setup can install Bun with winget/Scoop/the official installer and add `%LOCALAPPDATA%\SubMiner\bin\subminer.cmd` to your user PATH. Open a new terminal and type `subminer`.
4. **Play a video** — double-click the shortcut, drag a video file onto it, or run from a terminal:
```powershell
& "C:\Program Files\SubMiner\SubMiner.exe" --launch-mpv "C:\Videos\episode 01.mkv"
```
The shortcut and `--launch-mpv` pass SubMiner's default IPC socket, subtitle args, and bundled runtime plugin directly — no `mpv.conf` profile or global mpv plugin install is needed.
### Windows-Specific Notes
- The **SubMiner mpv** shortcut created during first-run setup is the recommended way to launch playback on Windows.
- The optional command-line launcher installs a `subminer.cmd` shim, but users type `subminer`; Windows resolves `.cmd` through `PATHEXT`.
- First-run setup adds only `%LOCALAPPDATA%\SubMiner\bin` to the HKCU user PATH. It does not add `SubMiner.exe` or the app install directory to PATH.
- First-run plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is in a non-standard location.
- Plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket` — do not keep `/tmp/subminer-socket` on Windows.
- Config is stored at `%APPDATA%\SubMiner\config.jsonc`.
### From Source (Windows)
<details>
<summary><b>Windows</b></summary>
```powershell
git clone https://github.com/ksyasuda/SubMiner.git
cd SubMiner
bun install
# Windows requires building the texthooker-ui submodule manually before
# the main build (Linux/macOS handle this automatically during `bun run build`).
# Windows requires building texthooker-ui manually before the main build
Set-Location vendor/texthooker-ui
bun install --frozen-lockfile
bun run build
@@ -383,86 +225,52 @@ Set-Location ../..
bun run build:win
```
Windows installer builds already get the required NSIS `WinShell` helper through electron-builder's cached `nsis-resources` bundle.
No extra repo-local WinShell plugin install step is required.
</details>
## MPV Plugin
## 3. Launch & First-Run Setup
SubMiner-managed playback loads the bundled mpv plugin at runtime. No separate global mpv plugin install is required when launching from the app, the launcher, or the packaged Windows SubMiner mpv shortcut.
::: warning Important
If first-run setup detects an older global SubMiner mpv plugin under mpv's `scripts` directory, use **Remove legacy mpv plugin** so regular mpv playback stops loading SubMiner.
:::
See [MPV Plugin](/mpv-plugin) for the keybindings, script messages, and runtime configuration reference.
## Anki Setup (Recommended)
If you plan to mine Anki cards (the primary use case for most users):
1. Install [Anki](https://apps.ankiweb.net/).
2. Install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on — open Anki, go to **Tools → Add-ons → Get Add-ons**, enter code `2055492159`.
3. Restart Anki and keep it running while using SubMiner.
AnkiConnect listens on `http://127.0.0.1:8765` by default. SubMiner will connect to it automatically with no extra config needed for basic card creation.
For enrichment configuration (sentence, audio, screenshot fields), see [Anki Integration](/anki-integration).
## First-Run Setup
Run the setup wizard to create a default config and finish initial configuration. You do **not** need to create the config manually — SubMiner handles it.
Launch SubMiner and the setup wizard will open automatically:
```bash
# Linux (AUR install)
subminer app --setup
# Linux (AppImage directly)
~/.local/bin/SubMiner.AppImage --setup
# macOS — launch SubMiner.app from /Applications, or:
subminer app --setup
```
> [!NOTE]
> On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch.
On **Windows**, just run `SubMiner.exe` — the setup wizard opens automatically on first launch.
The setup popup walks you through:
The setup wizard walks you through:
- **Config file**: auto-created at `~/.config/SubMiner/config.jsonc` (Linux/macOS) or `%APPDATA%\SubMiner\config.jsonc` (Windows)
- **mpv plugin**: install the bundled Lua plugin for in-player keybindings
- **Yomitan dictionaries**: import at least one dictionary so lookups work
- **Windows shortcut** _(Windows only)_: optionally create a `SubMiner mpv` Start Menu/Desktop shortcut
- **Command line launcher**: optionally install Bun and the `subminer` launcher to your command-line PATH
- **Config file** auto-created at `~/.config/SubMiner/config.jsonc` (Linux/macOS) or `%APPDATA%\SubMiner\config.jsonc` (Windows)
- **Yomitan dictionaries** — import at least one dictionary so word lookups work
- **Bun + `subminer` launcher** _(optional)_ — installs the command-line launcher into a writable PATH directory
- **Windows shortcut** _(Windows only)_ create a `SubMiner mpv` Start Menu/Desktop shortcut
The `Finish setup` button follows the normal config/Yomitan readiness checks. Bun and the command-line launcher are optional and never block setup completion.
The `Finish setup` button requires a config file and at least one Yomitan dictionary. Bun and the launcher are optional and never block setup completion.
> [!TIP]
> You can re-open the setup popup at any time with `subminer app --setup` or `SubMiner.AppImage --setup`.
> You can re-open the setup wizard at any time with `subminer app --setup` or `SubMiner.AppImage --setup`.
Once setup is complete, play a video to verify everything works:
### Play a Video
Once setup is complete:
```bash
subminer video.mkv
```
You should see the overlay appear over mpv. If subtitles are loaded in the video, they will appear as interactive text in the overlay.
You should see the overlay appear over mpv. If subtitles are loaded, they will appear as interactive text in the overlay.
<details>
<summary><b>More launch examples</b></summary>
On **Windows**, the recommended way to play video is with the **SubMiner mpv** shortcut created during setup — double-click it, or drag a video file onto it.
```bash
# Optional explicit overlay start for setups with plugin auto_start=no
subminer --start video.mkv
### Verify Setup
# Useful launch modes for troubleshooting
subminer --log-level debug video.mkv
SubMiner.AppImage --start --log-level debug
# Or with direct AppImage control
SubMiner.AppImage --background # Background tray service mode
SubMiner.AppImage --start
SubMiner.AppImage --start --dev
SubMiner.AppImage --help # Show all CLI options
```
</details>
## Verify Setup
After completing first-run setup, run the built-in diagnostic to confirm everything is in place:
Run the built-in diagnostic to confirm everything is working:
```bash
subminer doctor
@@ -470,19 +278,90 @@ subminer doctor
This checks for the app binary, mpv, ffmpeg, config file, and socket path. Fix any failures before continuing.
> [!NOTE]
> On Windows, run `SubMiner.exe` directly. Replace `SubMiner.AppImage` with `SubMiner.exe` in the direct app commands below.
## Anki Setup (Recommended)
If you plan to mine Anki cards:
1. Install [Anki](https://apps.ankiweb.net/)
2. Install [AnkiConnect](https://ankiweb.net/shared/info/2055492159) — open Anki → **Tools → Add-ons → Get Add-ons** → enter code `2055492159`
3. Restart Anki and keep it running while using SubMiner
AnkiConnect listens on `http://127.0.0.1:8765` by default. SubMiner connects automatically with no extra config needed.
For enrichment configuration (sentence, audio, screenshot fields), see [Anki Integration](/anki-integration).
## Updates
```bash
subminer -u
# or
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.
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.
`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
SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IPC socket, renders subtitles as interactive text using a bundled Yomitan dictionary engine, and optionally creates Anki flashcards via AnkiConnect.
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) — without it the overlay starts but subtitles won't appear.
The bundled mpv plugin is injected at runtime automatically — you don't need to install it separately. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
## Platform Notes
### macOS
**MeCab paths (Homebrew):**
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
- Intel: `/usr/local/bin/mecab`
Ensure `mecab` is available on your PATH when launching SubMiner.
**Fullscreen:** The overlay should appear correctly in fullscreen. If you encounter issues, check that accessibility permissions are granted.
### Windows
- The **SubMiner mpv** shortcut is the recommended way to launch playback. It starts `mpv.exe` with the right IPC socket and subtitle defaults.
- First-run setup adds only `%LOCALAPPDATA%\SubMiner\bin` to the HKCU user PATH. It does not add `SubMiner.exe` to PATH.
- IPC socket on Windows is `\\.\pipe\subminer-socket` — do not use `/tmp/subminer-socket`.
- Config is stored at `%APPDATA%\SubMiner\config.jsonc`.
## Manual Launcher Install
The `subminer` launcher uses a [Bun](https://bun.sh) shebang, so Bun must be installed. First-run setup can handle this automatically, but if you prefer to do it yourself:
### Linux {#manual-launcher-install-linux}
```bash
# Install Bun
curl -fsSL https://bun.sh/install | bash
# Download the launcher
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
chmod +x ~/.local/bin/subminer
```
### macOS {#manual-launcher-install-macos}
```bash
# Install Bun
curl -fsSL https://bun.sh/install | bash
# Download the launcher
sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer
sudo chmod +x /usr/local/bin/subminer
```
## Optional Extras
### Rofi Theme (Linux Only)
SubMiner ships a custom rofi theme bundled in the release assets tarball.
Install path (default auto-detected by `subminer`):
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
SubMiner ships a custom rofi theme in the release assets:
```bash
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
+2 -2
View File
@@ -6,7 +6,7 @@ SubMiner includes an optional Jellyfin CLI integration for:
- listing libraries and media items
- launching item playback in the connected mpv instance
- receiving Jellyfin remote cast-to-device playback events in-app
- opening an in-app setup window for server selection and authentication
- opening an in-app setup window for server URL and authentication
- toggling Jellyfin cast discovery from the tray once configured
## Requirements
@@ -50,7 +50,7 @@ subminer jellyfin -l \
--password 'your-password'
```
`subminer jellyfin` opens the setup window. It offers the configured server, recent servers, and a manual server URL field. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored.
`subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored.
3. List libraries:
+1
View File
@@ -77,6 +77,7 @@ subminer stats -b # start background stats daemon
| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) |
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
| `subminer doctor` | Dependency + config + socket diagnostics |
| `subminer settings` | Open the SubMiner settings window |
| `subminer config path` | Print active config file path |
| `subminer config show` | Print active config contents |
| `subminer mpv status` | Check mpv socket readiness |
+10 -7
View File
@@ -46,6 +46,7 @@
// Logging
// Controls logging verbosity.
// Set to debug for full runtime diagnostics.
// Hot-reload: logging.level applies live while SubMiner is running.
// ==========================================
"logging": {
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
@@ -154,7 +155,7 @@
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false
"jellyfinRemoteSession": false // Warm up Jellyfin remote session at startup. Values: true | false
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// ==========================================
@@ -335,11 +336,11 @@
}, // Dual subtitle track options.
// ==========================================
// Auto Subtitle Sync
// Subtitle Sync
// Subsync engine and executable paths.
// Hot-reload: subsync changes apply to the next subtitle sync run.
// ==========================================
"subsync": {
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
"alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.
"ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.
"ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.
@@ -376,13 +377,13 @@
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
"--subtitle-hover-token-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle hover token background color setting.
"--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting.
}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. 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
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
@@ -437,7 +438,7 @@
"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
"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
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
@@ -470,7 +471,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running.
// Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart.
// ==========================================
@@ -550,6 +551,7 @@
// ==========================================
// Jimaku
// Jimaku API configuration and defaults.
// Hot-reload: Jimaku changes apply to the next Jimaku request.
// ==========================================
"jimaku": {
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
@@ -560,6 +562,7 @@
// ==========================================
// YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
// ==========================================
"youtube": {
"primarySubLanguages": [
+13 -11
View File
@@ -61,6 +61,8 @@ These control playback and subtitle display. They require overlay window focus.
These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
## Subtitle & Feature Shortcuts
@@ -68,7 +70,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| Shortcut | Action | Config key |
| ------------------ | -------------------------------------------------------- | ----------------------------------- |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| `Ctrl/Cmd+Alt+A` | Open character dictionary AniList selector | `shortcuts.openCharacterDictionary` |
| `Ctrl/Cmd+Alt+A` | Open character dictionary AniList selector | `shortcuts.openCharacterDictionary` |
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
@@ -96,17 +98,17 @@ Controller input only drives the overlay while keyboard-only mode is enabled. Th
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
| Chord | Action |
| ----- | ------------------------ |
| `y-y` | Open SubMiner menu (OSD) |
| `y-s` | Start overlay |
| `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay |
| Chord | Action |
| ----- | -------------------------------------- |
| `y-y` | Open SubMiner menu (OSD) |
| `y-s` | Start overlay |
| `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay |
| `v` | Toggle primary subtitle bar visibility |
| `y-o` | Open Yomitan settings |
| `y-r` | Restart overlay |
| `y-c` | Check overlay status |
| `y-h` | Open session help |
| `y-o` | Open Yomitan settings |
| `y-r` | Restart overlay |
| `y-c` | Check overlay status |
| `y-h` | Open session help |
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so the SubMiner primary subtitle bar is hidden or restored instead.
+34 -33
View File
@@ -20,15 +20,16 @@ N+1 highlighting identifies sentences where you know every word except one, maki
**Key settings:**
| Option | Default | Description |
| --- | --- | --- |
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) |
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
| Option | Default | Description |
| ----------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) |
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting. Existing configs with known-word highlighting enabled are treated as enabled for compatibility unless this is explicitly set. |
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
::: tip
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
@@ -46,10 +47,10 @@ Character-name matches are built from the active merged SubMiner character dicti
**Key settings:**
| Option | Default | Description |
| --- | --- | --- |
| `subtitleStyle.nameMatchEnabled` | `true` | Enable character-name token highlighting |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
| Option | Default | Description |
| -------------------------------- | --------- | ---------------------------------------- |
| `subtitleStyle.nameMatchEnabled` | `false` | Enable character-name token highlighting |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
@@ -66,15 +67,15 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
**Key settings:**
| Option | Default | Description |
| --- | --- | --- |
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
| `subtitleStyle.frequencyDictionary.singleColor` | — | Color for single mode |
| `subtitleStyle.frequencyDictionary.bandedColors` | — | Array of five hex colors for banded mode |
| `subtitleStyle.frequencyDictionary.sourcePath` | — | Custom path to frequency dictionary root |
| Option | Default | Description |
| ------------------------------------------------ | ------------ | ---------------------------------------- |
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
| `subtitleStyle.frequencyDictionary.singleColor` | — | Color for single mode |
| `subtitleStyle.frequencyDictionary.bandedColors` | — | Array of five hex colors for banded mode |
| `subtitleStyle.frequencyDictionary.sourcePath` | — | Custom path to frequency dictionary root |
When `sourcePath` is omitted, SubMiner searches default install/runtime locations for `frequency-dictionary` directories automatically.
@@ -96,22 +97,22 @@ SubMiner loads offline `term_meta_bank_*.json` files from `vendor/yomitan-jlpt-v
**Default colors:**
| Level | Color | Preview |
| --- | --- | --- |
| N1 | `#ed8796` | Red |
| N2 | `#f5a97f` | Peach |
| N3 | `#f9e2af` | Yellow |
| N4 | `#a6e3a1` | Green |
| N5 | `#8aadf4` | Blue |
| Level | Color | Preview |
| ----- | --------- | ------- |
| N1 | `#ed8796` | Red |
| N2 | `#f5a97f` | Peach |
| N3 | `#f9e2af` | Yellow |
| N4 | `#a6e3a1` | Green |
| N5 | `#8aadf4` | Blue |
All colors are customizable via the `subtitleStyle.jlptColors` object.
**Key settings:**
| Option | Default | Description |
| --- | --- | --- |
| `subtitleStyle.enableJlpt` | `false` | Enable JLPT underline styling |
| `subtitleStyle.jlptColors.N1``N5` | see above | Per-level underline colors |
| Option | Default | Description |
| ---------------------------------- | --------- | ----------------------------- |
| `subtitleStyle.enableJlpt` | `false` | Enable JLPT underline styling |
| `subtitleStyle.jlptColors.N1``N5` | see above | Per-level underline colors |
## Runtime Toggles
+2 -2
View File
@@ -33,7 +33,7 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
"autoOpen": false,
"layout": "overlay",
"toggleKey": "Backslash",
"pauseVideoOnHover": false,
"pauseVideoOnHover": true,
"autoScroll": true,
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
"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 |
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
| `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 |
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels |
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` |
+2 -2
View File
@@ -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**
- 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 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"**
SubMiner tries alass first, then falls back to ffsubsync. If both fail:
If subtitle sync fails:
- Ensure the reference subtitle track exists in the video (alass requires a source track).
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
+33 -23
View File
@@ -1,11 +1,23 @@
# Usage
## Quick Start
Play a video with SubMiner:
```bash
subminer video.mkv
```
On **Windows**, use the **SubMiner mpv** shortcut created during first-run setup — double-click it, or drag a video file onto it.
That's the simplest way to get started. The `subminer` launcher handles mpv, the IPC socket, and the overlay automatically.
> [!IMPORTANT]
> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
> See [Yomitan setup](#yomitan-setup) for details.
::: tip Just finished first-run setup?
If you want Anki card enrichment (sentence, audio, screenshot), the only config you need is `ankiConnect` with your deck name and field names. Here is a minimal working example:
::: tip Anki card enrichment
If you want sentence, audio, and screenshot fields on your Anki cards, add this to your config:
```jsonc
{
@@ -27,25 +39,22 @@ Field names must match your Anki note type exactly (case-sensitive). See [Anki I
## How It Works
1. SubMiner starts the overlay app in the background
2. MPV runs with an IPC socket at `/tmp/subminer-socket`
2. mpv runs with an IPC socket at `/tmp/subminer-socket`
3. The overlay connects and subscribes to subtitle changes
4. Subtitles are tokenized with Yomitan's internal parser
5. Words are displayed as interactive spans in the overlay
6. Hover a word, then trigger Yomitan lookup with your configured lookup key/modifier to open the Yomitan popup
7. Optional [subtitle annotations](/subtitle-annotations) (N+1, character-name, frequency, JLPT) highlight useful cues in real time
There are several ways to use SubMiner:
> [!TIP]
> **New users on Linux/macOS: start with the `subminer` wrapper script.** On Windows, use the **SubMiner mpv** shortcut created during first-run setup. Both handle mpv launch, IPC socket setup, and overlay lifecycle automatically so you don't need to configure anything in `mpv.conf`.
### Ways to Launch
| Approach | Use when | How |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| **`subminer` script** | You want SubMiner to handle everything — launch mpv, set up the socket, start the overlay. **The simplest path and recommended starting point.** | `subminer video.mkv` |
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults directly. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
| **MPV plugin** (all platforms) | You launch mpv yourself or from another tool (file manager, Jellyfin, etc.). Requires `--input-ipc-server=/tmp/subminer-socket` in your mpv config. | Use `y` chord keybindings inside mpv |
| **`subminer` launcher** | You want SubMiner to handle everything — launch mpv, set up the socket, start the overlay. **Recommended for most users.** | `subminer video.mkv` |
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
You can use both — the plugin provides in-player controls, while the `subminer` script (Linux/macOS) or the SubMiner mpv shortcut (Windows) is convenient for direct playback.
The mpv plugin is always available — it's bundled with SubMiner and injected at runtime. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
## Live Config Reload
@@ -72,8 +81,8 @@ subminer # Current directory (uses fzf)
subminer -R # Use rofi instead of fzf
subminer -d ~/Videos # Specific directory
subminer -r -d ~/Anime # Recursive search
subminer video.mkv # Play specific file (default plugin config auto-starts visible overlay)
subminer --start video.mkv # Optional explicit overlay start (use when plugin auto_start=no)
subminer video.mkv # Play specific file (overlay auto-starts)
subminer --start video.mkv # Explicit overlay start (use when auto_start=no in config)
subminer -S video.mkv # Same as above via --start-overlay
subminer https://youtu.be/... # Play a YouTube URL
subminer ytsearch:"jp news" # Play first YouTube search result
@@ -122,13 +131,14 @@ SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle
SubMiner.AppImage --start --dev # Enable app/dev mode only
SubMiner.AppImage --start --debug # Alias for --dev
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-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-libraries
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start or plugin workflow)
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start)
SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series
@@ -153,7 +163,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
### Windows mpv Shortcut
First-run setup creates the config file, then requires Yomitan dictionaries before it can finish. The global mpv plugin install is optional because SubMiner-managed mpv launches inject the bundled runtime plugin.
First-run setup creates the config file, then requires Yomitan dictionaries before it can finish.
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly.
After setup completes, the shortcut is the normal Windows playback entry point.
@@ -175,7 +185,8 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
- `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 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.
@@ -197,13 +208,12 @@ SubMiner.AppImage --setup
Setup flow:
- config file: create the default config directory and prefer `config.jsonc`
- plugin compatibility: optionally install the legacy global mpv plugin; managed launches use the bundled runtime plugin without it
- legacy plugin cleanup: remove detected global SubMiner mpv plugin files from mpv script directories via the OS trash when you do not want regular mpv to load SubMiner
- legacy plugin cleanup: remove detected older global SubMiner mpv plugin files if present (the bundled plugin is injected at runtime automatically)
- Yomitan shortcut: open bundled Yomitan settings directly from the setup window
- dictionary check: ensure at least one bundled Yomitan dictionary is available, unless an external Yomitan profile is configured
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
- Windows: optionally set `mpv.executablePath` if `mpv.exe` is not on `PATH`
- refresh: re-check plugin + dictionary state without restarting
- refresh: re-check dictionary state without restarting
- `Finish setup` stays disabled until the config and dictionary gates are satisfied
- finish action writes setup completion state and suppresses future auto-open prompts
@@ -256,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.
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.
@@ -336,9 +346,9 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
Press `V` to hide or restore the primary SubMiner subtitle bar. The mpv plugin also binds bare `v` to the same action, overriding mpv's native primary subtitle visibility toggle.
Press `V` to hide or restore the primary SubMiner subtitle bar. The bundled mpv plugin also binds bare `v` to the same action (injected at runtime).
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. If you use the mpv plugin, the same help view is also available through the `y-h` chord.
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
+32 -21
View File
@@ -52,7 +52,7 @@ If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only he
### 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`
- **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
```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
{
"version": 1,
@@ -91,16 +121,7 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
}
```
#### Field reference
| 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:
Each annotation token may include:
| Token field | Type | Notes |
| --- | --- | --- |
@@ -119,16 +140,6 @@ Each token may include:
| `frequencyRankLabel` | string or `null` | Preformatted rank 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
The `sentence` field is pre-rendered HTML generated by SubMiner. Depending on token state, it can include classes such as:
+3 -1
View File
@@ -89,7 +89,9 @@ Notes:
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS ZIP, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks. Linux GitHub release metadata and asset downloads also use `/usr/bin/curl` instead of Electron networking for the same reason.
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks.
- Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks.
- Linux GitHub release metadata and asset downloads also use `/usr/bin/curl` instead of Electron networking for the same reason.
- Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed.
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
+2 -2
View File
@@ -6,8 +6,8 @@ export function runAppPassthroughCommand(context: LauncherCommandContext): boole
if (!appPath) {
return false;
}
if (args.configSettings) {
runAppCommandWithInherit(appPath, ['--config']);
if (args.settings) {
runAppCommandWithInherit(appPath, ['--settings']);
return true;
}
if (!args.appPassthrough) {
+174 -1
View File
@@ -1,6 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
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 { runPlaybackCommandWithDeps } from './playback-command.js';
import { state } from '../mpv.js';
@@ -53,7 +56,7 @@ function createContext(): LauncherCommandContext {
doctor: false,
doctorRefreshKnownWords: false,
version: false,
configSettings: false,
settings: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -151,6 +154,7 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
useTexthooker: true,
};
context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock',
@@ -206,3 +210,172 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
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']);
});
+43 -16
View File
@@ -8,6 +8,7 @@ import {
cleanupPlaybackSession,
launchAppCommandDetached,
resolveLauncherRuntimePluginPath,
isRunningAppControlServerAvailable,
startMpv,
startOverlay,
state,
@@ -29,6 +30,13 @@ import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
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 {
const missing: string[] = [];
@@ -99,10 +107,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
const { args, appPath } = context;
if (!appPath) return;
const configDir = getDefaultConfigDir({
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
});
const configDir = getLauncherConfigDir();
const statePath = getSetupStatePath(configDir);
const ready = await ensureLauncherSetupReady({
readSetupState: () => readSetupState(statePath),
@@ -146,6 +151,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
waitForUnixSocketReady,
startOverlay,
launchAppCommandDetached,
isAppControlServerAvailable: isRunningAppControlServerAvailable,
log,
cleanupPlaybackSession,
getMpvProc: () => state.mpvProc,
@@ -164,6 +170,7 @@ type PlaybackCommandDeps = {
waitForUnixSocketReady: typeof waitForUnixSocketReady;
startOverlay: typeof startOverlay;
launchAppCommandDetached: typeof launchAppCommandDetached;
isAppControlServerAvailable?: (logLevel: Args['logLevel'], configDir: string) => Promise<boolean>;
log: typeof log;
cleanupPlaybackSession: typeof cleanupPlaybackSession;
getMpvProc: () => typeof state.mpvProc;
@@ -208,11 +215,23 @@ export async function runPlaybackCommandWithDeps(
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
const isAppOwnedYoutubeFlow = isYoutubeUrl;
const youtubeMode = args.youtubeMode ?? 'download';
const configDir = getLauncherConfigDir();
if (isYoutubeUrl) {
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 =
pluginRuntimeConfig.autoStart &&
pluginRuntimeConfig.autoStartVisibleOverlay &&
@@ -238,16 +257,19 @@ export async function runPlaybackCommandWithDeps(
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
runtimePluginConfig: {
...pluginRuntimeConfig,
...effectivePluginRuntimeConfig,
backend: args.backend,
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
},
},
);
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow;
const shouldStartOverlay =
args.startOverlay ||
args.autoStartOverlay ||
isAppOwnedYoutubeFlow ||
shouldLauncherAttachRunningApp;
if (shouldStartOverlay) {
if (ready) {
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',
);
}
await deps.startOverlay(
appPath,
args,
mpvSocketPath,
isAppOwnedYoutubeFlow
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
: [],
);
const extraAppArgs = isAppOwnedYoutubeFlow
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
: shouldLauncherAttachRunningApp
? [
pluginRuntimeConfig.autoStartVisibleOverlay
? '--show-visible-overlay'
: '--hide-visible-overlay',
...(args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled
? ['--texthooker']
: []),
]
: [];
await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs, configDir);
} else if (pluginAutoStartEnabled) {
if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
+10
View File
@@ -106,6 +106,16 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
assert.equal(parsed.aniskipButtonKey, 'F8');
});
test('parseLauncherMpvConfig ignores blank subminer binary paths', () => {
const parsed = parseLauncherMpvConfig({
mpv: {
subminerBinaryPath: ' ',
},
});
assert.equal(parsed.subminerBinaryPath, undefined);
});
test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
const parsed = parseLauncherMpvConfig({
mpv: {
+44 -4
View File
@@ -124,6 +124,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
action: 'show',
logLevel: 'warn',
},
settingsInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -159,13 +160,14 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
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({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: {
action: undefined,
configInvocation: null,
settingsInvocation: {
logLevel: undefined,
},
mpvInvocation: null,
appInvocation: null,
@@ -190,16 +192,54 @@ test('applyInvocationsToArgs maps bare config invocation to settings window', ()
texthookerOpenBrowser: false,
});
assert.equal(parsed.configSettings, true);
assert.equal(parsed.settings, true);
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', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: null,
settingsInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
+14 -5
View File
@@ -158,7 +158,7 @@ export function createDefaultArgs(
doctorRefreshKnownWords: false,
version: false,
update: false,
configSettings: false,
settings: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -222,7 +222,7 @@ export function applyRootOptionsToArgs(
if (options.rofi === true) parsed.useRofi = true;
if (options.update === true) parsed.update = 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.texthooker === false) parsed.useTexthooker = false;
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);
}
const action = (invocations.configInvocation.action || '').toLowerCase();
if (!action) parsed.configSettings = true;
else if (action === 'path') parsed.configPath = true;
if (action === 'path') parsed.configPath = 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) {
+16 -2
View File
@@ -22,6 +22,7 @@ export interface CommandActionInvocation {
export interface CliInvocations {
jellyfinInvocation: JellyfinInvocation | null;
configInvocation: CommandActionInvocation | null;
settingsInvocation: CommandActionInvocation | null;
mpvInvocation: CommandActionInvocation | null;
appInvocation: { appArgs: string[] } | null;
dictionaryTriggered: boolean;
@@ -58,7 +59,7 @@ function applyRootOptions(program: Command): void {
.option('--start', 'Explicitly start overlay')
.option('--log-level <level>', 'Log level')
.option('-v, --version', 'Show SubMiner version')
.option('--config', 'Open configuration window')
.option('--settings', 'Open settings window')
.option('-u, --update', 'Check for updates')
.option('-R, --rofi', 'Use rofi picker')
.option('-S, --start-overlay', 'Auto-start overlay')
@@ -88,6 +89,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'jf',
'doctor',
'config',
'settings',
'mpv',
'dictionary',
'dict',
@@ -138,6 +140,7 @@ export function parseCliPrograms(
} {
let jellyfinInvocation: JellyfinInvocation | null = null;
let configInvocation: CommandActionInvocation | null = null;
let settingsInvocation: CommandActionInvocation | null = null;
let mpvInvocation: CommandActionInvocation | null = null;
let appInvocation: { appArgs: string[] } | null = null;
let dictionaryTriggered = false;
@@ -293,7 +296,7 @@ export function parseCliPrograms(
commandProgram
.command('config')
.description('Config helpers')
.description('Config file helpers (path|show)')
.argument('[action]', 'path|show')
.option('--log-level <level>', 'Log level')
.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
.command('mpv')
.description('MPV helpers')
@@ -356,6 +369,7 @@ export function parseCliPrograms(
invocations: {
jellyfinInvocation,
configInvocation,
settingsInvocation,
mpvInvocation,
appInvocation,
dictionaryTriggered,
+1 -2
View File
@@ -37,8 +37,7 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
typeof mpv.autoStartSubMiner === 'boolean' ? mpv.autoStartSubMiner : undefined,
pauseUntilOverlayReady:
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
subminerBinaryPath:
typeof mpv.subminerBinaryPath === 'string' ? mpv.subminerBinaryPath.trim() : undefined,
subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath),
aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined,
aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey),
};
+8 -8
View File
@@ -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) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
@@ -249,14 +249,14 @@ test('launcher config option forwards app configuration window command', () => {
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['--config'], env);
const result = runLauncher(['--settings'], env);
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) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
@@ -273,14 +273,14 @@ test('launcher config command forwards app configuration window command', () =>
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['config'], env);
const result = runLauncher(['settings'], env);
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) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
@@ -301,7 +301,7 @@ test('launcher config command suppresses known Electron macOS menu diagnostics',
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
};
const result = runLauncher(['config'], env);
const result = runLauncher(['settings'], env);
assert.equal(result.status, 0);
assert.equal(result.stderr, 'real stderr line\n');
+357 -1
View File
@@ -6,6 +6,7 @@ import os from 'node:os';
import net from 'node:net';
import { EventEmitter } from 'node:events';
import type { Args } from './types';
import { getAppControlSocketPath } from '../src/shared/app-control';
import {
buildConfiguredMpvDefaultArgs,
buildMpvBackendArgs,
@@ -294,6 +295,15 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
});
});
test('buildConfiguredMpvDefaultArgs disables macOS menu shortcuts so SubMiner bindings reach mpv', () => {
withPlatform('darwin', () => {
assert.equal(
buildConfiguredMpvDefaultArgs(makeArgs()).includes('--macos-menu-shortcuts=no'),
true,
);
});
});
test('resolveLauncherRuntimePluginPath finds bundled plugin from explicit environment path', () => {
const pluginDir = '/opt/SubMiner/plugin/subminer';
assert.equal(
@@ -560,7 +570,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
doctor: false,
doctorRefreshKnownWords: false,
version: false,
configSettings: false,
settings: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -646,6 +656,352 @@ 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 () => {
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 0; 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, /--app-ping/);
assert.match(invocationText, /--start/);
assert.doesNotMatch(invocationText, /--background/);
assert.equal(state.overlayManagedByLauncher, false);
assert.equal(state.appPath, '');
} finally {
net.createConnection = originalCreateConnection;
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
fs.rmSync(dir, { recursive: true, force: true });
}
});
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 () => {
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 0; fi',
'exit 0',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(socketPath, '');
const originalCreateConnection = net.createConnection;
try {
state.appPath = appPath;
state.overlayManagedByLauncher = true;
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);
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('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned children', async () => {
const { dir } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
+107 -3
View File
@@ -4,6 +4,11 @@ import os from 'node:os';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
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 {
detectInstalledMpvPlugin,
type InstalledMpvPluginDetection,
@@ -1001,21 +1006,82 @@ export async function startOverlay(
args: Args,
socketPath: string,
extraAppArgs: string[] = [],
configDir: string = getLauncherConfigDir(),
): Promise<void> {
const backend = detectBackend(args.backend);
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
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.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, {
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(process.env, target.env),
});
attachAppProcessLogging(state.overlayProc);
markOverlayManagedByLauncher(appPath);
if (borrowingExistingApp) {
log(
'debug',
args.logLevel,
'SubMiner app is already running; launcher will not stop it after playback',
);
clearOverlayManagedByLauncher();
} else {
markOverlayManagedByLauncher(appPath);
}
const [socketReady] = await Promise.all([
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
@@ -1035,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 {
if (appPath) {
state.appPath = appPath;
@@ -1042,6 +1128,20 @@ export function markOverlayManagedByLauncher(appPath?: string): void {
state.overlayManagedByLauncher = true;
}
function clearOverlayManagedByLauncher(): void {
state.appPath = '';
state.overlayManagedByLauncher = false;
}
function isAppAlreadyRunning(appPath: string, logLevel: LogLevel): boolean {
const result = runSyncAppCommand(appPath, ['--app-ping'], false);
if (result.error) {
log('debug', logLevel, `App ping failed before overlay start: ${result.error.message}`);
return false;
}
return result.status === 0;
}
export function openUrlInDefaultBrowser(url: string, logLevel: LogLevel): void {
const target =
process.platform === 'darwin'
@@ -1248,6 +1348,10 @@ export function buildConfiguredMpvDefaultArgs(
const mpvArgs: string[] = [];
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
if (process.platform === 'darwin') {
// macOS menu accelerators do not reach mpv script bindings unless disabled.
mpvArgs.push('--macos-menu-shortcuts=no');
}
mpvArgs.push(...buildMpvBackendArgs(args, baseEnv));
mpvArgs.push(...buildMpvLaunchModeArgs(args.launchMode));
return mpvArgs;
+15 -7
View File
@@ -57,10 +57,10 @@ test('parseArgs captures mpv args string', () => {
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
});
test('parseArgs maps root config window option', () => {
const parsed = parseArgs(['--config'], 'subminer', {});
test('parseArgs maps root settings window option', () => {
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', () => {
@@ -107,10 +107,10 @@ test('parseArgs maps config show action', () => {
assert.equal(parsed.configPath, false);
});
test('parseArgs maps bare config command to settings window', () => {
const parsed = parseArgs(['config'], 'subminer', {});
test('parseArgs maps settings command to settings window', () => {
const parsed = parseArgs(['settings'], 'subminer', {});
assert.equal(parsed.configSettings, true);
assert.equal(parsed.settings, true);
assert.equal(parsed.configPath, 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', {});
assert.equal(parsed.configPath, true);
assert.equal(parsed.configSettings, false);
assert.equal(parsed.settings, false);
});
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);
});
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', () => {
const parsed = parseArgs(['mpv', 'idle'], 'subminer', {});
+146 -1
View File
@@ -133,6 +133,9 @@ if (entry.argv.includes('--start')) {
if (entry.argv.includes('--stop')) {
fs.appendFileSync(stopPath, JSON.stringify(entry) + '\\n');
}
if (entry.argv.includes('--app-ping')) {
process.exit(process.env.SUBMINER_FAKE_APP_RUNNING === '1' ? 0 : 1);
}
process.exit(0);
`,
@@ -235,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', () => {
const smokeCase = createSmokeCase('setup-state');
try {
@@ -292,7 +383,7 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
});
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 },
async () => {
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
@@ -327,7 +418,9 @@ test(
const appStartArgs = appStartEntries[0]?.argv;
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('--managed-playback'), true);
assert.equal((appStartArgs as string[]).includes('--backend'), true);
assert.equal((appStartArgs as string[]).includes('x11'), true);
assert.equal((appStartArgs as string[]).includes('--socket'), true);
@@ -347,6 +440,58 @@ test(
},
);
test(
'launcher start-overlay attaches to a running background app without spawning another app command',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => {
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
const controlServer = await startFakeControlServer(smokeCase);
const env = {
...makeTestEnv(smokeCase),
SUBMINER_FAKE_APP_RUNNING: '1',
SUBMINER_APP_CONTROL_SOCKET: controlServer.socketPath,
};
try {
const result = runLauncher(
smokeCase,
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
env,
'overlay-borrow-background',
);
const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
await waitForJsonLines(controlServer.logPath, 1);
const appEntries = readJsonLines(appLogPath);
const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath);
const controlEntries = readJsonLines(controlServer.logPath);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const mpvError = mpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.equal(appEntries.length, 0);
assert.equal(appStartEntries.length, 0);
assert.equal(appStopEntries.length, 0);
assert.equal(controlEntries.length, 1);
const controlArgs = controlEntries[0]?.argv;
assert.equal(Array.isArray(controlArgs), true);
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();
}
});
},
);
test(
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
+1 -1
View File
@@ -133,7 +133,7 @@ export interface Args {
doctorRefreshKnownWords: boolean;
version: boolean;
update?: boolean;
configSettings: boolean;
settings: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;
-4711
View File
File diff suppressed because it is too large Load Diff
-38
View File
@@ -19,7 +19,6 @@
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"vscode-json-languageservice": "^5.7.2",
"ws": "^8.19.0"
},
"devDependencies": {
@@ -744,12 +743,6 @@
"npm": ">=7.0.0"
}
},
"node_modules/@vscode/l10n": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz",
"integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
"license": "MIT"
},
"node_modules/@xhayper/discord-rpc": {
"version": "1.3.3",
"license": "ISC",
@@ -3950,37 +3943,6 @@
"node": ">=0.6.0"
}
},
"node_modules/vscode-json-languageservice": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.7.2.tgz",
"integrity": "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w==",
"license": "MIT",
"dependencies": {
"@vscode/l10n": "^0.0.18",
"jsonc-parser": "^3.3.1",
"vscode-languageserver-textdocument": "^1.0.12",
"vscode-languageserver-types": "^3.17.5",
"vscode-uri": "^3.1.0"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"license": "MIT"
},
"node_modules/wcwidth": {
"version": "1.0.1",
"dev": true,
+3 -4
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.15.0-beta.3",
"version": "0.15.0-beta.4",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -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: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: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/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: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/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: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/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: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",
@@ -118,7 +118,6 @@
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"vscode-json-languageservice": "^5.7.2",
"ws": "^8.19.0"
},
"devDependencies": {
+8 -2
View File
@@ -18,8 +18,14 @@ function M.create(ctx)
local function is_macos()
local platform = mp.get_property("platform") or ""
if platform == "macos" or platform == "darwin" then
return true
if platform ~= "" then
local normalized = platform:lower()
if normalized == "macos" or normalized == "darwin" or normalized == "osx" then
return true
end
if normalized == "windows" or normalized == "win32" or normalized == "linux" then
return false
end
end
local ostype = os.getenv("OSTYPE") or ""
return ostype:find("darwin") ~= nil
+81 -18
View File
@@ -1,5 +1,8 @@
local M = {}
local AUTO_START_SOCKET_RETRY_DELAY_SECONDS = 0.2
local AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS = 25
function M.create(ctx)
local mp = ctx.mp
local opts = ctx.opts
@@ -52,24 +55,95 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_auto_start, false)
end
local function rearm_managed_subtitle_defaults()
local function next_auto_start_retry_generation()
state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1
return state.auto_start_retry_generation
end
local function has_matching_subminer_socket()
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
return false
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("sid", "auto")
mp.set_property_native("secondary-sid", "auto")
return true
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)
if generation ~= state.auto_start_retry_generation then
return
end
if media_identity ~= nil and state.current_media_identity ~= media_identity then
return
end
if not resolve_auto_start_enabled() then
schedule_aniskip_fetch("file-loaded", 0)
return
end
local has_matching_socket = refresh_managed_subtitle_autoloading()
if not has_matching_socket then
if attempt < AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS then
mp.add_timeout(AUTO_START_SOCKET_RETRY_DELAY_SECONDS, function()
start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt + 1)
end)
return
end
subminer_log(
"info",
"lifecycle",
"Skipping auto-start: input-ipc-server does not match configured socket_path"
)
schedule_aniskip_fetch("file-loaded", 0)
return
end
process.start_overlay({
auto_start_trigger = true,
socket_path = opts.socket_path,
rearm_pause_until_ready = not same_media_loaded,
})
-- Give the overlay process a moment to initialize before querying AniSkip.
schedule_aniskip_fetch("overlay-start", 0.8)
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 media_identity = resolve_media_identity()
local retry_generation = next_auto_start_retry_generation()
local previous_media_identity = state.current_media_identity
local same_media_reload = (
media_identity ~= nil
and state.pending_reload_media_identity ~= nil
and media_identity == state.pending_reload_media_identity
)
local same_media_loaded = (
media_identity ~= nil
and previous_media_identity ~= nil
and media_identity == previous_media_identity
)
state.pending_reload_media_identity = nil
state.current_media_identity = media_identity
@@ -92,32 +166,18 @@ function M.create(ctx)
if not preserve_active_auto_start_gate then
process.disarm_auto_play_ready_gate()
end
has_matching_socket = rearm_managed_subtitle_defaults()
if should_auto_start then
if not has_matching_socket then
subminer_log(
"info",
"lifecycle",
"Skipping auto-start: input-ipc-server does not match configured socket_path"
)
schedule_aniskip_fetch("file-loaded", 0)
return
end
process.start_overlay({
auto_start_trigger = true,
socket_path = opts.socket_path,
})
-- Give the overlay process a moment to initialize before querying AniSkip.
schedule_aniskip_fetch("overlay-start", 0.8)
start_overlay_when_socket_ready(retry_generation, media_identity, same_media_loaded, 1)
return
end
refresh_managed_subtitle_autoloading()
schedule_aniskip_fetch("file-loaded", 0)
end
local function on_shutdown()
next_auto_start_retry_generation()
aniskip.clear_aniskip_state()
hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate()
@@ -126,6 +186,7 @@ function M.create(ctx)
end
local function register_lifecycle_hooks()
mp.register_event("start-file", on_start_file)
mp.register_event("file-loaded", on_file_loaded)
mp.register_event("shutdown", on_shutdown)
mp.register_event("file-loaded", function()
@@ -139,6 +200,8 @@ function M.create(ctx)
state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity()
return
end
next_auto_start_retry_generation()
state.current_media_identity = nil
state.pending_reload_media_identity = nil
if state.overlay_running and reason ~= "quit" then
process.hide_visible_overlay()
+19 -6
View File
@@ -207,7 +207,9 @@ function M.create(ctx)
end
if action == "start" then
table.insert(args, "--background")
if overrides.background ~= false then
table.insert(args, "--background")
end
table.insert(args, "--managed-playback")
local backend = resolve_backend(overrides.backend)
@@ -411,7 +413,15 @@ function M.create(ctx)
if overrides.auto_start_trigger == true then
subminer_log("debug", "process", "Auto-start ignored because overlay is already running")
local socket_path = overrides.socket_path or opts.socket_path
if not state.auto_play_ready_gate_armed then
local should_pause_until_ready = (
overrides.rearm_pause_until_ready == true
and resolve_visible_overlay_startup()
and resolve_pause_until_ready()
and has_matching_mpv_ipc_socket(socket_path)
)
if should_pause_until_ready then
arm_auto_play_ready_gate()
elseif not state.auto_play_ready_gate_armed then
disarm_auto_play_ready_gate()
end
local visibility_action = resolve_visible_overlay_startup()
@@ -497,10 +507,13 @@ function M.create(ctx)
end)
end
launch_overlay_with_retry(1)
if texthooker_enabled then
ensure_texthooker_running(function() end)
end
environment.is_subminer_app_running_async(function(app_running)
overrides.background = not app_running
launch_overlay_with_retry(1)
if texthooker_enabled then
ensure_texthooker_running(function() end)
end
end, { force_refresh = true })
end
local function start_overlay_from_script_message(...)
+2
View File
@@ -151,6 +151,8 @@ function M.create(ctx)
return { "--toggle-subtitle-sidebar" }
elseif action_id == "markAudioCard" then
return { "--mark-audio-card" }
elseif action_id == "markWatched" then
return { "--mark-watched" }
elseif action_id == "openRuntimeOptions" then
return { "--open-runtime-options" }
elseif action_id == "openJimaku" then
+1
View File
@@ -37,6 +37,7 @@ function M.new()
force_ready_overlay_restore = false,
current_media_identity = nil,
pending_reload_media_identity = nil,
auto_start_retry_generation = 0,
session_binding_generation = 0,
session_binding_names = {},
session_numeric_binding_names = {},
+64 -8
View File
@@ -1,35 +1,91 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
## Highlights
### Breaking Changes
- **Settings Window:** The Configuration window is now called the Settings window everywhere — UI, tray menu, docs, and CLI. `--config` and `subminer config` (no action) are replaced by `--settings` and `subminer settings`; `subminer config` now only accepts `path` or `show`. The `--settings` alias that previously opened the Yomitan settings popup is removed — use `--yomitan` instead.
### Added
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`. Options are organized into Appearance, Behavior, Anki, Input, and Integration sections with learned keybinding controls, AnkiConnect-backed deck/field/note-type pickers, and live reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, and Anki field mappings. AI and translation fields remain supported in config files only.
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`. Checks include checksum verification, configurable notifications, and an opt-in channel for prerelease builds. The `subminer` launcher and Linux rofi theme are also updated automatically.
- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH.
- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH. First-run setup also includes an Open SubMiner Settings button.
- **Launcher:** `subminer --version` / `subminer -v` now prints the installed SubMiner app version.
### Changed
- **Settings Window:** Option rows no longer display raw config paths; live/restart status is shown inline beside each option title. Known-words deck rows are now cards with the deck name on a separate header line so long names remain readable. Playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls have been reorganized.
- **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`. Existing configs are migrated automatically. Sidebar appearance is now configured via `subtitleSidebar.css`; the default subtitle font is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`.
- **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys are still accepted with deprecation warnings. Existing configs that had known-word highlighting enabled retain N+1 highlighting; new configs leave N+1 disabled unless `ankiConnect.nPlusOne.enabled` is explicitly set.
- **Linux Updater:** Tray "Check for Updates" now automatically installs the new AppImage via `electron-updater`, matching the macOS and Windows tray flow. AppImages managed by a system package (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow.
- **Subsync:** Always opens the manual subtitle picker. The `subsync.defaultMode` config option has been removed.
- **Jellyfin:** The server presets dropdown in Jellyfin setup is removed; setup now shows a single editable server URL field.
- **AniSkip:** The key binding setting now uses click-to-learn key capture instead of raw text entry.
- **Setup:** The bundled mpv runtime plugin readiness card is removed from first-run setup; the legacy mpv plugin removal notice still appears when needed.
- **Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
### Fixed
- **macOS Overlay:** Significantly improved overlay focus and stability: the overlay now hides when mpv loses focus or is minimized, stays stable through transient window-tracking misses, remains correctly layered during stats mouse passthrough, and opens over fullscreen mpv without switching Spaces. Passthrough is also fixed so mpv controls stay clickable before hovering a subtitle bar. Background tracking overhead is reduced while mpv is stably focused.
- **macOS Overlay:** Significantly improved overlay focus and stability: the overlay now hides when mpv loses focus or is minimized, stays stable through transient window-tracking misses, remains correctly layered during stats mouse passthrough, and opens over fullscreen mpv without switching Spaces. Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. Background tracking overhead is reduced while mpv is stably focused.
- **Subtitle Sync Modal:** Fixed a macOS issue where opening the subtitle sync modal would flash and disappear on the first attempt, or leave stale state after syncing.
- **Controller:** Controller config and debug shortcuts now stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge, remaps are saved per controller profile, and individual bindings can be reset to their defaults.
- **AniList Progress:** Progress threshold checks now use fresh playback position data, so updates fire correctly when playback reaches or skips past the watched threshold. Season-specific results are preferred for multi-season files, and a clear message is shown when the matched season is not in Planning or Watching status.
- **AniList Progress:** Progress threshold checks now use fresh playback position data so updates fire correctly when playback reaches or skips past the watched threshold. Season-specific results are preferred for multi-season files, and a clear message is shown when the matched season is not in Planning or Watching status.
- **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits.
- **Updater - Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists.
- **Updater Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists. Update check traffic is routed through `/usr/bin/curl` to avoid Electron network-service crashes during video startup.
- **Updater - macOS:** Update dialogs now come to the front when launched from `subminer --update`. Builds that cannot install native updates show a manual-install message instead of an inapplicable restart prompt. Signed macOS builds remain on the native updater path without triggering premature Squirrel install checks.
- **Updater macOS:** Update dialogs now reliably come to the front when launched from `subminer --update`. Builds that cannot install native updates show a manual-install message instead of an inapplicable restart prompt. Signed macOS builds remain on the native `electron-updater`/Squirrel path; supplemental GitHub release lookups are routed through `/usr/bin/curl`, eliminating the last Electron-networking path from background update checks.
- **Setup - macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, returning control to the terminal.
- **Updater — Windows:** Automatic updates keep the native `electron-updater`/NSIS install path enabled while routing updater HTTP through main-process fetch, avoiding the delayed app exit seen shortly after launch.
- **Launcher - Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
- **Setup — macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed — both return control to the terminal without requiring Ctrl+C.
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running.
- **Build - Linux Install:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
- **Launcher — Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
- **Launcher:** Launcher-opened videos now reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends.
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay can render text before video playback begins.
- **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered.
- **macOS Shortcuts:** Native mpv menu shortcuts are disabled during managed macOS playback so configured SubMiner shortcuts work while mpv has focus. Session shortcuts including `stats.markWatchedKey` are now correctly wired through mpv.
- **Overlay Restart:** The visible overlay and subtitle stream now stay alive after restarting SubMiner from the `y-r` shortcut, with correct bounds reapplication on Linux and user-paused playback preserved through readiness gates.
- **WebSocket:** The subtitle WebSocket is now plain-text only; annotation spans and token metadata are sent exclusively on the annotation WebSocket.
- **Jellyfin:** Fixed the setup popup login path on Windows using an IPC bridge, with immediate login progress feedback and a timeout for unreachable server attempts.
- **Windows:** Startup failures now show a native error dialog and write fatal details to the app log instead of exiting silently.
- **Yomitan:** Fixed Yomitan popups not opening when overlay startup races the Yomitan extension load.
- **Settings:** Settings window search now searches across all categories, narrows correctly on multi-word terms, and hides settings with dedicated editors. Live saves for subtitle CSS declarations apply immediately to open overlays. Legacy subtitle appearance options and hover token colors are automatically migrated into `subtitleStyle.css`.
- **Config:** User config files are preserved during legacy compatibility handling. The note-fields note-type picker now defaults to the configured Anki deck's note type, falling back to `Kiku`, then `Lapis`, then blank for manual selection.
- **Build — Linux:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
### Docs
- **Versioned Docs:** Stable docs are now published at the site root with current development docs under `/main/`. Fixed versioned docs navigation so archived pages keep local links under the selected version, the version switcher no longer nests paths incorrectly, local dev version routes serve warmed archive files instead of redirecting to production, and internal README files no longer break archived builds.
## Installation
+9
View File
@@ -220,6 +220,14 @@ local ctx = {
actionType = "mpv-command",
command = { "quit" },
},
{
key = {
code = "KeyW",
modifiers = {},
},
actionType = "session-action",
actionId = "markWatched",
},
{
key = {
code = "KeyA",
@@ -307,6 +315,7 @@ local expected_cli_bindings = {
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
{ keys = "Ctrl+L", flag = "--play-next-subtitle" },
{ keys = "w", flag = "--mark-watched" },
}
for _, expected in ipairs(expected_cli_bindings) do
+194 -9
View File
@@ -23,6 +23,11 @@ local function run_plugin_scenario(config)
return config.platform or "linux"
end
if name == "input-ipc-server" then
if config.input_ipc_server_sequence then
config.input_ipc_server_sequence_index = (config.input_ipc_server_sequence_index or 0) + 1
local index = config.input_ipc_server_sequence_index
return config.input_ipc_server_sequence[index] or config.input_ipc_server_sequence[#config.input_ipc_server_sequence] or ""
end
return config.input_ipc_server or ""
end
if name == "filename/no-ext" then
@@ -638,6 +643,97 @@ do
)
end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/episode-01.mkv",
media_title = "Episode 1",
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for new-media rearm scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
fire_event(recorded, "end-file", { reason = "eof" })
scenario.path = "/media/episode-02.mkv"
scenario.media_title = "Episode 2"
fire_event(recorded, "file-loaded")
assert_true(
count_start_calls(recorded.async_calls) == 1,
"new media after prior playback should reuse the running overlay"
)
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 2,
"new media after prior playback should re-arm pause-until-ready"
)
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 2,
"new media after prior playback should resume only after readiness"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server_sequence = { "", "", "/tmp/subminer-socket" },
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for delayed socket auto-start scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(find_start_call(recorded.async_calls) ~= nil, "delayed socket auto-start should eventually issue --start")
assert_true(
has_property_set(recorded.property_sets, "pause", true),
"delayed socket auto-start should arm pause-until-ready once the socket is available"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
platform = "osx",
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 macOS platform alias scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "macOS platform alias auto-start should issue --start")
assert_true(
call_has_arg(start_call, "macos"),
"macOS platform alias auto-start should pass macos backend instead of falling back to x11"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
@@ -663,7 +759,14 @@ do
assert_true(env_has(call, "PATH=/usr/bin"), "AppImage subprocess should preserve existing environment")
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_1=--background"), "AppImage subprocess should transport --background")
assert_true(
env_has(call, "SUBMINER_APP_ARG_1=--background"),
"AppImage subprocess should transport --background"
)
assert_true(
env_has(call, "SUBMINER_APP_ARG_2=--managed-playback"),
"AppImage subprocess should transport --managed-playback"
)
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_MPV_LOG="), "AppImage subprocess should include mpv log env")
@@ -992,18 +1095,54 @@ do
},
})
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(
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(
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(
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
@@ -1170,10 +1309,13 @@ do
fire_event(recorded, "file-loaded")
local start_call = find_start_call(recorded.async_calls)
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, "--background"),
"auto-start should launch SubMiner in background/tray mode"
)
assert_true(
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(find_control_call(recorded.async_calls, "--texthooker") == nil, "auto-start should not issue a separate texthooker helper command")
@@ -1490,7 +1632,7 @@ do
[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, "end-file", { reason = "quit" })
assert_true(
@@ -1500,7 +1642,7 @@ do
fire_event(recorded, "shutdown")
assert_true(
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(
find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil,
@@ -1508,6 +1650,41 @@ do
)
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
local recorded, err = run_plugin_scenario({
process_list = "",
@@ -1527,6 +1704,14 @@ do
fire_event(recorded, "file-loaded")
local start_call = find_start_call(recorded.async_calls)
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(
call_has_arg(start_call, "--hide-visible-overlay"),
"auto-start with visible overlay disabled should include --hide-visible-overlay on --start"
+4 -1
View File
@@ -92,7 +92,10 @@ for artifact in "$appimage" "$wrapper" "$assets"; do
fi
done
mapfile -t sha256sums < <(sha256sum "$appimage" "$wrapper" "$assets" | awk '{print $1}')
sha256sums=()
while IFS=' ' read -r sum _; do
sha256sums+=("$sum")
done < <(sha256sum "$appimage" "$wrapper" "$assets")
tmpfile="$(mktemp)"
awk \
+4 -1
View File
@@ -147,7 +147,10 @@ test('AnkiConnectClient treats negative deck note sample sizes as empty samples'
},
};
assert.deepEqual(await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining', -1), []);
assert.deepEqual(
await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining', -1),
[],
);
assert.deepEqual(
calls.map((call) => call.action),
['findNotes'],
+4 -1
View File
@@ -188,7 +188,10 @@ export class AnkiConnectClient {
}
const finiteSampleSize = Number.isFinite(sampleSize) ? sampleSize : 0;
const normalizedSampleSize = Math.min(noteIds.length, Math.max(0, Math.floor(finiteSampleSize)));
const normalizedSampleSize = Math.min(
noteIds.length,
Math.max(0, Math.floor(finiteSampleSize)),
);
if (normalizedSampleSize === 0) {
return [];
}
+26 -20
View File
@@ -7,7 +7,7 @@ import {
isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
parseArgs,
shouldRunSettingsOnlyStartup,
shouldRunYomitanOnlyStartup,
shouldStartApp,
} from './args';
@@ -66,7 +66,7 @@ test('parseArgs captures update command and internal launcher paths', () => {
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
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', () => {
@@ -94,6 +94,7 @@ test('parseArgs captures youtube startup forwarding flags', () => {
test('parseArgs captures session action forwarding flags', () => {
const args = parseArgs([
'--toggle-stats-overlay',
'--mark-watched',
'--open-jimaku',
'--open-youtube-picker',
'--open-playlist-browser',
@@ -110,6 +111,7 @@ test('parseArgs captures session action forwarding flags', () => {
]);
assert.equal(args.toggleStatsOverlay, true);
assert.equal(args.markWatched, true);
assert.equal(args.openJimaku, true);
assert.equal(args.openYoutubePicker, true);
assert.equal(args.openPlaylistBrowser, true);
@@ -206,35 +208,33 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(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']);
assert.equal(settings.settings, true);
assert.equal(hasExplicitCommand(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']);
assert.equal(configSettings.configSettings, true);
assert.equal(hasExplicitCommand(configSettings), true);
assert.equal(shouldStartApp(configSettings), true);
assert.equal(shouldRunSettingsOnlyStartup(configSettings), false);
assert.equal(commandNeedsOverlayRuntime(configSettings), false);
assert.equal(commandNeedsOverlayStartupPrereqs(configSettings), false);
const yomitanWithOverlay = parseArgs(['--yomitan', '--toggle-visible-overlay']);
assert.equal(yomitanWithOverlay.yomitan, true);
assert.equal(yomitanWithOverlay.toggleVisibleOverlay, true);
assert.equal(shouldRunYomitanOnlyStartup(yomitanWithOverlay), false);
const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']);
assert.equal(settingsWithOverlay.settings, true);
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 settingsDoesNotEnableYomitan = parseArgs(['--settings']);
assert.equal(settingsDoesNotEnableYomitan.yomitan, false);
const help = parseArgs(['--help']);
assert.equal(help.help, true);
assert.equal(hasExplicitCommand(help), true);
assert.equal(shouldStartApp(help), false);
assert.equal(shouldRunSettingsOnlyStartup(help), false);
assert.equal(shouldRunYomitanOnlyStartup(help), false);
const appPing = parseArgs(['--app-ping']);
assert.equal(appPing.appPing, true);
@@ -285,6 +285,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
const markWatched = parseArgs(['--mark-watched']);
assert.equal(markWatched.markWatched, true);
assert.equal(hasExplicitCommand(markWatched), true);
assert.equal(shouldStartApp(markWatched), true);
assert.equal(commandNeedsOverlayRuntime(markWatched), true);
const dictionary = parseArgs(['--dictionary']);
assert.equal(dictionary.dictionary, true);
assert.equal(hasExplicitCommand(dictionary), true);
+18 -10
View File
@@ -10,8 +10,8 @@ export interface CliArgs {
toggle: boolean;
toggleVisibleOverlay: boolean;
togglePrimarySubtitleBar: boolean;
yomitan: boolean;
settings: boolean;
configSettings: boolean;
setup: boolean;
show: boolean;
hide: boolean;
@@ -28,6 +28,7 @@ export interface CliArgs {
triggerSubsync: boolean;
markAudioCard: boolean;
toggleStatsOverlay: boolean;
markWatched: boolean;
toggleSubtitleSidebar: boolean;
openRuntimeOptions: boolean;
openSessionHelp: boolean;
@@ -116,8 +117,8 @@ export function parseArgs(argv: string[]): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -134,6 +135,7 @@ export function parseArgs(argv: string[]): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
@@ -237,8 +239,8 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--toggle') args.toggle = true;
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
else if (arg === '--config') args.configSettings = true;
else if (arg === '--yomitan') args.yomitan = true;
else if (arg === '--settings') args.settings = true;
else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true;
else if (arg === '--hide') args.hide = true;
@@ -255,6 +257,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--trigger-subsync') args.triggerSubsync = true;
else if (arg === '--mark-audio-card') args.markAudioCard = true;
else if (arg === '--toggle-stats-overlay') args.toggleStatsOverlay = true;
else if (arg === '--mark-watched') args.markWatched = true;
else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true;
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
else if (arg === '--open-session-help') args.openSessionHelp = true;
@@ -491,8 +494,8 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.settings ||
args.configSettings ||
args.setup ||
args.show ||
args.hide ||
@@ -509,6 +512,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.triggerSubsync ||
args.markAudioCard ||
args.toggleStatsOverlay ||
args.markWatched ||
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
@@ -565,8 +569,8 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.yomitan &&
!args.settings &&
!args.configSettings &&
!args.setup &&
!args.show &&
!args.hide &&
@@ -583,6 +587,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.triggerSubsync &&
!args.markAudioCard &&
!args.toggleStatsOverlay &&
!args.markWatched &&
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
@@ -634,8 +639,8 @@ export function shouldStartApp(args: CliArgs): boolean {
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.settings ||
args.configSettings ||
args.setup ||
args.copySubtitle ||
args.copySubtitleMultiple ||
@@ -648,6 +653,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.triggerSubsync ||
args.markAudioCard ||
args.toggleStatsOverlay ||
args.markWatched ||
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
@@ -681,16 +687,16 @@ export function shouldStartApp(args: CliArgs): boolean {
return false;
}
export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
return (
args.settings &&
args.yomitan &&
!args.background &&
!args.start &&
!args.stop &&
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.configSettings &&
!args.settings &&
!args.show &&
!args.hide &&
!args.setup &&
@@ -707,6 +713,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.triggerSubsync &&
!args.markAudioCard &&
!args.toggleStatsOverlay &&
!args.markWatched &&
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
@@ -768,6 +775,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.updateLastCardFromClipboard ||
args.toggleSecondarySub ||
args.toggleStatsOverlay ||
args.markWatched ||
args.toggleSubtitleSidebar ||
args.triggerFieldGrouping ||
args.triggerSubsync ||
+3 -1
View File
@@ -22,7 +22,9 @@ test('printHelp includes configured texthooker port', () => {
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
assert.doesNotMatch(output, /--refresh-known-words/);
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, /--anilist-status/);
assert.match(output, /--anilist-retry-queue/);
assert.match(output, /--dictionary/);
+3 -2
View File
@@ -24,8 +24,8 @@ ${B}Overlay${R}
--toggle-primary-subtitle-bar Toggle primary subtitle bar
--show-visible-overlay Show subtitle overlay
--hide-visible-overlay Hide subtitle overlay
--settings Open Yomitan settings window
--config Open configuration window
--yomitan Open Yomitan settings window
--settings Open SubMiner settings window
--setup Open first-run setup window
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
@@ -39,6 +39,7 @@ ${B}Mining${R}
--trigger-field-grouping Run Kiku field grouping
--trigger-subsync Run subtitle sync
--toggle-secondary-sub Cycle secondary subtitle mode
--mark-watched Mark current video watched and advance playlist
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
--open-runtime-options Open runtime options palette
--open-session-help Open session help modal
+27 -3
View File
@@ -30,6 +30,8 @@ const LEGACY_N_PLUS_ONE_PATH_MAP = {
nPlusOne: 'subtitleStyle.nPlusOneColor',
} 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 {
return propertyNode.children?.[0]?.value;
}
@@ -82,6 +84,12 @@ function normalizeLegacyDecks(value: unknown): unknown {
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): {
operations: ConfigSettingsPatchOperation[];
hasLegacy: boolean;
@@ -90,9 +98,9 @@ function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
const ankiConnect = propertyValue(findLastProperty(root, 'ankiConnect'));
const nPlusOneProperties = findProperties(ankiConnect, 'nPlusOne');
const nPlusOneObjects = nPlusOneProperties.map(propertyValue).filter(Boolean) as JsoncNode[];
if (nPlusOneObjects.length === 0) {
return { operations, hasLegacy: false };
}
const knownWords = propertyValue(findLastProperty(ankiConnect, 'knownWords'));
const knownWordsColorNode = propertyValue(findLastProperty(knownWords, 'color'));
const knownWordsColor = knownWordsColorNode ? getNodeValue(knownWordsColorNode) : undefined;
const canonicalNPlusOneValues = new Map<string, 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 };
}
+38 -100
View File
@@ -89,7 +89,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.startupWarmups.mecab, true);
assert.equal(config.startupWarmups.yomitanExtension, true);
assert.equal(config.startupWarmups.subtitleDictionaries, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A');
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
@@ -101,8 +101,9 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
assert.equal(config.subtitleSidebar.enabled, true);
assert.equal(config.subtitleSidebar.pauseVideoOnHover, true);
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
assert.equal(config.subtitleStyle.fontWeight, '600');
assert.equal(config.subtitleStyle.lineHeight, 1.35);
@@ -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 configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
const originalContent = `{
"subtitleStyle": {
"fontSize": 42,
"fontColor": "#ffffff",
@@ -251,63 +250,29 @@ test('migrates legacy subtitle appearance options into css declaration objects o
"font-size": "19px"
}
}
}`,
'utf-8',
);
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
const service = new ConfigService(dir);
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
subtitleStyle: {
fontSize?: unknown;
fontColor?: unknown;
hoverTokenColor?: unknown;
hoverTokenBackgroundColor?: unknown;
css?: Record<string, string>;
secondary?: {
fontSize?: unknown;
fontColor?: unknown;
css?: Record<string, string>;
};
};
subtitleSidebar: {
fontFamily?: unknown;
fontSize?: unknown;
textColor?: unknown;
timestampColor?: unknown;
css?: Record<string, string>;
};
};
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
assert.deepEqual(parsed.subtitleStyle.css, {
assert.deepEqual(service.getConfig().subtitleStyle.css, {
color: '#ffffff',
'font-size': '44px',
'--subtitle-hover-token-color': '#abcdef',
'--subtitle-hover-token-background-color': 'transparent',
'text-wrap': 'balance',
});
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontSize'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontColor'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenColor'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenBackgroundColor'), false);
assert.deepEqual(parsed.subtitleStyle.secondary?.css, {
assert.deepEqual(service.getConfig().subtitleStyle.secondary.css, {
color: '#bbbbbb',
'font-size': '28px',
});
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontSize'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontColor'), false);
assert.deepEqual(parsed.subtitleSidebar.css, {
assert.deepEqual(service.getConfig().subtitleSidebar.css, {
'font-family': 'M PLUS 1, sans-serif',
color: '#dddddd',
'font-size': '19px',
'--subtitle-sidebar-timestamp-color': '#aaaaaa',
});
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'fontFamily'), false);
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'fontSize'), false);
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'textColor'), false);
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'timestampColor'), false);
assert.equal(service.getConfig().subtitleStyle.css['font-size'], '44px');
assert.equal(service.getConfig().subtitleStyle.secondary.css['font-size'], '28px');
assert.equal(service.getConfig().subtitleSidebar.css['font-size'], '19px');
});
test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => {
@@ -2067,12 +2032,10 @@ test('ignores invalid legacy ankiConnect n+1 color value after migration attempt
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
});
test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => {
test('resolves legacy ankiConnect n+1 color value without rewriting config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
const originalContent = `{
"ankiConnect": {
"nPlusOne": {
"nPlusOne": "#c6a0f6"
@@ -2081,31 +2044,31 @@ test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => {
"color": "#a6da95"
}
}
}`,
'utf-8',
);
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: { nPlusOne?: Record<string, unknown> };
subtitleStyle: { nPlusOneColor?: string; knownWordColor?: string };
};
assert.equal(parsed.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, 'nPlusOne'), false);
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
});
test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', () => {
test('legacy migration failures are logged and rethrown', () => {
const source = fs.readFileSync(path.join(process.cwd(), 'src/config/service.ts'), 'utf-8');
const catchBlock = source.match(/catch\s*\(error\)\s*\{(?<body>[\s\S]*?)\n \}/)?.groups?.body;
assert.ok(catchBlock);
assert.match(catchBlock, /legacy config migration failed/);
assert.match(catchBlock, /console\.error/);
assert.match(catchBlock, /throw error;/);
});
test('resolves legacy ankiConnect nPlusOne known-word settings without rewriting config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
const originalContent = `{
"ankiConnect": {
"nPlusOne": {
"highlightEnabled": true,
@@ -2115,22 +2078,15 @@ test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', (
"knownWord": "#a6da95"
}
}
}`,
'utf-8',
);
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: {
knownWords: Record<string, unknown>;
nPlusOne?: Record<string, unknown>;
};
subtitleStyle: { knownWordColor?: string };
};
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.deepEqual(config.ankiConnect.knownWords.decks, {
@@ -2138,28 +2094,14 @@ test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', (
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
});
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(parsed.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(parsed.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(parsed.ankiConnect.knownWords.matchMode, 'surface');
assert.deepEqual(parsed.ankiConnect.knownWords.decks, {
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
});
assert.equal(parsed.subtitleStyle.knownWordColor, '#a6da95');
assert.ok(
['highlightEnabled', 'refreshMinutes', 'matchMode', 'decks', 'knownWord'].every(
(key) => !Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, key),
),
);
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
assert.ok(warnings.every((warning) => !warning.path.startsWith('ankiConnect.nPlusOne.')));
});
test('migrates duplicate ankiConnect nPlusOne objects to the modal path', () => {
test('resolves duplicate ankiConnect nPlusOne objects without rewriting config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
const originalContent = `{
"ankiConnect": {
"nPlusOne": {
"enabled": true,
@@ -2172,19 +2114,14 @@ test('migrates duplicate ankiConnect nPlusOne objects to the modal path', () =>
"minSentenceWords": "3"
}
}
}`,
'utf-8',
);
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
const service = new ConfigService(dir);
const config = service.getConfig();
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: { nPlusOne: Record<string, unknown> };
};
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, '3');
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
});
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
@@ -2208,6 +2145,7 @@ test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.ok(
+1 -2
View File
@@ -105,7 +105,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
primarySubLanguages: ['ja', 'jpn'],
},
subsync: {
defaultMode: 'auto',
alass_path: '',
ffsubsync_path: '',
ffmpeg_path: '',
@@ -116,7 +115,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
mecab: true,
yomitanExtension: true,
subtitleDictionaries: true,
jellyfinRemoteSession: true,
jellyfinRemoteSession: false,
},
updates: {
enabled: true,
+3 -3
View File
@@ -9,8 +9,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
autoPauseVideoOnHover: true,
autoPauseVideoOnYomitanPopup: true,
hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
nameMatchEnabled: true,
hoverTokenBackgroundColor: 'transparent',
nameMatchEnabled: false,
nameMatchColor: '#f5bde6',
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 35,
@@ -69,7 +69,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
pauseVideoOnHover: true,
autoScroll: true,
css: {},
maxWidth: 420,
@@ -132,7 +132,7 @@ test('n+1 annotation color has one public config path', () => {
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
assert.ok(leaves.includes('subtitleStyle.nPlusOneColor'));
assert.ok(!leaves.includes('ankiConnect.nPlusOne.nPlusOne'));
assert.ok(!leaves.includes('ankiConnect.nPlusOne.color'));
});
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
-7
View File
@@ -388,13 +388,6 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.annotationWebsocket.port,
description: 'Annotated subtitle websocket server port.',
},
{
path: 'subsync.defaultMode',
kind: 'enum',
enumValues: ['auto', 'manual'],
defaultValue: defaultConfig.subsync.defaultMode,
description: 'Subsync default mode.',
},
{
path: 'subsync.replace',
kind: 'boolean',
+6 -2
View File
@@ -33,6 +33,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Logging',
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
notes: ['Hot-reload: logging.level applies live while SubMiner is running.'],
key: 'logging',
},
{
@@ -89,8 +90,9 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
key: 'secondarySub',
},
{
title: 'Auto Subtitle Sync',
title: 'Subtitle Sync',
description: ['Subsync engine and executable paths.'],
notes: ['Hot-reload: subsync changes apply to the next subtitle sync run.'],
key: 'subsync',
},
{
@@ -127,7 +129,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'],
notes: [
'Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running.',
'Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.',
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
'Most other AnkiConnect settings still require restart.',
],
@@ -136,6 +138,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Jimaku',
description: ['Jimaku API configuration and defaults.'],
notes: ['Hot-reload: Jimaku changes apply to the next Jimaku request.'],
key: 'jimaku',
},
{
@@ -143,6 +146,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
description: [
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
],
notes: ['Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.'],
key: 'youtube',
},
{
+23
View File
@@ -84,6 +84,29 @@ test('accepts knownWords.addMinedWordsImmediately boolean override', () => {
);
});
test('enables n+1 for existing configs with known-word highlighting enabled', () => {
const { context } = makeContext({
knownWords: { highlightEnabled: true },
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, true);
});
test('keeps explicit n+1 disabled when known-word highlighting is enabled', () => {
const { context } = makeContext({
knownWords: { highlightEnabled: true },
nPlusOne: { enabled: false },
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, false);
});
test('converts legacy knownWords.decks array to object with default fields', () => {
const { context, warnings } = makeContext({
knownWords: { decks: ['Core Deck'] },
+2
View File
@@ -758,6 +758,8 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
'Expected boolean.',
);
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
} else if (context.resolved.ankiConnect.knownWords.highlightEnabled === true) {
context.resolved.ankiConnect.nPlusOne.enabled = true;
} else {
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
}
-7
View File
@@ -273,13 +273,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
}
if (isObject(src.subsync)) {
const mode = src.subsync.defaultMode;
if (mode === 'auto' || mode === 'manual') {
resolved.subsync.defaultMode = mode;
} else if (mode !== undefined) {
warn('subsync.defaultMode', mode, resolved.subsync.defaultMode, 'Expected auto or manual.');
}
const alass = asString(src.subsync.alass_path);
if (alass !== undefined) resolved.subsync.alass_path = alass;
const ffsubsync = asString(src.subsync.ffsubsync_path);
+4 -2
View File
@@ -31,12 +31,14 @@ const SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY = '--subtitle-hover-token-bac
function applySubtitleHoverTokenCssCompatibility(
subtitleStyle: ResolvedConfig['subtitleStyle'],
): void {
const hoverTokenColor = subtitleStyle.css[SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY];
const hoverTokenColor = asCssColor(subtitleStyle.css[SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY]);
if (hoverTokenColor !== undefined) {
subtitleStyle.hoverTokenColor = hoverTokenColor;
}
const hoverTokenBackgroundColor = subtitleStyle.css[SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY];
const hoverTokenBackgroundColor = asCssColor(
subtitleStyle.css[SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY],
);
if (hoverTokenBackgroundColor !== undefined) {
subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
}
+17 -1
View File
@@ -74,6 +74,22 @@ test('subtitleStyle css declarations accept string declaration maps and warn on
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.secondary.css'));
});
test('subtitleStyle hover css compatibility ignores invalid color declarations', () => {
const { context } = createResolveContext({
subtitleStyle: {
css: {
'--subtitle-hover-token-color': 'purple',
'--subtitle-hover-token-background-color': '#363a4fd6',
},
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(context.resolved.subtitleStyle.hoverTokenBackgroundColor, '#363a4fd6');
});
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
const { context, warnings } = createResolveContext({
subtitleStyle: {
@@ -146,7 +162,7 @@ test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, true);
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, false);
assert.ok(
warnings.some(
(warning) =>
+3 -3
View File
@@ -149,10 +149,10 @@ export class ConfigService {
if (!migrated) {
return rawConfig;
}
fs.writeFileSync(configPath, content, 'utf-8');
return rawConfig;
} catch {
return config;
} catch (error) {
console.error(`[ConfigService] legacy config migration failed for ${configPath}:`, error);
throw error;
}
}
}
+31
View File
@@ -65,6 +65,37 @@ test('applyConfigSettingsPatchToContent updates effective duplicate object path'
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
});
test('applyConfigSettingsPatchToContent removes duplicate properties across JSONC trivia', () => {
const input = `{
"ankiConnect": {
"nPlusOne": {
"enabled": false
} /* old value */ ,
// effective value follows
"nPlusOne": {
"minSentenceWords": 3
}
}
}`;
const result = applyConfigSettingsPatchToContent({
content: input,
operations: [
{
op: 'set',
path: 'ankiConnect.nPlusOne.enabled',
value: true,
},
],
previousWarnings: [],
});
assert.equal(result.ok, true);
const parsed = parse(result.content);
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
});
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
const input = `{
"subtitleStyle": {
+58 -5
View File
@@ -15,7 +15,7 @@ import type {
ConfigSettingsSnapshot,
} from '../../types/settings';
import { resolveConfig } from '../resolve';
import { getConfigValueAtPath } from './registry';
import { getConfigValueAtPath, SECRET_PATHS } from './registry';
const JSONC_FORMATTING_OPTIONS: FormattingOptions = {
insertSpaces: true,
@@ -125,16 +125,55 @@ function isWhitespace(value: string | undefined): boolean {
function nextNonWhitespaceOffset(content: string, offset: number): number {
let index = offset;
while (index < content.length && isWhitespace(content[index])) {
index += 1;
while (index < content.length) {
if (isWhitespace(content[index])) {
index += 1;
continue;
}
if (content[index] === '/' && content[index + 1] === '/') {
index += 2;
while (index < content.length && content[index] !== '\n') index += 1;
continue;
}
if (content[index] === '/' && content[index + 1] === '*') {
index += 2;
while (
index + 1 < content.length &&
!(content[index] === '*' && content[index + 1] === '/')
) {
index += 1;
}
index = Math.min(content.length, index + 2);
continue;
}
break;
}
return index;
}
function previousNonWhitespaceOffset(content: string, offset: number): number {
let index = offset;
while (index >= 0 && isWhitespace(content[index])) {
index -= 1;
while (index >= 0) {
if (isWhitespace(content[index])) {
index -= 1;
continue;
}
const lineStart = content.lastIndexOf('\n', index) + 1;
const linePrefix = content.slice(lineStart, index + 1);
const lineCommentStart = linePrefix.lastIndexOf('//');
if (lineCommentStart >= 0 && /^[ \t]*$/.test(linePrefix.slice(0, lineCommentStart))) {
index = lineStart - 1;
continue;
}
if (content[index] === '/' && content[index - 1] === '*') {
index -= 2;
while (index > 0 && !(content[index - 1] === '/' && content[index] === '*')) {
index -= 1;
}
index -= 2;
continue;
}
break;
}
return index;
}
@@ -298,6 +337,20 @@ export function buildConfigSettingsSnapshot(
values[field.configPath] = structuredClone(rawValue != null ? rawValue : resolvedValue);
}
for (const secretPath of SECRET_PATHS) {
if (Object.hasOwn(values, secretPath)) {
continue;
}
const rawValue = getConfigValueAtPath(options.rawConfig, secretPath);
const resolvedValue = getConfigValueAtPath(options.resolvedConfig, secretPath);
if (
(typeof rawValue === 'string' && rawValue.length > 0) ||
(typeof resolvedValue === 'string' && resolvedValue.length > 0)
) {
values[secretPath] = { configured: true };
}
}
return {
configPath: options.configPath,
fields: options.fields,
+131 -4
View File
@@ -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('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
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').section, 'YouTube Playback Settings');
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(
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
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', () => {
assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
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', () => {
assert.equal(field('jimaku.apiBaseUrl').category, 'integrations');
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
assert.equal(field('subsync.defaultMode').category, 'integrations');
assert.equal(field('subsync.defaultMode').section, 'Subtitle Sync');
assert.equal(field('subsync.replace').category, 'integrations');
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
});
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
@@ -160,6 +228,17 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
test('settings registry hides app-managed and inactive config surfaces', () => {
const paths = new Set(fields.map((candidate) => candidate.configPath));
for (const hiddenPath of [
'ai.enabled',
'ai.apiKey',
'ai.apiKeyCommand',
'ai.model',
'ai.baseUrl',
'ai.systemPrompt',
'ai.requestTimeoutMs',
'ankiConnect.ai.enabled',
'ankiConnect.ai.model',
'ankiConnect.ai.systemPrompt',
'ankiConnect.fields.translation',
'controller.bindings',
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
@@ -176,3 +255,51 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
}
assert.equal(field('anilist.characterDictionary.enabled').section, 'Character Dictionary');
});
test('settings registry marks safe live config paths as hot-reloadable', () => {
for (const path of [
'mpv.aniskipButtonKey',
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
'youtube.primarySubLanguages',
'jimaku.apiBaseUrl',
'jimaku.languagePreference',
'jimaku.maxEntryResults',
'subsync.replace',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.addMinedWordsImmediately',
'ankiConnect.knownWords.matchMode',
'ankiConnect.knownWords.decks',
'ankiConnect.nPlusOne.enabled',
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.fields.word',
'ankiConnect.fields.audio',
'ankiConnect.fields.image',
'ankiConnect.fields.sentence',
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
]) {
assert.equal(field(path).restartBehavior, 'hot-reload', path);
}
});
test('settings registry does not expose removed subsync mode option', () => {
const paths = new Set(fields.map((candidate) => candidate.configPath));
assert.equal(paths.has('subsync.defaultMode'), false);
});
test('settings registry keeps unsafe config siblings restart-required', () => {
for (const path of [
'stats.serverPort',
'ankiConnect.url',
'ankiConnect.proxy.enabled',
'mpv.socketPath',
'websocket.port',
]) {
assert.equal(field(path).restartBehavior, 'restart', path);
}
});
+69 -15
View File
@@ -57,6 +57,7 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'ankiConnect.behavior.nPlusOneMatchMode',
'ankiConnect.isLapis.sentenceCardSentenceField',
'ankiConnect.isLapis.sentenceCardAudioField',
'ankiConnect.fields.translation',
'controller.bindings',
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
@@ -64,18 +65,27 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy',
'anilist.characterDictionary.profileScope',
'jellyfin.accessToken',
'jellyfin.userId',
'jellyfin.clientName',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
'controller.buttonIndices',
'shortcuts.multiCopyTimeoutMs',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
] as const;
const EXCLUDED_PREFIXES = ['controller.buttonIndices', 'youtubeSubgen'] as const;
const EXCLUDED_PREFIXES = [
'ai',
'ankiConnect.ai',
'controller.buttonIndices',
'youtubeSubgen',
] as const;
const JSON_OBJECT_FIELDS = new Set([
'keybindings',
@@ -87,7 +97,7 @@ const JSON_OBJECT_FIELDS = new Set([
'subtitleSidebar.css',
]);
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
export const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor']);
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
@@ -117,12 +127,11 @@ const SECTION_ORDER = new Map<string, number>(
'Primary Subtitle Appearance',
'Secondary Subtitle Appearance',
'Subtitle Sidebar Appearance',
'Playback Pause Behavior',
'Playback Behavior',
'Subtitle Behavior',
'Subtitle Sidebar Behavior',
'Visible Overlay Auto-Start',
'YouTube Playback Settings',
'MPV Launcher',
'mpv Playback',
'Note Fields',
'Media Capture',
'Kiku/Lapis Features',
@@ -134,7 +143,19 @@ const SECTION_ORDER = new Map<string, number>(
'MPV Keybindings',
'Overlay Shortcuts',
'Controller',
'Annotation WebSocket',
'WebSocket server',
'AniList',
'Character Dictionary',
'Discord Rich Presence',
'Jellyfin',
'Texthooker',
'Yomitan',
'Stats dashboard',
'Startup warmups',
'Logging',
'Updates',
'Immersion tracking',
].map((section, index) => [section, index]),
);
@@ -163,9 +184,9 @@ const PATH_ORDER = new Map<string, number>(
'mpv.backend',
'mpv.subminerBinaryPath',
'mpv.aniskipEnabled',
'mpv.aniskipButtonKey',
'mpv.launchMode',
'mpv.executablePath',
'mpv.aniskipButtonKey',
].map((path, index) => [path, index]),
);
@@ -180,7 +201,6 @@ const SUBSECTION_ORDER = new Map<string, number>(
'Toggle & Visibility',
'Open Panels',
'Playback',
'Timing',
'Default Fold State',
].map((subsection, index) => [subsection, index]),
);
@@ -209,6 +229,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
'mpv.aniskipEnabled': 'Enable AniSkip',
'mpv.aniskipButtonKey': 'AniSkip Button Key',
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
};
const DESCRIPTION_OVERRIDES: Record<string, string> = {
@@ -226,6 +247,10 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
'subtitleSidebar.css':
'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> {
@@ -289,7 +314,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
path === 'subtitleSidebar.pauseVideoOnHover'
) {
return { category: 'behavior', section: 'Playback Pause Behavior' };
return { category: 'behavior', section: 'Playback Behavior' };
}
if (path === 'subtitleStyle.preserveLineBreaks') {
return { category: 'behavior', section: 'Subtitle Behavior' };
@@ -367,8 +392,15 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('ankiConnect.')) {
return { category: 'mining-anki', section: 'AnkiConnect' };
}
if (path === 'auto_start_overlay') {
return { category: 'behavior', section: topSection(path) };
if (
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.')) {
return { category: 'behavior', section: topSection(path) };
@@ -431,7 +463,7 @@ function topSection(path: string): string {
jimaku: 'Jimaku',
jellyfin: 'Jellyfin',
logging: 'Logging',
mpv: 'MPV Launcher',
mpv: 'mpv Playback',
stats: 'Stats dashboard',
startupWarmups: 'Startup warmups',
subsync: 'Subtitle Sync',
@@ -441,7 +473,7 @@ function topSection(path: string): string {
yomitan: 'Yomitan',
youtube: 'YouTube Playback Settings',
youtubeSubgen: 'YouTube subtitle generation',
auto_start_overlay: 'Visible Overlay Auto-Start',
auto_start_overlay: 'Playback Behavior',
};
return labels[top] ?? humanizePath(top);
}
@@ -509,9 +541,11 @@ function subsectionForPath(path: string): string | undefined {
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return 'Toggle & Visibility';
}
if (path === 'mpv.aniskipButtonKey') {
return 'Playback';
}
if (path.startsWith('shortcuts.')) {
const leaf = path.split('.').at(-1) ?? '';
if (leaf === 'multiCopyTimeoutMs') return 'Timing';
if (
leaf === 'copySubtitle' ||
leaf === 'copySubtitleMultiple' ||
@@ -610,9 +644,29 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
pathStartsWith(path, 'subtitleStyle') ||
pathStartsWith(path, 'subtitleSidebar') ||
path === 'secondarySub.defaultMode' ||
pathStartsWith(path, 'ankiConnect.ai') ||
path === 'ankiConnect.ai.enabled' ||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
path === 'ankiConnect.knownWords.highlightEnabled' ||
path === 'ankiConnect.knownWords.refreshMinutes' ||
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
path === 'ankiConnect.knownWords.matchMode' ||
path === 'ankiConnect.knownWords.decks' ||
path === 'ankiConnect.nPlusOne.enabled' ||
path === 'ankiConnect.nPlusOne.minSentenceWords' ||
path === 'ankiConnect.fields.word' ||
path === 'ankiConnect.fields.audio' ||
path === 'ankiConnect.fields.image' ||
path === 'ankiConnect.fields.sentence' ||
path === 'ankiConnect.fields.miscInfo' ||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
path === 'ankiConnect.isKiku.fieldGrouping' ||
path === 'mpv.aniskipButtonKey' ||
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey'
path === 'stats.markWatchedKey' ||
path === 'logging.level' ||
path === 'youtube.primarySubLanguages' ||
pathStartsWith(path, 'jimaku') ||
pathStartsWith(path, 'subsync')
) {
return 'hot-reload';
}
+151 -1
View File
@@ -14,8 +14,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
@@ -167,3 +168,152 @@ test('startAppLifecycle app ping exits zero immediately when another instance ow
assert.equal(lockCalls, 1);
assert.deepEqual(calls, ['exit:0']);
});
test('startAppLifecycle queues second-instance commands until app ready runtime completes', async () => {
const handled: string[] = [];
let secondInstanceHandler: ((_event: unknown, 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,
onSecondInstance: (handler) => {
secondInstanceHandler = handler;
},
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
handleCliCommand: (args, source) => {
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
},
whenReady: (handler) => {
readyHandler = handler;
},
onReady: async () => {
await readyFinished;
handled.push('ready');
},
});
startAppLifecycle(makeArgs({ background: true }), deps);
const runSecondInstance = (argv: string[]) => {
assert.ok(secondInstanceHandler);
(secondInstanceHandler as (_event: unknown, argv: string[]) => void)({}, argv);
};
const runReady = () => {
assert.ok(readyHandler);
return (readyHandler as () => Promise<void>)();
};
runSecondInstance(['SubMiner', '--start']);
assert.deepEqual(handled, []);
const readyRun = runReady();
await Promise.resolve();
assert.deepEqual(handled, []);
assert.ok(releaseReady);
(releaseReady as () => void)();
await readyRun;
assert.deepEqual(handled, ['ready', 'second-instance:start']);
runSecondInstance(['SubMiner', '--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']);
});
+49 -3
View File
@@ -13,6 +13,7 @@ export interface AppLifecycleServiceDeps {
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
whenReady: (handler: () => Promise<void>) => void;
onWindowAllClosed: (handler: () => void) => void;
onWillQuit: (handler: () => void) => void;
@@ -41,6 +42,7 @@ export interface AppLifecycleDepsRuntimeOptions {
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
@@ -70,6 +72,7 @@ export function createAppLifecycleDepsRuntime(
handleCliCommand: options.handleCliCommand,
printHelp: options.printHelp,
logNoRunningInstance: options.logNoRunningInstance,
startControlServer: options.startControlServer,
whenReady: (handler) => {
options.app
.whenReady()
@@ -114,12 +117,42 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
return;
}
deps.onSecondInstance((_event, argv) => {
let appReadyRuntimeComplete = false;
const pendingSecondInstanceCommands: CliArgs[] = [];
let stopControlServer: (() => void) | null = null;
const handleSecondInstanceCommand = (args: CliArgs): void => {
try {
deps.handleCliCommand(deps.parseArgs(argv), 'second-instance');
deps.handleCliCommand(args, 'second-instance');
} catch (error) {
logger.error('Failed to handle second-instance CLI command:', error);
}
};
const flushPendingSecondInstanceCommands = (): void => {
while (pendingSecondInstanceCommands.length > 0) {
const nextArgs = pendingSecondInstanceCommands.shift();
if (nextArgs) {
handleSecondInstanceCommand(nextArgs);
}
}
};
const dispatchSecondInstanceArgv = (argv: string[]): void => {
try {
const nextArgs = deps.parseArgs(argv);
if (!appReadyRuntimeComplete) {
pendingSecondInstanceCommands.push(nextArgs);
return;
}
handleSecondInstanceCommand(nextArgs);
} catch (error) {
logger.error('Failed to handle second-instance CLI command:', error);
}
};
deps.onSecondInstance((_event, argv) => {
dispatchSecondInstanceArgv(argv);
});
if (!deps.shouldStartApp(initialArgs)) {
@@ -132,17 +165,30 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
return;
}
try {
stopControlServer = deps.startControlServer?.(dispatchSecondInstanceArgv) ?? null;
} catch (error) {
logger.error('Failed to start app control socket:', error);
}
deps.whenReady(async () => {
await deps.onReady();
appReadyRuntimeComplete = true;
flushPendingSecondInstanceCommands();
});
deps.onWindowAllClosed(() => {
if (!deps.isDarwinPlatform() && deps.shouldQuitOnWindowAllClosed()) {
if (
deps.shouldQuitOnWindowAllClosed() &&
(!deps.isDarwinPlatform() || initialArgs.settings || initialArgs.setup)
) {
deps.quitApp();
}
});
deps.onWillQuit(() => {
stopControlServer?.();
stopControlServer = null;
deps.onWillQuitCleanup();
});
+152 -4
View File
@@ -1,7 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CliArgs } from '../../cli/args';
import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
import {
CliCommandServiceDeps,
createCliCommandDepsRuntime,
handleCliCommand,
} from './cli-command';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
@@ -15,8 +19,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
stop: false,
toggle: false,
toggleVisibleOverlay: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -32,6 +36,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
refreshKnownWords: false,
openRuntimeOptions: false,
@@ -500,6 +505,132 @@ test('handleCliCommand applies socket path and connects on start', () => {
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', () => {
const { deps, calls } = createDeps({
isTexthookerRunning: () => true,
@@ -585,8 +716,8 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
args: Partial<CliArgs>;
expected: string;
}> = [
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ args: { configSettings: true }, expected: 'openConfigSettingsWindow' },
{ args: { yomitan: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ args: { settings: true }, expected: 'openConfigSettingsWindow' },
{
args: { showVisibleOverlay: true },
expected: 'setVisibleOverlayVisible:true',
@@ -607,6 +738,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
{ args: { togglePrimarySubtitleBar: true }, expected: 'togglePrimarySubtitleBar' },
{ args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
{ args: { markWatched: true }, expected: 'dispatchSessionAction' },
{
args: { openRuntimeOptions: true },
expected: 'openRuntimeOptionsPalette',
@@ -653,6 +785,22 @@ test('handleCliCommand dispatches cycle-runtime-option session action', async ()
});
});
test('handleCliCommand dispatches mark-watched session action', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(makeArgs({ markWatched: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'markWatched',
});
});
test('handleCliCommand logs AniList status details', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);

Some files were not shown because too many files have changed in this diff Show More