Compare commits

..

27 Commits

Author SHA1 Message Date
sudacode fcd6511aa1 fix: default hoverTokenBackgroundColor to transparent
- Change default from rgba(54, 58, 79, 0.84) to transparent in config, CSS, and sanitizer fallbacks
- Update tests and docs to reflect new default
2026-05-20 16:31:59 -07:00
sudacode e18b6eda77 fix: reset WS subtitle state and parsed cues on media path change
- Clear activeParsedSubtitleCues, activeParsedSubtitleSource, lastObservedTimePos
- Broadcast reset payload to subtitleWsService and annotationSubtitleWsService
- Extend wiring test to cover new reset fields and WS broadcasts
2026-05-20 11:40:21 -07:00
sudacode 1145e131da fix: clear stale CSS properties and subtitle state on style/media update
- Remove CSS properties absent from subsequent subtitle style updates
- Broadcast subtitle:set clear when media path changes
- Preserve launcher lifecycle ownership for already-managed overlay apps
- Clamp negative autoplay current time to zero
- Reject blank subminerBinaryPath values via parseNonEmptyString
- Log and rethrow legacy config migration errors instead of swallowing
- Normalize modifier aliases (e.g. CommandOrControl) in keybinding display
2026-05-20 10:14:28 -07:00
sudacode dde19ad0da fix: prime startup subtitle before autoplay resumes
- Add `selectAutoplayStartupCue` to pick active or imminent cue at startup
- Call `primeCurrentSubtitle` in warm-release before signaling autoplay ready
- Reset primed state on media path change to avoid stale cue leaks
2026-05-20 08:09:49 -07:00
sudacode 4813ce1fea fix: drop stale deferred autoplay-ready signals on media change
- autoplay-ready gate now stamps pending signal with mediaPath and discards it on flush if media has changed
- tokenization warm release skips signaling when current media path is cleared (null)
- tighten regex matchers in main-wiring and overlay-legacy-cleanup tests
2026-05-20 01:45:14 -07:00
sudacode 403ee32579 fix: managed playback overlay lifecycle for launcher-owned sessions
- Remove --background from launcher-owned mpv starts; quit only non-tray/non-background managed sessions
- Defer autoplay-ready signal until overlay window content is loaded; retry after flush
- Retry socket availability before auto-starting overlay (up to 25 attempts, 200ms apart)
- Extract warm tokenization signal into autoplay-tokenization-warm-release with stale-media guard
- Queue second-instance commands until app ready runtime completes
- Guard globalShortcut cleanup with isAppReady check to avoid pre-ready crash
- Recognize "osx" as a macOS platform alias in Lua environment detection
2026-05-20 01:45:14 -07:00
sudacode e4165a418c refactor: deduplicate mpv plugin config, fix CSS font fallbacks
- Extract shared getMpvPluginRuntimeConfig() helper to eliminate duplicate inline objects
- Call ensureImmersionTrackerStarted() before markActiveVideoWatched action
- Quote multi-word font names and add sans-serif generic fallback in subtitle sidebar CSS
- Add main-wiring tests asserting deduplication and tracker start ordering
2026-05-20 01:45:14 -07:00
sudacode 2772c61aba feat: add mark-watched action, background app reuse, and N+1 compat
- Add `--mark-watched` CLI flag + mpv session binding; marks video watched, shows OSD, advances playlist
- Launcher detects running background app via `--app-ping` and borrows it instead of owning its lifecycle
- Preserve N+1 highlighting for existing configs with `knownWords.highlightEnabled` set
- Fix `resolveConfiguredShortcuts` to respect explicit `null` overrides (disabling defaults)
- Split session-help modal into focused modules (colors, render, sections, tabs)
2026-05-20 01:45:14 -07:00
sudacode 5c710ffcaf test: add 20s timeout to 365d trends dashboard test 2026-05-20 01:45:14 -07:00
sudacode ab29d56649 feat: include unconfigured secret paths in config settings snapshot
- Export SECRET_PATHS from registry for reuse
- Populate snapshot with `{ configured: true }` for non-empty secrets not already covered by registered fields
2026-05-20 01:45:14 -07:00
sudacode 1f7318d615 feat: expand hot-reload to logging, jimaku, subsync, and Anki sub-fields
- Mark logging.level, stats keys, jimaku.*, subsync.*, and granular ankiConnect fields (knownWords, nPlusOne, fields, isLapis, isKiku, behavior) as hot-reloadable
- Refactor classifyConfigHotReloadDiff to path-walk diffing instead of per-key branches
- Wire setLogLevel, invalidateTokenizationCache, refreshSubtitlePrefetch, refreshCurrentSubtitle into hot-reload applied handler
- Exclude ai.* and ankiConnect.ai.* prefixes from config window; hide fields.translation
- Update docs and config.example.jsonc hot-reload annotations
2026-05-20 01:45:14 -07:00
sudacode cc7c3939e9 docs: add config subcommand and --config flag to CLI reference 2026-05-20 01:45:14 -07:00
sudacode 887de056c5 docs: simplify and restructure installation guide
- Consolidate requirements into a single table with status column
- Rewrite installation.md as a numbered 3-step guide
- Remove verbose platform-specific notes; fold essentials into platform sections
- Trim README quick-start to minimal install/launch commands
2026-05-20 01:45:14 -07:00
sudacode 553117356d fix: disable macOS mpv menu shortcuts, buffer latest subtitle IPC state
- Pass --macos-menu-shortcuts=no on Darwin so SubMiner bindings reach mpv
- Replace queued IPC listener with latest-value variant for subtitle channels
- Skip JSONC line/block comments in duplicate-key offset helpers
- Preserve configured Anki note model name in selectPreferredNoteFieldModelName
- Guard known-words deck rename against collision; add chooseKnownWordsDeckRenameValue
- Apply asCssColor on hover token CSS compat reads
2026-05-20 01:45:14 -07:00
sudacode 193b3136f2 migrate subtitle style config to CSS declaration shape
- Flat style keys (fontFamily, fontSize, hoverTokenColor, etc.) consolidated into subtitleStyle.css, secondary.css, and subtitleSidebar.css objects
- Hover token colors migrated to --subtitle-hover-token-color CSS custom properties
- Plugin app-ping now checks result.status (0=running, 1=stopped) to avoid treating transient failures as stopped
- Note-fields note type picker defaults to configured deck's note type before falling back to Kiku/Lapis
- New migration for legacy ankiConnect N+1 config paths
2026-05-20 01:45:14 -07:00
sudacode 1bb7b26641 fix: transport AppImage args via env and gate restart on app-ping
- Transport Linux AppImage CLI args through SUBMINER_APP_ARGC/ARG_* env vars instead of argv
- Add --app-ping command to probe single-instance lock ownership (exit 0 = running, 1 = not)
- Gate manual restart: poll app-ping until old app releases lock, then until new app owns it
- Preserve user-paused playback when disarming the auto-play-ready gate on restart
- Snapshot subtitles before connection side effects (sub-visibility hide) can suppress them
- Reapply overlay bounds after first show for Hyprland compatibility
2026-05-20 01:45:14 -07:00
sudacode 48447c2f1a fix: curl fetch for Linux updater, overlay restart restore, Yomitan late
- Use /usr/bin/curl on Linux for update checks to avoid Electron net-service crashes
- Restore visible overlay on manual restart even when auto-start visibility is disabled
- Reload overlay windows after Yomitan extension loads to fix popup race on startup
2026-05-20 01:45:14 -07:00
sudacode 2b13c82d69 feat(settings): move restart badge inline with option title
- Remove field-meta row (config path, advanced chip) from option rows
- Inline live/restart status badge beside each option label
- Extract getFieldTitleBadges into settings-field-layout module with tests
2026-05-20 01:43:20 -07:00
sudacode db60365b0e fix: normalize anki deck sample size 2026-05-20 01:43:20 -07:00
sudacode 93d9ed81a2 fix: address follow-up review feedback 2026-05-20 01:43:20 -07:00
sudacode 6f48d4b65b fix: address config modal review feedback 2026-05-20 01:43:20 -07:00
sudacode 7fb1e6d7a5 fix: add missing changelog metadata 2026-05-20 01:43:20 -07:00
sudacode 1ff44e0d69 feat(config): unify mpv plugin options under main config and add CSS/Ani
- Replace subminer.conf plugin config with mpv.* fields in config.jsonc
- Add socketPath, backend, autoStartSubMiner, pauseUntilOverlayReady, aniskipEnabled/buttonKey, subminerBinaryPath to mpv config
- Add subtitleSidebar.css field; migrate legacy sidebar appearance fields
- Add paintOrder and WebkitTextStroke to subtitle style options
- Update default subtitle/sidebar fontFamily to CJK-first stack
- Fix overlay visible state surviving mpv y-r restart
- Fix live config saves applying subtitle CSS immediately to open overlays
- Migrate legacy primary/secondary subtitle appearance into subtitleStyle.css on load
- Switch AniSkip button key setting to click-to-learn key capture
2026-05-20 01:43:20 -07:00
sudacode 0354a0e74b feat(config): add subtitle CSS editor, nPlusOne.enabled flag, and fix se
- subtitleStyle.css / subtitleStyle.secondary.css replace flat style fields in the settings window
- ankiConnect.nPlusOne.enabled gates known-word cache independently of knownWords.highlightEnabled
- Settings search now covers all categories, narrows on multi-word terms, and hides editor-owned fields
- Default note-type picker to Kiku then Lapis; rename isLapis.sentenceCardModel default to "Lapis"
2026-05-20 01:43:20 -07:00
sudacode b0fd7bd9e8 fix(launcher): suppress Electron menu diagnostics 2026-05-20 01:43:20 -07:00
sudacode 58f5fff6ad style: format config settings changes 2026-05-20 01:43:20 -07:00
sudacode 309ce6ef8f feat(config): reorganize settings window and move annotation colors to subtitleStyle
- Reorganize Configuration window into Appearance, Behavior, Anki, Input, and Integration sections
- Add AnkiConnect-backed deck, note-type, and field pickers in the Anki section
- Add click-to-learn keybinding controls
- Move known-word and N+1 highlight colors to subtitleStyle.knownWordColor / subtitleStyle.nPlusOneColor; legacy ankiConnect.knownWords.color and ankiConnect.nPlusOne.nPlusOne keys still accepted with deprecation warnings
- Add deckNames, modelNames, modelFieldNames, and fieldNamesForDeck methods to AnkiConnectClient
- Mark discordPresence.presenceStyle as an enum in the config registry
2026-05-20 01:43:20 -07:00
138 changed files with 5968 additions and 3123 deletions
+23 -23
View File
@@ -4,7 +4,7 @@
# SubMiner
Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
Integrates Yomitan with mpv - look up words, mine to Anki, and track your 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 @@ Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersio
### Dictionary Lookups
Hover over any word and trigger a lookup to get the full Yomitan popup - definitions, pitch accent, and frequency data - without ever leaving mpv.
Yomitan runs inside the overlay. Trigger a lookup on any word for full dictionary popups — definitions, pitch accent, 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. Grammar-only tokens and particles 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. Known words fade back; new words stand out. Grammar-only tokens 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 tracking watch time, vocabulary growth, mining throughput, session history, and trends. All stored locally, no third-party tracking.
Local stats dashboard watch time, anime library, 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>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</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>
</tr>
<tr>
<td><b>WebSocket</b></td>
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
<td>Annotated subtitle feed for external clients (texthooker pages, custom tools)</td>
</tr>
</table>
@@ -110,17 +110,16 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
## Requirements
Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but enhances the experience.
Only **mpv** is required. Everything else is optional but enhances the experience.
| 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 |
| Dependency | Status | What it does |
| -------------------- | ----------- | ------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on |
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
| MeCab + mecab-ipadic | Recommended | More precise N+1, JLPT, and frequency annotations |
| yt-dlp | Optional | YouTube playback |
| fzf / rofi | Optional | Video picker in the launcher |
| alass / ffsubsync | Optional | Subtitle sync |
<details>
<summary><b>Platform-specific install commands</b></summary>
@@ -197,24 +196,25 @@ See the [build-from-source guide](https://docs.subminer.moe/installation#from-so
Run SubMiner and the first-run setup wizard will guide you through importing Yomitan dictionaries and optionally installing the `subminer` command-line launcher.
```bash
# Linux
# Linux (AUR)
subminer app --setup
# macOS — open SubMiner.app, or:
subminer app --setup
```
On **Windows**, just run `SubMiner.exe` and the setup will open automatically on first launch.
On **Windows**, just run `SubMiner.exe` setup opens automatically on first launch.
### 3. Mine
### 3. Play
```bash
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)
subminer video.mkv # play video with overlay
subminer stats # open immersion dashboard
subminer config # open configuration window
subminer --config # open configuration window via flag
```
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
On **Windows**, use the **SubMiner mpv** shortcut created during setup — double-click it or drag a video file onto it.
## Documentation
+193
View File
@@ -0,0 +1,193 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveMacAppBundlePath = resolveMacAppBundlePath;
exports.isMacApplicationsFolderBundle = isMacApplicationsFolderBundle;
exports.isKnownLinuxPackageManagedAppImage = isKnownLinuxPackageManagedAppImage;
exports.isNativeUpdaterSupported = isNativeUpdaterSupported;
exports.configureAutoUpdater = configureAutoUpdater;
exports.createElectronAppUpdater = createElectronAppUpdater;
const node_fs_1 = require("node:fs");
const node_child_process_1 = require("node:child_process");
const node_os_1 = __importDefault(require("node:os"));
const node_path_1 = __importDefault(require("node:path"));
const node_util_1 = require("node:util");
const electron_updater_1 = require("electron-updater");
const release_assets_1 = require("./release-assets");
const updaterErrorListeners = new WeakMap();
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
function resolveMacAppBundlePath(execPath) {
const marker = '.app/Contents/MacOS/';
const markerIndex = execPath.indexOf(marker);
if (markerIndex < 0)
return null;
return execPath.slice(0, markerIndex + '.app'.length);
}
async function readMacCodeSignature(appBundlePath) {
try {
const result = await execFileAsync('/usr/bin/codesign', ['-dv', '--verbose=4', appBundlePath], {
encoding: 'utf8',
});
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
}
catch {
return null;
}
}
function realpathOrOriginal(filePath) {
try {
return (0, node_fs_1.realpathSync)(filePath);
}
catch {
return filePath;
}
}
function isSameOrInsideDirectory(parentPath, candidatePath) {
const relative = node_path_1.default.relative(parentPath, candidatePath);
return (relative === '' ||
(relative.length > 0 && !relative.startsWith('..') && !node_path_1.default.isAbsolute(relative)));
}
function isMacApplicationsFolderBundle(appBundlePath, homeDir = node_os_1.default.homedir()) {
const resolvedBundlePath = node_path_1.default.resolve(appBundlePath);
return (isSameOrInsideDirectory('/Applications', resolvedBundlePath) ||
isSameOrInsideDirectory(node_path_1.default.join(homeDir, 'Applications'), resolvedBundlePath));
}
function isKnownLinuxPackageManagedAppImage(appImagePath) {
return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage';
}
async function isNativeUpdaterSupported(options) {
if (!options.isPackaged) {
options.log?.('Skipping native updater because this build is not packaged.');
return false;
}
if (options.platform === 'linux') {
options.log?.('Skipping native Linux updater because Linux tray checks use GitHub release assets.');
return false;
}
if (options.platform !== 'darwin') {
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
return false;
}
const appBundlePath = resolveMacAppBundlePath(options.execPath);
if (!appBundlePath) {
options.log?.('Skipping native macOS updater because the app bundle path could not be resolved.');
return false;
}
if (!isMacApplicationsFolderBundle(appBundlePath, options.homeDir)) {
options.log?.('Skipping native macOS updater because the app is not installed in an Applications folder.');
return false;
}
const signature = await (options.readCodeSignature ?? readMacCodeSignature)(appBundlePath);
if (!signature) {
options.log?.('Skipping native macOS updater because the app code signature could not be read.');
return false;
}
if (/Signature=adhoc\b/.test(signature) || /TeamIdentifier=not set\b/.test(signature)) {
options.log?.('Skipping native macOS updater because this build is ad-hoc signed.');
return false;
}
return true;
}
function configureAutoUpdater(updater, log = () => { }, channel = 'stable') {
updater.autoDownload = false;
// On macOS this avoids invoking Squirrel until the explicit restart/install step.
updater.autoInstallOnAppQuit = false;
updater.allowPrerelease = channel === 'prerelease';
updater.allowDowngrade = false;
updater.logger = {
info: () => { },
debug: () => { },
warn: (message) => log(message),
error: (message) => log(message),
};
const previousErrorListener = updaterErrorListeners.get(updater);
if (previousErrorListener) {
if (updater.off) {
updater.off('error', previousErrorListener);
}
else {
updater.removeListener?.('error', previousErrorListener);
}
}
if (updater.on) {
const errorListener = (error) => {
const message = error instanceof Error ? error.message : String(error);
log(`Updater error event: ${message}`);
};
updater.on('error', errorListener);
updaterErrorListeners.set(updater, errorListener);
}
return updater;
}
function createElectronAppUpdater(options) {
const getChannel = options.getChannel ?? (() => 'stable');
const updater = configureAutoUpdater(options.updater ?? electron_updater_1.autoUpdater, options.log, getChannel());
if (options.configureHttpExecutor) {
// electron-updater has no public executor hook; keep the macOS cURL override localized.
updater.httpExecutor = options.configureHttpExecutor();
}
if (options.disableDifferentialDownload !== undefined) {
updater.disableDifferentialDownload = options.disableDifferentialDownload;
}
let nativeUpdaterSupported = null;
async function getNativeUpdaterSupported() {
if (!options.isNativeUpdaterSupported)
return true;
if (nativeUpdaterSupported === null) {
nativeUpdaterSupported = Promise.resolve(options.isNativeUpdaterSupported());
}
return nativeUpdaterSupported;
}
return {
async checkForUpdates(channel) {
if (!options.isPackaged) {
return {
available: false,
version: options.currentVersion,
canUpdate: false,
};
}
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping native app update check because native updater is unsupported.');
return {
available: false,
version: options.currentVersion,
canUpdate: false,
};
}
configureAutoUpdater(updater, options.log, channel ?? getChannel());
const result = await updater.checkForUpdates();
const version = result?.updateInfo?.version ?? options.currentVersion;
return {
available: (0, release_assets_1.compareSemverLike)(version, options.currentVersion) > 0,
version,
canUpdate: true,
};
},
async downloadUpdate() {
if (!options.isPackaged) {
options.log('Skipping app update download because this build is not packaged.');
return;
}
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping app update download because native updater is unsupported.');
return;
}
await updater.downloadUpdate();
},
async quitAndInstall() {
if (!options.isPackaged) {
options.log('Skipping app update install because this build is not packaged.');
return;
}
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping app update install because native updater is unsupported.');
return;
}
updater.quitAndInstall(false, true);
},
};
}
//# sourceMappingURL=app-updater.js.map
+1 -1
View File
@@ -1,4 +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.
- Reused an already-running background SubMiner app for launcher-opened videos, preserving warmups and keeping the tray app alive after playback closes.
@@ -1,4 +0,0 @@
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 Settings 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 Configuration 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 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.
- 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.
+5 -5
View File
@@ -1,8 +1,8 @@
type: added
area: config
- 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.
- 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.
- Marked safe live config options in the Configuration window and expanded hot reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, Anki media/sentence/misc field mappings, sentence card model, and selected Anki annotation/runtime options.
- Hid AI and translation fields from the Configuration window while keeping them supported in config files.
@@ -1,4 +0,0 @@
type: changed
area: config
- Defaulted Jellyfin remote-session startup warmup and character-name subtitle highlighting to off.
@@ -1,5 +0,0 @@
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.
@@ -1,4 +0,0 @@
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 settings` launcher output.
- Suppressed Electron macOS menu diagnostics from `subminer config` launcher output.
-4
View File
@@ -1,4 +0,0 @@
type: changed
area: setup
- Setup: Removed the bundled mpv runtime plugin readiness card; legacy mpv plugin removal still appears when needed.
-5
View File
@@ -1,5 +0,0 @@
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
@@ -1,4 +0,0 @@
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.
@@ -1,4 +0,0 @@
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
@@ -1,4 +0,0 @@
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
@@ -1,4 +0,0 @@
type: fixed
area: websocket
- WebSocket: Kept the regular subtitle websocket plain-text only; annotation spans and token metadata now stay on the annotation websocket.
@@ -1,7 +0,0 @@
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
@@ -1,4 +0,0 @@
type: changed
area: config
- Settings: reorganized playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls in the settings modal.
@@ -1,4 +0,0 @@
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
@@ -1,4 +0,0 @@
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 Settings window saves so primary and secondary subtitle CSS declarations apply immediately to open video overlays.
- Fixed live Configuration window saves so primary and secondary subtitle CSS declarations apply immediately to open video overlays.
+5 -4
View File
@@ -155,7 +155,7 @@
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
"jellyfinRemoteSession": false // Warm up Jellyfin remote session at startup. Values: true | false
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// ==========================================
@@ -336,11 +336,12 @@
}, // Dual subtitle track options.
// ==========================================
// Subtitle Sync
// Auto 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.
@@ -383,7 +384,7 @@
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
"nameMatchEnabled": true, // 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.
@@ -438,7 +439,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": true, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
"pauseVideoOnHover": false, // 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.
+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. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`).
4. 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` | `false` | Toggle character-name highlighting |
| `subtitleStyle.nameMatchEnabled` | `true` | 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` | `false` | Toggle character-name highlighting in subtitles |
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting in subtitles |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
## Reference Implementation
+13 -11
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 `--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.
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.
The Settings window groups options by workflow instead of mirroring the raw config-file shape:
@@ -171,7 +171,7 @@ The configuration file includes several main sections:
**External Integrations**
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**AniList**](#anilist) - Optional post-watch progress updates
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath`
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
@@ -258,7 +258,7 @@ Control which startup warmups run in the background versus deferring to first re
"mecab": true,
"yomitanExtension": true,
"subtitleDictionaries": true,
"jellyfinRemoteSession": false
"jellyfinRemoteSession": true
}
}
```
@@ -271,11 +271,11 @@ Control which startup warmups run in the background versus deferring to first re
| `subtitleDictionaries` | `true`, `false` | Warm up JLPT + frequency dictionaries at startup |
| `jellyfinRemoteSession` | `true`, `false` | Warm up Jellyfin remote session at startup (still requires Jellyfin remote auto-connect settings) |
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.
Defaults warm everything (`true` for all toggles, `lowPowerMode: false`). Setting a warmup toggle to `false` defers that work until first usage.
### WebSocket Server
The overlay includes a built-in WebSocket server that broadcasts plain subtitle text to connected clients for external processing.
The overlay includes a built-in WebSocket server that broadcasts subtitle text to connected clients (such as texthooker-ui) for external processing.
For endpoint details, payload examples, and client patterns, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api).
@@ -391,7 +391,7 @@ See `config.example.jsonc` for detailed configuration options.
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: `"transparent"`); `hoverBackground` is accepted as an alias |
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`false` by default) |
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` 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`) |
@@ -443,7 +443,7 @@ Configure the parsed-subtitle sidebar modal.
"autoOpen": false,
"layout": "overlay",
"toggleKey": "Backslash",
"pauseVideoOnHover": true,
"pauseVideoOnHover": false,
"autoScroll": true,
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
"fontSize": 16
@@ -457,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 (`true` by default) |
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list |
| `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`) |
@@ -1111,16 +1111,17 @@ 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.
### Subtitle Sync
### Auto Subtitle Sync
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
Sync 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.
- [`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
- [`ffsubsync`](https://github.com/smacke/ffsubsync) — audio-based sync using the video file as reference (fallback)
```json
{
"subsync": {
"defaultMode": "auto",
"alass_path": "",
"ffsubsync_path": "",
"ffmpeg_path": "",
@@ -1131,6 +1132,7 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy
| 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 retime them with alass or ffsubsync — all from within SubMiner.
Search and download subtitles from Jimaku, then automatically synchronize 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 retime subtitles with alass or ffsubsync — all from the overlay.
details: Search and pull subtitles from Jimaku, then auto-sync timing with alass or ffsubsync — all from the overlay.
link: /jimaku-integration
linkText: Jimaku integration
- icon:
+3 -3
View File
@@ -22,7 +22,7 @@ Only **mpv** is strictly required to run SubMiner. Everything else enhances the
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
| 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. |
| ffsubsync | Optional | Subtitle sync engine (fallback). Disabled without alass or ffsubsync. |
| fuse2 | Linux only | Required to run the AppImage. |
### Linux
@@ -300,9 +300,9 @@ subminer --update
SubMiner verifies AppImage, launcher, and rofi theme downloads against `SHA256SUMS.txt`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
On Linux, `subminer -u` performs the AppImage update from the launcher process directly.
`subminer -u` also performs the AppImage update directly from the launcher process, which is useful when SubMiner is not currently running.
On macOS, tray update checks can also update the app automatically through Electron's built-in updater.
## How It All Fits Together
-1
View File
@@ -77,7 +77,6 @@ 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 |
+5 -4
View File
@@ -155,7 +155,7 @@
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
"jellyfinRemoteSession": false // Warm up Jellyfin remote session at startup. Values: true | false
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// ==========================================
@@ -336,11 +336,12 @@
}, // Dual subtitle track options.
// ==========================================
// Subtitle Sync
// Auto 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.
@@ -383,7 +384,7 @@
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
"nameMatchEnabled": true, // 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.
@@ -438,7 +439,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": true, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
"pauseVideoOnHover": false, // 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.
+1 -1
View File
@@ -49,7 +49,7 @@ Character-name matches are built from the active merged SubMiner character dicti
| Option | Default | Description |
| -------------------------------- | --------- | ---------------------------------------- |
| `subtitleStyle.nameMatchEnabled` | `false` | Enable character-name token highlighting |
| `subtitleStyle.nameMatchEnabled` | `true` | 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.
+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": true,
"pauseVideoOnHover": false,
"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 | `true` | Pause playback while hovering the cue list |
| `pauseVideoOnHover` | boolean | `false` | 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 --yomitan`) and confirm at least one dictionary is imported.
- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --settings`) 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"**
If subtitle sync fails:
SubMiner tries alass first, then falls back to ffsubsync. If both fail:
- 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).
+3 -5
View File
@@ -131,8 +131,7 @@ 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 --yomitan # Open Yomitan settings
SubMiner.AppImage --settings # Open SubMiner settings window
SubMiner.AppImage --settings # Open Yomitan settings
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
@@ -185,8 +184,7 @@ 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 settings`: open the SubMiner settings window (also `subminer --settings`).
- `subminer config`: config file helpers (`path`, `show`).
- `subminer config`: config 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.
@@ -266,7 +264,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 --yomitan` or `SubMiner.AppImage --yomitan`) and import at least one dictionary in the bundled Yomitan instance.
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.
If you also use Yomitan in a browser, configure that browser profile separately; it does not inherit dictionaries or settings from the bundled instance.
+21 -32
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 as plain text.
Use the basic subtitle websocket when you only need the current subtitle line and a ready-to-render HTML sentence string.
- **Default URL:** `ws://127.0.0.1:6677`
- **Transport:** local WebSocket server bound to `127.0.0.1`
@@ -64,36 +64,6 @@ 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,
@@ -121,7 +91,16 @@ In practice, if you are building a new client, prefer `annotationWebsocket` unle
}
```
Each annotation token may include:
#### 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:
| Token field | Type | Notes |
| --- | --- | --- |
@@ -140,6 +119,16 @@ Each annotation 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:
+2 -2
View File
@@ -6,8 +6,8 @@ export function runAppPassthroughCommand(context: LauncherCommandContext): boole
if (!appPath) {
return false;
}
if (args.settings) {
runAppCommandWithInherit(appPath, ['--settings']);
if (args.configSettings) {
runAppCommandWithInherit(appPath, ['--config']);
return true;
}
if (!args.appPassthrough) {
+1 -174
View File
@@ -1,9 +1,6 @@
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';
@@ -56,7 +53,7 @@ function createContext(): LauncherCommandContext {
doctor: false,
doctorRefreshKnownWords: false,
version: false,
settings: false,
configSettings: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -154,7 +151,6 @@ 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',
@@ -210,172 +206,3 @@ 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']);
});
+16 -43
View File
@@ -8,7 +8,6 @@ import {
cleanupPlaybackSession,
launchAppCommandDetached,
resolveLauncherRuntimePluginPath,
isRunningAppControlServerAvailable,
startMpv,
startOverlay,
state,
@@ -30,13 +29,6 @@ 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[] = [];
@@ -107,7 +99,10 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
const { args, appPath } = context;
if (!appPath) return;
const configDir = getLauncherConfigDir();
const configDir = getDefaultConfigDir({
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
});
const statePath = getSetupStatePath(configDir);
const ready = await ensureLauncherSetupReady({
readSetupState: () => readSetupState(statePath),
@@ -151,7 +146,6 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
waitForUnixSocketReady,
startOverlay,
launchAppCommandDetached,
isAppControlServerAvailable: isRunningAppControlServerAvailable,
log,
cleanupPlaybackSession,
getMpvProc: () => state.mpvProc,
@@ -170,7 +164,6 @@ 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;
@@ -215,23 +208,11 @@ 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 &&
@@ -257,19 +238,16 @@ export async function runPlaybackCommandWithDeps(
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
runtimePluginConfig: {
...effectivePluginRuntimeConfig,
...pluginRuntimeConfig,
backend: args.backend,
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
},
},
);
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
const shouldStartOverlay =
args.startOverlay ||
args.autoStartOverlay ||
isAppOwnedYoutubeFlow ||
shouldLauncherAttachRunningApp;
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow;
if (shouldStartOverlay) {
if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
@@ -280,19 +258,14 @@ export async function runPlaybackCommandWithDeps(
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
);
}
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);
await deps.startOverlay(
appPath,
args,
mpvSocketPath,
isAppOwnedYoutubeFlow
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
: [],
);
} else if (pluginAutoStartEnabled) {
if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
+4 -44
View File
@@ -124,7 +124,6 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
action: 'show',
logLevel: 'warn',
},
settingsInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -160,14 +159,13 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
assert.equal(parsed.logLevel, 'warn');
});
test('applyInvocationsToArgs maps settings invocation to settings window', () => {
test('applyInvocationsToArgs maps bare config invocation to settings window', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: null,
settingsInvocation: {
logLevel: undefined,
configInvocation: {
action: undefined,
},
mpvInvocation: null,
appInvocation: null,
@@ -192,54 +190,16 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
texthookerOpenBrowser: false,
});
assert.equal(parsed.settings, true);
assert.equal(parsed.configSettings, 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,
+5 -14
View File
@@ -158,7 +158,7 @@ export function createDefaultArgs(
doctorRefreshKnownWords: false,
version: false,
update: false,
settings: false,
configSettings: 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.settings === true) parsed.settings = true;
if (options.config === true) parsed.configSettings = 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,19 +311,10 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel);
}
const action = (invocations.configInvocation.action || '').toLowerCase();
if (action === 'path') parsed.configPath = true;
if (!action) parsed.configSettings = true;
else if (action === 'path') parsed.configPath = true;
else if (action === 'show') parsed.configShow = true;
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;
else fail(`Unknown config action: ${invocations.configInvocation.action}`);
}
if (invocations.mpvInvocation) {
+2 -16
View File
@@ -22,7 +22,6 @@ export interface CommandActionInvocation {
export interface CliInvocations {
jellyfinInvocation: JellyfinInvocation | null;
configInvocation: CommandActionInvocation | null;
settingsInvocation: CommandActionInvocation | null;
mpvInvocation: CommandActionInvocation | null;
appInvocation: { appArgs: string[] } | null;
dictionaryTriggered: boolean;
@@ -59,7 +58,7 @@ function applyRootOptions(program: Command): void {
.option('--start', 'Explicitly start overlay')
.option('--log-level <level>', 'Log level')
.option('-v, --version', 'Show SubMiner version')
.option('--settings', 'Open settings window')
.option('--config', 'Open configuration window')
.option('-u, --update', 'Check for updates')
.option('-R, --rofi', 'Use rofi picker')
.option('-S, --start-overlay', 'Auto-start overlay')
@@ -89,7 +88,6 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'jf',
'doctor',
'config',
'settings',
'mpv',
'dictionary',
'dict',
@@ -140,7 +138,6 @@ 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;
@@ -296,7 +293,7 @@ export function parseCliPrograms(
commandProgram
.command('config')
.description('Config file helpers (path|show)')
.description('Config helpers')
.argument('[action]', 'path|show')
.option('--log-level <level>', 'Log level')
.action((action: string | undefined, options: Record<string, unknown>) => {
@@ -306,16 +303,6 @@ 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')
@@ -369,7 +356,6 @@ export function parseCliPrograms(
invocations: {
jellyfinInvocation,
configInvocation,
settingsInvocation,
mpvInvocation,
appInvocation,
dictionaryTriggered,
+8 -8
View File
@@ -232,7 +232,7 @@ test('doctor refresh-known-words forwards app refresh command without requiring
});
});
test('launcher settings option forwards app settings window command', () => {
test('launcher config option forwards app configuration window command', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
@@ -249,14 +249,14 @@ test('launcher settings option forwards app settings window command', () => {
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['--settings'], env);
const result = runLauncher(['--config'], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n');
});
});
test('launcher settings command forwards app settings window command', () => {
test('launcher config command forwards app configuration window command', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
@@ -273,14 +273,14 @@ test('launcher settings command forwards app settings window command', () => {
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['settings'], env);
const result = runLauncher(['config'], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n');
});
});
test('launcher settings command suppresses known Electron macOS menu diagnostics', () => {
test('launcher config 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 settings command suppresses known Electron macOS menu diagnostics
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
};
const result = runLauncher(['settings'], env);
const result = runLauncher(['config'], env);
assert.equal(result.status, 0);
assert.equal(result.stderr, 'real stderr line\n');
+1 -265
View File
@@ -6,7 +6,6 @@ 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,
@@ -570,7 +569,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
doctor: false,
doctorRefreshKnownWords: false,
version: false,
settings: false,
configSettings: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -656,48 +655,6 @@ 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');
@@ -729,7 +686,6 @@ test('startOverlay borrows an already-running background app instead of owning i
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 {
@@ -741,226 +697,6 @@ test('startOverlay borrows an already-running background app instead of owning i
}
});
test('startOverlay attaches through the running app control socket without spawning another app command', async () => {
if (process.platform === 'win32') return;
const { dir, socketPath } = createTempSocketPath();
const controlSocketPath = path.join(dir, 'control.sock');
const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log');
const receivedControlArgv: string[][] = [];
const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
'exit 0',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
const mpvServer = net.createServer((socket) => socket.end());
const controlServer = net.createServer((socket) => {
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8');
const newlineMatch = buffer.match(/\r?\n/);
if (!newlineMatch || newlineMatch.index === undefined) return;
const line = buffer.slice(0, newlineMatch.index).trim();
buffer = buffer.slice(newlineMatch.index + newlineMatch[0].length);
if (!line) return;
const payload = JSON.parse(line) as { argv?: unknown };
if (Array.isArray(payload.argv)) {
receivedControlArgv.push(
payload.argv.filter((value): value is string => typeof value === 'string'),
);
}
socket.end(JSON.stringify({ ok: true }) + '\n');
});
});
try {
process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath;
await new Promise<void>((resolve, reject) => {
mpvServer.once('error', reject);
mpvServer.listen(socketPath, resolve);
});
await new Promise<void>((resolve, reject) => {
controlServer.once('error', reject);
controlServer.listen(controlSocketPath, resolve);
});
await startOverlay(appPath, makeArgs(), socketPath);
const invocationText = fs.existsSync(appInvocationsPath)
? fs.readFileSync(appInvocationsPath, 'utf8')
: '';
assert.equal(invocationText, '');
assert.equal(receivedControlArgv.length, 1);
assert.deepEqual(receivedControlArgv[0]?.slice(0, 7), [
'--start',
'--managed-playback',
'--backend',
'x11',
'--socket',
socketPath,
'--log-level',
]);
assert.equal(state.overlayManagedByLauncher, false);
assert.equal(state.appPath, '');
} finally {
if (originalControlSocket === undefined) {
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
} else {
process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
}
await new Promise<void>((resolve) => mpvServer.close(() => resolve()));
await new Promise<void>((resolve) => controlServer.close(() => resolve()));
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('startOverlay uses caller config dir for app control socket discovery', async () => {
if (process.platform === 'win32') return;
const { dir, socketPath } = createTempSocketPath();
const configDir = path.join(dir, 'launcher-config');
const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' });
const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log');
const receivedControlArgv: string[][] = [];
const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
'exit 0',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
const mpvServer = net.createServer((socket) => socket.end());
const controlServer = net.createServer((socket) => {
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8');
const newlineIndex = buffer.indexOf('\n');
if (newlineIndex < 0) return;
const payload = JSON.parse(buffer.slice(0, newlineIndex)) as { argv?: unknown };
if (Array.isArray(payload.argv)) {
receivedControlArgv.push(
payload.argv.filter((value): value is string => typeof value === 'string'),
);
}
socket.end(JSON.stringify({ ok: true }) + '\n');
});
});
try {
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
await new Promise<void>((resolve, reject) => {
mpvServer.once('error', reject);
mpvServer.listen(socketPath, resolve);
});
await new Promise<void>((resolve, reject) => {
controlServer.once('error', reject);
controlServer.listen(controlSocketPath, resolve);
});
await startOverlay(appPath, makeArgs(), socketPath, [], configDir);
const invocationText = fs.existsSync(appInvocationsPath)
? fs.readFileSync(appInvocationsPath, 'utf8')
: '';
assert.equal(invocationText, '');
assert.equal(receivedControlArgv.length, 1);
assert.deepEqual(receivedControlArgv[0]?.slice(0, 6), [
'--start',
'--managed-playback',
'--backend',
'x11',
'--socket',
socketPath,
]);
} finally {
if (originalControlSocket === undefined) {
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
} else {
process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
}
await new Promise<void>((resolve) => mpvServer.close(() => resolve()));
await new Promise<void>((resolve) => controlServer.close(() => resolve()));
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('startOverlay falls back to legacy app startup when control command fails', async () => {
if (process.platform === 'win32') return;
const { dir, socketPath } = createTempSocketPath();
const controlSocketPath = path.join(dir, 'control.sock');
const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log');
const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
'exit 0',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
const controlServer = net.createServer((socket) => {
socket.on('data', () => {
socket.end(JSON.stringify({ ok: false, error: 'boom' }) + '\n');
});
});
try {
process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath;
await new Promise<void>((resolve, reject) => {
controlServer.once('error', reject);
controlServer.listen(controlSocketPath, resolve);
});
await startOverlay(appPath, makeArgs(), socketPath);
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
assert.match(invocationText, /--app-ping/);
assert.match(invocationText, /--start/);
} finally {
if (originalControlSocket === undefined) {
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
} else {
process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
}
await new Promise<void>((resolve) => controlServer.close(() => resolve()));
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('startOverlay keeps lifecycle ownership for its already-managed app', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
+4 -80
View File
@@ -4,11 +4,6 @@ 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,
@@ -1006,73 +1001,22 @@ 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 appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
const overlayArgs = [
'--start',
'--managed-playback',
'--backend',
backend,
'--socket',
socketPath,
...extraAppArgs,
];
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath, ...extraAppArgs];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
if (args.useTexthooker) overlayArgs.push('--texthooker');
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);
const target = resolveAppSpawnTarget(appPath, overlayArgs);
state.overlayProc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(process.env, target.env),
});
attachAppProcessLogging(state.overlayProc);
if (borrowingExistingApp) {
if (appAlreadyRunning && !(state.overlayManagedByLauncher && state.appPath === appPath)) {
log(
'debug',
args.logLevel,
@@ -1101,26 +1045,6 @@ 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;
+7 -15
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 settings window option', () => {
const parsed = parseArgs(['--settings'], 'subminer', {});
test('parseArgs maps root config window option', () => {
const parsed = parseArgs(['--config'], 'subminer', {});
assert.equal(parsed.settings, true);
assert.equal(parsed.configSettings, 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 settings command to settings window', () => {
const parsed = parseArgs(['settings'], 'subminer', {});
test('parseArgs maps bare config command to settings window', () => {
const parsed = parseArgs(['config'], 'subminer', {});
assert.equal(parsed.settings, true);
assert.equal(parsed.configSettings, 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.settings, false);
assert.equal(parsed.configSettings, false);
});
test('parseArgs rejects removed config open and launch actions', () => {
@@ -134,14 +134,6 @@ 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', {});
+29 -128
View File
@@ -238,94 +238,6 @@ 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 {
@@ -383,7 +295,7 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
});
test(
'launcher start-overlay run forwards socket/backend and stops owned background app after mpv exits',
'launcher start-overlay run forwards socket/backend and keeps background app alive after mpv exits',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => {
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
@@ -418,9 +330,7 @@ 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);
@@ -441,53 +351,44 @@ test(
);
test(
'launcher start-overlay attaches to a running background app without spawning another app command',
'launcher start-overlay borrows a running background app and does not stop it after mpv exits',
{ 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 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 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(appStartPath, 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);
const appEntries = readJsonLines(appLogPath);
const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath);
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();
}
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.ok(
appEntries.some(
(entry) => Array.isArray(entry.argv) && (entry.argv as string[]).includes('--app-ping'),
),
);
assert.equal(appStartEntries.length, 1);
assert.equal(appStopEntries.length, 0);
});
},
);
+1 -1
View File
@@ -133,7 +133,7 @@ export interface Args {
doctorRefreshKnownWords: boolean;
version: boolean;
update?: boolean;
settings: boolean;
configSettings: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;
+4711
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.15.0-beta.4",
"version": "0.15.0-beta.3",
"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/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:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
"test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:core: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",
+3 -25
View File
@@ -60,31 +60,17 @@ function M.create(ctx)
return state.auto_start_retry_generation
end
local function has_matching_subminer_socket()
local function rearm_managed_subtitle_defaults()
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
@@ -97,7 +83,7 @@ function M.create(ctx)
return
end
local has_matching_socket = refresh_managed_subtitle_autoloading()
local has_matching_socket = rearm_managed_subtitle_defaults()
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()
@@ -123,13 +109,6 @@ function M.create(ctx)
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()
@@ -172,7 +151,7 @@ function M.create(ctx)
return
end
refresh_managed_subtitle_autoloading()
rearm_managed_subtitle_defaults()
schedule_aniskip_fetch("file-loaded", 0)
end
@@ -186,7 +165,6 @@ 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()
+4 -10
View File
@@ -207,9 +207,6 @@ function M.create(ctx)
end
if action == "start" then
if overrides.background ~= false then
table.insert(args, "--background")
end
table.insert(args, "--managed-playback")
local backend = resolve_backend(overrides.backend)
@@ -507,13 +504,10 @@ function M.create(ctx)
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 })
launch_overlay_with_retry(1)
if texthooker_enabled then
ensure_texthooker_running(function() end)
end
end
local function start_overlay_from_script_message(...)
+8 -64
View File
@@ -1,91 +1,35 @@
> 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 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.
- **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.
### 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 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 also 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. Update check traffic is routed through `/usr/bin/curl` to avoid Electron network-service crashes during video startup.
- **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 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.
- **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 — 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.
- **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.
- **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.
- **Launcher - Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
- **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.
- **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.
- **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.
## Installation
+16 -95
View File
@@ -757,17 +757,17 @@ do
assert_true(call ~= nil, "AppImage start should issue an async subprocess")
assert_true(#call.args == 1 and call.args[1] == appimage_path, "AppImage subprocess should not receive raw CLI flags")
assert_true(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_ARGC=7"), "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_2=--managed-playback"),
env_has(call, "SUBMINER_APP_ARG_1=--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(
not env_has(call, "SUBMINER_APP_ARG_1=--background"),
"AppImage subprocess should not transport --background for video-owned playback"
)
assert_true(env_has(call, "SUBMINER_APP_ARG_6=--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")
assert_true(
@@ -1095,54 +1095,18 @@ do
},
})
assert_true(recorded ~= nil, "plugin failed to load for subtitle rearm scenario: " .. tostring(err))
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
assert_true(
has_property_set(recorded.property_sets, "sub-auto", "fuzzy"),
"managed start-file should rearm sub-auto before mpv loads tracks"
"managed file-loaded should rearm sub-auto for idle mpv sessions"
)
assert_true(
has_property_set(recorded.property_sets, "sid", "auto"),
"managed start-file should rearm primary subtitle selection before mpv loads tracks"
"managed file-loaded should rearm primary subtitle selection for idle mpv sessions"
)
assert_true(
has_property_set(recorded.property_sets, "secondary-sid", "auto"),
"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"
"managed file-loaded should rearm secondary subtitle selection for idle mpv sessions"
)
end
@@ -1310,12 +1274,12 @@ do
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/tray mode"
not call_has_arg(start_call, "--background"),
"auto-start should not mark video-owned playback as background/tray mode"
)
assert_true(
call_has_arg(start_call, "--managed-playback"),
"auto-start should mark SubMiner as managed playback"
"auto-start should mark SubMiner as launcher-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")
@@ -1632,7 +1596,7 @@ do
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for shutdown-managed-background scenario: " .. tostring(err))
assert_true(recorded ~= nil, "plugin failed to load for shutdown-preserve-background scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
fire_event(recorded, "end-file", { reason = "quit" })
assert_true(
@@ -1642,7 +1606,7 @@ do
fire_event(recorded, "shutdown")
assert_true(
find_control_call(recorded.async_calls, "--stop") == nil,
"mpv shutdown should leave managed-playback ownership to the app process"
"mpv shutdown should not stop the background SubMiner process"
)
assert_true(
find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil,
@@ -1650,41 +1614,6 @@ 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 = "",
@@ -1704,14 +1633,6 @@ 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"
+1 -4
View File
@@ -92,10 +92,7 @@ for artifact in "$appimage" "$wrapper" "$assets"; do
fi
done
sha256sums=()
while IFS=' ' read -r sum _; do
sha256sums+=("$sum")
done < <(sha256sum "$appimage" "$wrapper" "$assets")
mapfile -t sha256sums < <(sha256sum "$appimage" "$wrapper" "$assets" | awk '{print $1}')
tmpfile="$(mktemp)"
awk \
+20 -18
View File
@@ -7,7 +7,7 @@ import {
isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
parseArgs,
shouldRunYomitanOnlyStartup,
shouldRunSettingsOnlyStartup,
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(shouldRunYomitanOnlyStartup(args), false);
assert.equal(shouldRunSettingsOnlyStartup(args), false);
});
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
@@ -208,33 +208,35 @@ 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(shouldRunYomitanOnlyStartup(settings), false);
assert.equal(commandNeedsOverlayRuntime(settings), false);
assert.equal(commandNeedsOverlayStartupPrereqs(settings), false);
assert.equal(shouldRunSettingsOnlyStartup(settings), true);
const yomitanWithOverlay = parseArgs(['--yomitan', '--toggle-visible-overlay']);
assert.equal(yomitanWithOverlay.yomitan, true);
assert.equal(yomitanWithOverlay.toggleVisibleOverlay, true);
assert.equal(shouldRunYomitanOnlyStartup(yomitanWithOverlay), 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 settingsDoesNotEnableYomitan = parseArgs(['--settings']);
assert.equal(settingsDoesNotEnableYomitan.yomitan, 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 help = parseArgs(['--help']);
assert.equal(help.help, true);
assert.equal(hasExplicitCommand(help), true);
assert.equal(shouldStartApp(help), false);
assert.equal(shouldRunYomitanOnlyStartup(help), false);
assert.equal(shouldRunSettingsOnlyStartup(help), false);
const appPing = parseArgs(['--app-ping']);
assert.equal(appPing.appPing, true);
+10 -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;
@@ -117,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,
@@ -239,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 === '--yomitan') args.yomitan = true;
else if (arg === '--settings') args.settings = true;
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
else if (arg === '--config') args.configSettings = true;
else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true;
else if (arg === '--hide') args.hide = true;
@@ -494,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 ||
@@ -569,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 &&
@@ -639,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 ||
@@ -687,16 +687,16 @@ export function shouldStartApp(args: CliArgs): boolean {
return false;
}
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
return (
args.yomitan &&
args.settings &&
!args.background &&
!args.start &&
!args.stop &&
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.settings &&
!args.configSettings &&
!args.show &&
!args.hide &&
!args.setup &&
+1 -2
View File
@@ -22,8 +22,7 @@ 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, /--settings\s+Open SubMiner settings window/);
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
assert.match(output, /--config\s+Open configuration 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/);
+2 -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
--yomitan Open Yomitan settings window
--settings Open SubMiner settings window
--settings Open Yomitan settings window
--config Open configuration window
--setup Open first-run setup window
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
+3 -27
View File
@@ -30,8 +30,6 @@ 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;
}
@@ -84,12 +82,6 @@ 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;
@@ -98,9 +90,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[];
const knownWords = propertyValue(findLastProperty(ankiConnect, 'knownWords'));
const knownWordsColorNode = propertyValue(findLastProperty(knownWords, 'color'));
const knownWordsColor = knownWordsColorNode ? getNodeValue(knownWordsColorNode) : undefined;
if (nPlusOneObjects.length === 0) {
return { operations, hasLegacy: false };
}
const canonicalNPlusOneValues = new Map<string, unknown>();
const legacyValues = new Map<keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, unknown>();
@@ -152,22 +144,6 @@ 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 };
}
+98 -25
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, false);
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A');
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
@@ -101,7 +101,6 @@ 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, 'transparent');
assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
@@ -223,10 +222,12 @@ test('throws actionable startup parse error for malformed config at construction
);
});
test('resolves legacy subtitle appearance options without rewriting config on load', () => {
test('migrates legacy subtitle appearance options into css declaration objects on load', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
const originalContent = `{
fs.writeFileSync(
configPath,
`{
"subtitleStyle": {
"fontSize": 42,
"fontColor": "#ffffff",
@@ -250,29 +251,63 @@ test('resolves legacy subtitle appearance options without rewriting config on lo
"font-size": "19px"
}
}
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
}`,
'utf-8',
);
const service = new ConfigService(dir);
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
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.deepEqual(service.getConfig().subtitleStyle.css, {
assert.deepEqual(parsed.subtitleStyle.css, {
color: '#ffffff',
'font-size': '44px',
'--subtitle-hover-token-color': '#abcdef',
'--subtitle-hover-token-background-color': 'transparent',
'text-wrap': 'balance',
});
assert.deepEqual(service.getConfig().subtitleStyle.secondary.css, {
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, {
color: '#bbbbbb',
'font-size': '28px',
});
assert.deepEqual(service.getConfig().subtitleSidebar.css, {
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontSize'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontColor'), false);
assert.deepEqual(parsed.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', () => {
@@ -2032,10 +2067,12 @@ test('ignores invalid legacy ankiConnect n+1 color value after migration attempt
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
});
test('resolves legacy ankiConnect n+1 color value without rewriting config', () => {
test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
const originalContent = `{
fs.writeFileSync(
configPath,
`{
"ankiConnect": {
"nPlusOne": {
"nPlusOne": "#c6a0f6"
@@ -2044,15 +2081,22 @@ test('resolves legacy ankiConnect n+1 color value without rewriting config', ()
"color": "#a6da95"
}
}
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: { nPlusOne?: Record<string, unknown> };
subtitleStyle: { nPlusOneColor?: string; knownWordColor?: string };
};
assert.equal(parsed.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.equal(Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, 'nPlusOne'), false);
});
test('legacy migration failures are logged and rethrown', () => {
@@ -2065,10 +2109,12 @@ test('legacy migration failures are logged and rethrown', () => {
assert.match(catchBlock, /throw error;/);
});
test('resolves legacy ankiConnect nPlusOne known-word settings without rewriting config', () => {
test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
const originalContent = `{
fs.writeFileSync(
configPath,
`{
"ankiConnect": {
"nPlusOne": {
"highlightEnabled": true,
@@ -2078,12 +2124,20 @@ test('resolves legacy ankiConnect nPlusOne known-word settings without rewriting
"knownWord": "#a6da95"
}
}
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
}`,
'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);
@@ -2094,14 +2148,28 @@ test('resolves legacy ankiConnect nPlusOne known-word settings without rewriting
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
});
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
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.ok(warnings.every((warning) => !warning.path.startsWith('ankiConnect.nPlusOne.')));
});
test('resolves duplicate ankiConnect nPlusOne objects without rewriting config', () => {
test('migrates duplicate ankiConnect nPlusOne objects to the modal path', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
const originalContent = `{
fs.writeFileSync(
configPath,
`{
"ankiConnect": {
"nPlusOne": {
"enabled": true,
@@ -2114,14 +2182,19 @@ test('resolves duplicate ankiConnect nPlusOne objects without rewriting config',
"minSentenceWords": "3"
}
}
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
}`,
'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(fs.readFileSync(configPath, 'utf-8'), originalContent);
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, '3');
});
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
+2 -1
View File
@@ -105,6 +105,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
primarySubLanguages: ['ja', 'jpn'],
},
subsync: {
defaultMode: 'auto',
alass_path: '',
ffsubsync_path: '',
ffmpeg_path: '',
@@ -115,7 +116,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
mecab: true,
yomitanExtension: true,
subtitleDictionaries: true,
jellyfinRemoteSession: false,
jellyfinRemoteSession: true,
},
updates: {
enabled: true,
+2 -2
View File
@@ -10,7 +10,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
autoPauseVideoOnYomitanPopup: true,
hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'transparent',
nameMatchEnabled: false,
nameMatchEnabled: true,
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: true,
pauseVideoOnHover: false,
autoScroll: true,
css: {},
maxWidth: 420,
+7
View File
@@ -388,6 +388,13 @@ 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',
+1 -1
View File
@@ -90,7 +90,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
key: 'secondarySub',
},
{
title: 'Subtitle Sync',
title: 'Auto Subtitle Sync',
description: ['Subsync engine and executable paths.'],
notes: ['Hot-reload: subsync changes apply to the next subtitle sync run.'],
key: 'subsync',
+7
View File
@@ -273,6 +273,13 @@ 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);
+1 -1
View File
@@ -162,7 +162,7 @@ test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, false);
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, true);
assert.ok(
warnings.some(
(warning) =>
+1
View File
@@ -149,6 +149,7 @@ export class ConfigService {
if (!migrated) {
return rawConfig;
}
fs.writeFileSync(configPath, content, 'utf-8');
return rawConfig;
} catch (error) {
console.error(`[ConfigService] legacy config migration failed for ${configPath}:`, error);
+5 -79
View File
@@ -19,85 +19,17 @@ 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, 'Playback Behavior');
assert.equal(field('auto_start_overlay').section, 'Visible Overlay Auto-Start');
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 Playback');
assert.equal(field('mpv.launchMode').section, 'MPV Launcher');
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');
@@ -202,8 +134,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.replace').category, 'integrations');
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
assert.equal(field('subsync.defaultMode').category, 'integrations');
assert.equal(field('subsync.defaultMode').section, 'Subtitle Sync');
});
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
@@ -258,7 +190,6 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
test('settings registry marks safe live config paths as hot-reloadable', () => {
for (const path of [
'mpv.aniskipButtonKey',
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
@@ -266,7 +197,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
'jimaku.apiBaseUrl',
'jimaku.languagePreference',
'jimaku.maxEntryResults',
'subsync.replace',
'subsync.defaultMode',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
@@ -287,11 +218,6 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
}
});
test('settings registry does not expose removed subsync mode option', () => {
const paths = new Set(fields.map((candidate) => candidate.configPath));
assert.equal(paths.has('subsync.defaultMode'), false);
});
test('settings registry keeps unsafe config siblings restart-required', () => {
for (const path of [
'stats.serverPort',
+11 -40
View File
@@ -65,17 +65,13 @@ 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;
@@ -127,11 +123,12 @@ const SECTION_ORDER = new Map<string, number>(
'Primary Subtitle Appearance',
'Secondary Subtitle Appearance',
'Subtitle Sidebar Appearance',
'Playback Behavior',
'Playback Pause Behavior',
'Subtitle Behavior',
'Subtitle Sidebar Behavior',
'Visible Overlay Auto-Start',
'YouTube Playback Settings',
'mpv Playback',
'MPV Launcher',
'Note Fields',
'Media Capture',
'Kiku/Lapis Features',
@@ -143,19 +140,7 @@ 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]),
);
@@ -184,9 +169,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]),
);
@@ -201,6 +186,7 @@ const SUBSECTION_ORDER = new Map<string, number>(
'Toggle & Visibility',
'Open Panels',
'Playback',
'Timing',
'Default Fold State',
].map((subsection, index) => [subsection, index]),
);
@@ -229,7 +215,6 @@ 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> = {
@@ -247,10 +232,6 @@ 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> {
@@ -314,7 +295,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
path === 'subtitleSidebar.pauseVideoOnHover'
) {
return { category: 'behavior', section: 'Playback Behavior' };
return { category: 'behavior', section: 'Playback Pause Behavior' };
}
if (path === 'subtitleStyle.preserveLineBreaks') {
return { category: 'behavior', section: 'Subtitle Behavior' };
@@ -392,15 +373,8 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('ankiConnect.')) {
return { category: 'mining-anki', section: 'AnkiConnect' };
}
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 === 'auto_start_overlay') {
return { category: 'behavior', section: topSection(path) };
}
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
return { category: 'behavior', section: topSection(path) };
@@ -463,7 +437,7 @@ function topSection(path: string): string {
jimaku: 'Jimaku',
jellyfin: 'Jellyfin',
logging: 'Logging',
mpv: 'mpv Playback',
mpv: 'MPV Launcher',
stats: 'Stats dashboard',
startupWarmups: 'Startup warmups',
subsync: 'Subtitle Sync',
@@ -473,7 +447,7 @@ function topSection(path: string): string {
yomitan: 'Yomitan',
youtube: 'YouTube Playback Settings',
youtubeSubgen: 'YouTube subtitle generation',
auto_start_overlay: 'Playback Behavior',
auto_start_overlay: 'Visible Overlay Auto-Start',
};
return labels[top] ?? humanizePath(top);
}
@@ -541,11 +515,9 @@ 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' ||
@@ -660,7 +632,6 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
path === 'ankiConnect.fields.miscInfo' ||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
path === 'ankiConnect.isKiku.fieldGrouping' ||
path === 'mpv.aniskipButtonKey' ||
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey' ||
path === 'logging.level' ||
+1 -95
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,
@@ -223,97 +223,3 @@ test('startAppLifecycle queues second-instance commands until app ready runtime
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']);
});
+2 -21
View File
@@ -13,7 +13,6 @@ 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;
@@ -42,7 +41,6 @@ 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;
@@ -72,7 +70,6 @@ export function createAppLifecycleDepsRuntime(
handleCliCommand: options.handleCliCommand,
printHelp: options.printHelp,
logNoRunningInstance: options.logNoRunningInstance,
startControlServer: options.startControlServer,
whenReady: (handler) => {
options.app
.whenReady()
@@ -119,7 +116,6 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
let appReadyRuntimeComplete = false;
const pendingSecondInstanceCommands: CliArgs[] = [];
let stopControlServer: (() => void) | null = null;
const handleSecondInstanceCommand = (args: CliArgs): void => {
try {
deps.handleCliCommand(args, 'second-instance');
@@ -137,7 +133,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
}
};
const dispatchSecondInstanceArgv = (argv: string[]): void => {
deps.onSecondInstance((_event, argv) => {
try {
const nextArgs = deps.parseArgs(argv);
if (!appReadyRuntimeComplete) {
@@ -149,10 +145,6 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
} catch (error) {
logger.error('Failed to handle second-instance CLI command:', error);
}
};
deps.onSecondInstance((_event, argv) => {
dispatchSecondInstanceArgv(argv);
});
if (!deps.shouldStartApp(initialArgs)) {
@@ -165,12 +157,6 @@ 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;
@@ -178,17 +164,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
});
deps.onWindowAllClosed(() => {
if (
deps.shouldQuitOnWindowAllClosed() &&
(!deps.isDarwinPlatform() || initialArgs.settings || initialArgs.setup)
) {
if (!deps.isDarwinPlatform() && deps.shouldQuitOnWindowAllClosed()) {
deps.quitApp();
}
});
deps.onWillQuit(() => {
stopControlServer?.();
stopControlServer = null;
deps.onWillQuitCleanup();
});
+4 -134
View File
@@ -1,11 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CliArgs } from '../../cli/args';
import {
CliCommandServiceDeps,
createCliCommandDepsRuntime,
handleCliCommand,
} from './cli-command';
import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
@@ -19,8 +15,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,
@@ -505,132 +501,6 @@ 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,
@@ -716,8 +586,8 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
args: Partial<CliArgs>;
expected: string;
}> = [
{ args: { yomitan: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ args: { settings: true }, expected: 'openConfigSettingsWindow' },
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ args: { configSettings: true }, expected: 'openConfigSettingsWindow' },
{
args: { showVisibleOverlay: true },
expected: 'setVisibleOverlayVisible:true',
+2 -7
View File
@@ -115,7 +115,6 @@ export interface CliCommandServiceDeps {
interface MpvClientLike {
setSocketPath: (socketPath: string) => void;
connect: () => void;
reconnect?: () => void;
}
interface TexthookerServiceLike {
@@ -236,10 +235,6 @@ export function createCliCommandDepsRuntime(
connectMpvClient: () => {
const client = options.mpv.getClient();
if (!client) return;
if (client.reconnect) {
client.reconnect();
return;
}
client.connect();
},
isTexthookerRunning: () => options.texthooker.service.isRunning(),
@@ -391,9 +386,9 @@ export function handleCliCommand(
} else if (args.setup) {
deps.openFirstRunSetup(true);
deps.logDebug('Opened first-run setup flow.');
} else if (args.yomitan) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.settings) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.configSettings) {
deps.openConfigSettingsWindow();
} else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true);
+2 -4
View File
@@ -21,13 +21,12 @@ test('classifyConfigHotReloadDiff separates hot and restart-required fields', ()
test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloadable', () => {
const prev = deepCloneConfig(DEFAULT_CONFIG);
const next = deepCloneConfig(DEFAULT_CONFIG);
next.mpv.aniskipButtonKey = 'F8';
next.stats.toggleKey = 'F8';
next.stats.markWatchedKey = 'F9';
next.logging.level = 'debug';
next.youtube.primarySubLanguages = ['ja', 'en'];
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
next.subsync.replace = !prev.subsync.replace;
next.subsync.defaultMode = prev.subsync.defaultMode === 'auto' ? 'manual' : 'auto';
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
@@ -53,12 +52,11 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
new Set(diff.hotReloadFields),
new Set([
'stats.toggleKey',
'mpv.aniskipButtonKey',
'stats.markWatchedKey',
'logging.level',
'youtube.primarySubLanguages',
'jimaku.maxEntryResults',
'subsync.replace',
'subsync.defaultMode',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
-1
View File
@@ -56,7 +56,6 @@ const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitle
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'secondarySub.defaultMode',
'mpv.aniskipButtonKey',
'ankiConnect.ai.enabled',
'stats.toggleKey',
'stats.markWatchedKey',
+1 -51
View File
@@ -32,12 +32,6 @@ class FakeSocket extends EventEmitter {
}
}
class ManualCloseSocket extends FakeSocket {
override destroy(): void {
this.destroyed = true;
}
}
const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
@@ -209,15 +203,12 @@ test('MpvSocketTransport ignores connect requests while already connecting or co
});
test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () => {
const events: string[] = [];
const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock',
onConnect: () => {},
onData: () => {},
onError: () => {},
onClose: () => {
events.push('close');
},
onClose: () => {},
socketFactory: () => new FakeSocket() as unknown as net.Socket,
});
@@ -229,45 +220,4 @@ test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () =
assert.equal(transport.isConnected, false);
assert.equal(transport.isConnecting, false);
assert.equal(transport.getSocket(), null);
assert.deepEqual(events, []);
});
test('MpvSocketTransport ignores stale socket events after shutdown and reconnect', async () => {
const events: string[] = [];
const sockets: ManualCloseSocket[] = [];
const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock',
onConnect: () => {
events.push('connect');
},
onData: () => {
events.push('data');
},
onError: () => {
events.push('error');
},
onClose: () => {
events.push('close');
},
socketFactory: () => {
const socket = new ManualCloseSocket();
sockets.push(socket);
return socket as unknown as net.Socket;
},
});
transport.connect();
await wait();
transport.shutdown();
transport.connect();
await wait();
const eventsBeforeStaleSocket = [...events];
sockets[0]!.emit('data', Buffer.from('{}'));
sockets[0]!.emit('error', new Error('stale'));
sockets[0]!.emit('close');
assert.deepEqual(events, eventsBeforeStaleSocket);
assert.equal(transport.isConnected, true);
assert.equal(transport.getSocket(), sockets[1]);
});
+10 -16
View File
@@ -105,37 +105,32 @@ export class MpvSocketTransport {
}
this.connecting = true;
const socket = this.socketFactory();
this.socketRef = socket;
this.socket = socket;
this.socketRef = this.socketFactory();
this.socket = this.socketRef;
socket.on('connect', () => {
if (this.socketRef !== socket) return;
this.socketRef.on('connect', () => {
this.connected = true;
this.connecting = false;
this.callbacks.onConnect();
});
socket.on('data', (data: Buffer) => {
if (this.socketRef !== socket) return;
this.socketRef.on('data', (data: Buffer) => {
this.callbacks.onData(data);
});
socket.on('error', (error: Error) => {
if (this.socketRef !== socket) return;
this.socketRef.on('error', (error: Error) => {
this.connected = false;
this.connecting = false;
this.callbacks.onError(error);
});
socket.on('close', () => {
if (this.socketRef !== socket) return;
this.socketRef.on('close', () => {
this.connected = false;
this.connecting = false;
this.callbacks.onClose();
});
socket.connect(this.socketPath);
this.socketRef.connect(this.socketPath);
}
send(payload: MpvSocketMessagePayload): boolean {
@@ -149,14 +144,13 @@ export class MpvSocketTransport {
}
shutdown(): void {
const socket = this.socketRef;
if (this.socketRef) {
this.socketRef.destroy();
}
this.socketRef = null;
this.socket = null;
this.connected = false;
this.connecting = false;
if (socket) {
socket.destroy();
}
}
getSocket(): net.Socket | null {
-31
View File
@@ -168,37 +168,6 @@ test('MpvIpcClient connect logs connect-request at debug level', () => {
assert.equal(requestLogs.length, 1);
});
test('MpvIpcClient reconnect clears stale connected state and starts a fresh transport connect', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const calls: string[] = [];
const connectionChanges: boolean[] = [];
const resolved: unknown[] = [];
client.on('connection-change', ({ connected }) => {
connectionChanges.push(connected);
});
(client as any).connected = true;
(client as any).connecting = false;
(client as any).socket = {};
(client as any).pendingRequests.set(10, (message: unknown) => {
resolved.push(message);
});
(client as any).transport.shutdown = () => {
calls.push('shutdown');
};
(client as any).transport.connect = () => {
calls.push('connect');
};
client.reconnect();
assert.deepEqual(calls, ['shutdown', 'connect']);
assert.equal(client.connected, false);
assert.equal((client as any).connecting, true);
assert.equal((client as any).socket, null);
assert.deepEqual(connectionChanges, [false]);
assert.deepEqual(resolved, [{ request_id: 10, error: 'disconnected' }]);
});
test('MpvIpcClient failPendingRequests resolves outstanding requests as disconnected', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const resolved: unknown[] = [];
-15
View File
@@ -275,21 +275,6 @@ export class MpvIpcClient implements MpvClient {
this.transport.connect();
}
reconnect(): void {
logger.debug('MPV IPC reconnect requested.');
const wasConnected = this.connected;
this.transport.shutdown();
this.connected = false;
this.connecting = false;
this.socket = null;
this.playbackPaused = null;
if (wasConnected) {
this.emit('connection-change', { connected: false });
}
this.failPendingRequests();
this.connect();
}
private scheduleReconnect(): void {
this.reconnectAttempt = scheduleMpvReconnect({
attempt: this.reconnectAttempt,
+1 -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,
+14 -34
View File
@@ -41,6 +41,7 @@ function makeDeps(
return {
getMpvClient: () => mpvClient,
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath: '/usr/bin/alass',
ffsubsyncPath: '/usr/bin/ffsubsync',
ffmpegPath: '/usr/bin/ffmpeg',
@@ -67,7 +68,7 @@ test('triggerSubsyncFromConfig returns early when already in progress', async ()
assert.deepEqual(osd, ['Subsync already running']);
});
test('triggerSubsyncFromConfig opens manual picker', async () => {
test('triggerSubsyncFromConfig opens manual picker in manual mode', async () => {
const osd: string[] = [];
let payloadTrackCount = 0;
let inProgressState: boolean | null = null;
@@ -91,31 +92,6 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
assert.equal(inProgressState, false);
});
test('triggerSubsyncFromConfig does not run automatic sync', async () => {
const osd: string[] = [];
let payloadTrackCount = 0;
let spinnerRan = false;
await triggerSubsyncFromConfig(
makeDeps({
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
},
showMpvOsd: (text) => {
osd.push(text);
},
runWithSubsyncSpinner: async <T>(task: () => Promise<T>) => {
spinnerRan = true;
return task();
},
}),
);
assert.equal(payloadTrackCount, 1);
assert.equal(spinnerRan, false);
assert.deepEqual(osd, ['Subsync: choose engine and source']);
});
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
let payloadTrackCount = 0;
@@ -185,14 +161,14 @@ test('runSubsyncManual requires a source track for alass', async () => {
});
});
test('triggerSubsyncFromConfig does not validate sync tool paths before manual selection', async () => {
test('triggerSubsyncFromConfig reports path validation failures', async () => {
const osd: string[] = [];
const inProgress: boolean[] = [];
let payloadTrackCount = 0;
await triggerSubsyncFromConfig(
makeDeps({
getResolvedConfig: () => ({
defaultMode: 'auto',
alassPath: '/missing/alass',
ffsubsyncPath: '/missing/ffsubsync',
ffmpegPath: '/missing/ffmpeg',
@@ -200,18 +176,16 @@ test('triggerSubsyncFromConfig does not validate sync tool paths before manual s
setSubsyncInProgress: (value) => {
inProgress.push(value);
},
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
},
showMpvOsd: (text) => {
osd.push(text);
},
}),
);
assert.deepEqual(inProgress, [false]);
assert.equal(payloadTrackCount, 1);
assert.deepEqual(osd, ['Subsync: choose engine and source']);
assert.deepEqual(inProgress, [true, false]);
assert.ok(
osd.some((line) => line.startsWith('Subsync failed: Configured ffmpeg executable not found')),
);
});
function writeExecutableScript(filePath: string, content: string): void {
@@ -286,6 +260,7 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -351,6 +326,7 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -406,6 +382,7 @@ test('runSubsyncManual reports ffsubsync command failures with details', async (
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -471,6 +448,7 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -542,6 +520,7 @@ test('runSubsyncManual keeps internal alass source file alive until sync finishe
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -598,6 +577,7 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
+64 -2
View File
@@ -15,6 +15,9 @@ import {
SubsyncResolvedConfig,
} from '../../subsync/utils';
import { isRemoteMediaPath } from '../../jimaku/utils';
import { createLogger } from '../../logger';
const logger = createLogger('main:subsync');
interface FileExtractionResult {
path: string;
@@ -337,6 +340,57 @@ function validateFfsubsyncReference(videoPath: string): void {
}
}
async function runSubsyncAutoInternal(deps: SubsyncCoreDeps): Promise<SubsyncResult> {
const client = getMpvClientForSubsync(deps);
const context = await gatherSubsyncContext(client);
const resolved = deps.getResolvedConfig();
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
if (context.secondaryTrack) {
let secondaryExtraction: FileExtractionResult | null = null;
try {
secondaryExtraction = await extractSubtitleTrackToFile(
ffmpegPath,
context.videoPath,
context.secondaryTrack,
);
const alassResult = await subsyncToReference(
'alass',
secondaryExtraction.path,
context,
resolved,
client,
);
if (alassResult.ok) {
return alassResult;
}
} catch (error) {
logger.warn('Auto alass sync failed, trying ffsubsync fallback:', error);
} finally {
if (secondaryExtraction) {
cleanupTemporaryFile(secondaryExtraction);
}
}
}
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
if (!ffsubsyncPath) {
return {
ok: false,
message: 'No secondary subtitle for alass and ffsubsync not configured',
};
}
try {
validateFfsubsyncReference(context.videoPath);
} catch (error) {
return {
ok: false,
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
};
}
return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client);
}
export async function runSubsyncManual(
request: SubsyncManualRunRequest,
deps: SubsyncCoreDeps,
@@ -394,9 +448,17 @@ export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDep
return;
}
const resolved = deps.getResolvedConfig();
try {
await openSubsyncManualPicker(deps);
deps.showMpvOsd('Subsync: choose engine and source');
if (resolved.defaultMode === 'manual') {
await openSubsyncManualPicker(deps);
deps.showMpvOsd('Subsync: choose engine and source');
return;
}
deps.setSubsyncInProgress(true);
const result = await deps.runWithSubsyncSpinner(() => runSubsyncAutoInternal(deps));
deps.showMpvOsd(result.message);
} catch (error) {
deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`);
} finally {
-32
View File
@@ -217,38 +217,6 @@ test('serializeSubtitleWebsocketMessage emits structured token api payload', ()
});
});
test('serializeSubtitleWebsocketMessage can force plain subtitle payloads', () => {
const payload: SubtitleData = {
text: '無事',
tokens: [
{
surface: '無事',
reading: 'ぶじ',
headword: '無事',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: true,
isNPlusOneTarget: false,
jlptLevel: 'N2',
frequencyRank: 745,
},
],
};
const raw = serializeSubtitleWebsocketMessage(payload, frequencyOptions, {
payloadMode: 'plain',
});
assert.deepEqual(JSON.parse(raw), {
version: 1,
text: '無事',
sentence: '無事',
tokens: [],
});
});
test('serializeInitialSubtitleWebsocketMessage keeps annotated current subtitle content', () => {
const payload: SubtitleData = {
text: 'ignored fallback',
+2 -24
View File
@@ -18,12 +18,6 @@ export type SubtitleWebsocketFrequencyOptions = {
mode: 'single' | 'banded';
};
export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated';
type SubtitleWebsocketMessageOptions = {
payloadMode?: SubtitleWebsocketPayloadMode;
};
type SerializedSubtitleToken = Pick<
MergedToken,
| 'surface'
@@ -204,17 +198,7 @@ export function serializeSubtitleMarkup(
export function serializeSubtitleWebsocketMessage(
payload: SubtitleData,
options: SubtitleWebsocketFrequencyOptions,
messageOptions: SubtitleWebsocketMessageOptions = {},
): string {
if (messageOptions.payloadMode === 'plain') {
return JSON.stringify({
version: 1,
text: payload.text,
sentence: escapeHtml(payload.text).replaceAll('\n', '<br>'),
tokens: [],
});
}
return JSON.stringify({
version: 1,
text: payload.text,
@@ -226,21 +210,18 @@ export function serializeSubtitleWebsocketMessage(
export function serializeInitialSubtitleWebsocketMessage(
payload: SubtitleData | null,
options: SubtitleWebsocketFrequencyOptions,
messageOptions: SubtitleWebsocketMessageOptions = {},
): string | null {
if (!payload || !payload.text.trim()) {
return null;
}
return serializeSubtitleWebsocketMessage(payload, options, messageOptions);
return serializeSubtitleWebsocketMessage(payload, options);
}
export class SubtitleWebSocket {
private server: WebSocket.Server | null = null;
private latestMessage = '';
public constructor(private readonly payloadMode: SubtitleWebsocketPayloadMode = 'annotated') {}
public isRunning(): boolean {
return this.server !== null;
}
@@ -266,7 +247,6 @@ export class SubtitleWebSocket {
const currentMessage = serializeInitialSubtitleWebsocketMessage(
getCurrentSubtitleData(),
getFrequencyOptions(),
{ payloadMode: this.payloadMode },
);
if (currentMessage) {
ws.send(currentMessage);
@@ -282,9 +262,7 @@ export class SubtitleWebSocket {
public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void {
if (!this.server) return;
const message = serializeSubtitleWebsocketMessage(payload, options, {
payloadMode: this.payloadMode,
});
const message = serializeSubtitleWebsocketMessage(payload, options);
this.latestMessage = message;
for (const client of this.server.clients) {
if (client.readyState === WebSocket.OPEN) {
+28 -64
View File
@@ -21,6 +21,7 @@ import {
clipboard,
globalShortcut,
ipcMain,
net,
shell,
protocol,
Extension,
@@ -34,8 +35,6 @@ import {
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import { startAppControlServer } from './main/runtime/app-control-server';
import { getAppControlSocketPath } from './shared/app-control';
import {
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
@@ -92,7 +91,7 @@ protocol.registerSchemesAsPrivileged([
]);
import * as fs from 'fs';
import { execFile, spawn } from 'node:child_process';
import { spawn } from 'node:child_process';
import * as os from 'os';
import * as path from 'path';
import { MecabTokenizer } from './mecab-tokenizer';
@@ -168,7 +167,6 @@ import {
rememberAnilistAttemptedUpdateKey,
} from './main/runtime/domains/anilist';
import { DEFAULT_MIN_WATCH_RATIO } from './shared/watch-threshold';
import { shouldShowTexthookerTrayEntry } from './main/runtime/tray-main-actions';
import {
createApplyJellyfinMpvDefaultsHandler,
createBuildApplyJellyfinMpvDefaultsMainDepsHandler,
@@ -507,7 +505,11 @@ import {
createElectronAppUpdater,
isNativeUpdaterSupported,
} from './main/runtime/update/app-updater';
import { createCurlFetch, createGlobalFetch } from './main/runtime/update/fetch-adapter';
import {
createCurlFetch,
createElectronNetFetch,
createGlobalFetch,
} from './main/runtime/update/fetch-adapter';
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor';
import {
@@ -616,7 +618,6 @@ const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60;
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
const TRAY_TOOLTIP = 'SubMiner';
const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner';
const JELLYFIN_SETUP_PRELOAD_PATH = path.join(__dirname, 'preload-jellyfin-setup.js');
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
@@ -793,7 +794,7 @@ const bootServices = createMainBootServices({
warn: (message: string, details?: unknown) => console.warn(message, details),
error: (message: string, details?: unknown) => console.error(message, details),
}),
createSubtitleWebSocket: (payloadMode) => new SubtitleWebSocket(payloadMode),
createSubtitleWebSocket: () => new SubtitleWebSocket(),
createLogger,
createMainRuntimeRegistry,
createOverlayManager,
@@ -3076,16 +3077,6 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
return;
}
if (submission.action === 'open-config-settings') {
const opened = openConfigSettingsWindow();
firstRunSetupMessage = opened
? 'Opened SubMiner settings.'
: 'SubMiner settings are unavailable.';
if (opened) {
return { skipRender: true };
}
return;
}
if (submission.action === 'refresh') {
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
firstRunSetupMessage = snapshot.message;
@@ -4903,19 +4894,28 @@ flushPendingMpvLogWrites = () => {
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json'));
let updateService: ReturnType<typeof createUpdateService> | null = null;
const electronNetFetch = createElectronNetFetch({
fetch: (url, init) => net.fetch(url, init as RequestInit),
});
const globalFetchForUpdater = createGlobalFetch();
const curlFetch = createCurlFetch();
function createNativeUpdaterHttpExecutor() {
if (process.platform === 'darwin') {
return createCurlHttpExecutor();
}
if (process.platform === 'win32') {
return createFetchHttpExecutor();
}
return createCurlHttpExecutor();
return undefined;
}
function getFetchForUpdater() {
if (process.platform === 'win32') return globalFetchForUpdater;
return curlFetch;
if (process.platform === 'win32') {
return globalFetchForUpdater;
}
if (process.platform === 'linux') return curlFetch;
return electronNetFetch;
}
async function updateLauncherFromSelectedRelease(
@@ -4962,8 +4962,11 @@ function getUpdateService() {
isPackaged: app.isPackaged,
log: (message) => logger.info(message),
getChannel: () => getResolvedConfig().updates.channel,
configureHttpExecutor: createNativeUpdaterHttpExecutor,
disableDifferentialDownload: true,
configureHttpExecutor:
process.platform === 'darwin' || process.platform === 'win32'
? createNativeUpdaterHttpExecutor
: undefined,
disableDifferentialDownload: process.platform === 'darwin' || process.platform === 'win32',
isNativeUpdaterSupported: () =>
isNativeUpdaterSupported({
platform: process.platform,
@@ -4975,37 +4978,7 @@ function getUpdateService() {
});
const updateDialogPresenter = createUpdateDialogPresenter({
platform: process.platform,
focusApp: async () => {
if (process.platform !== 'darwin') {
app.focus({ steal: true });
return;
}
try {
await app.dock?.show();
} catch (error) {
logger.warn('Failed to show macOS dock before update dialog', error);
}
// app.focus({ steal: true }) alone does not reliably activate the process
// when SubMiner was reached via `subminer -u` (single-instance forwarding
// from a CLI-spawned child). osascript's `activate` uses LaunchServices,
// which is the only path that reliably brings the running app forward.
await new Promise<void>((resolve) => {
execFile(
'/usr/bin/osascript',
['-e', `tell application id "${SUBMINER_BUNDLE_ID}" to activate`],
{ timeout: 2000 },
(error) => {
if (error) {
logger.warn(
`Failed to activate SubMiner via osascript: ${error instanceof Error ? error.message : String(error)}`,
);
}
resolve();
},
);
});
app.focus({ steal: true });
},
focusApp: () => app.focus({ steal: true }),
showMessageBox: (options) => dialog.showMessageBox(options),
});
updateService = createUpdateService({
@@ -5809,16 +5782,6 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
startControlServer: (handleArgv: (argv: string[]) => void) => {
const server = startAppControlServer({
socketPath: getAppControlSocketPath({ configDir: CONFIG_DIR }),
platform: process.platform,
handleArgv,
logDebug: (message) => logger.debug(message),
logWarn: (message, error) => logger.warn(message, error),
});
return () => server.close();
},
onReady: runAppReadyRuntimeWithFatalReporting,
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
@@ -5966,11 +5929,12 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
openSessionHelpModal: () => openSessionHelpOverlay(),
openTexthookerInBrowser: () =>
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
showTexthookerPage: () => shouldShowTexthookerTrayEntry(getResolvedConfig()),
showTexthookerPage: () => getResolvedConfig().texthooker.launchAtStartup !== false,
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
openYomitanSettings: () => openYomitanSettings(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openConfigSettingsWindow: () => openConfigSettingsWindow(),
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
isJellyfinConfigured: () =>
-2
View File
@@ -11,7 +11,6 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
@@ -74,7 +73,6 @@ export function createAppLifecycleRuntimeDeps(
handleCliCommand: params.handleCliCommand,
printHelp: params.printHelp,
logNoRunningInstance: params.logNoRunningInstance,
startControlServer: params.startControlServer,
onReady: params.onReady,
onWillQuitCleanup: params.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
+2 -7
View File
@@ -21,7 +21,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
{ targetPath: string },
{ targetPath: string },
{ targetPath: string },
{ kind: string; payloadMode: 'plain' | 'annotated' },
{ kind: string },
{ scope: string; warn: () => void; info: () => void; error: () => void },
{ registry: boolean },
{ getMainWindow: () => null; getModalWindow: () => null },
@@ -76,7 +76,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
createAnilistTokenStore: (targetPath) => ({ targetPath }),
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
createSubtitleWebSocket: (payloadMode) => ({ kind: 'ws', payloadMode }),
createSubtitleWebSocket: () => ({ kind: 'ws' }),
createLogger: (scope) =>
({
scope,
@@ -115,11 +115,6 @@ test('createMainBootServices builds boot-phase service bundle', () => {
assert.deepEqual(services.anilistUpdateQueue, {
targetPath: '/tmp/subminer-config/anilist-retry-queue.json',
});
assert.deepEqual(services.subtitleWsService, { kind: 'ws', payloadMode: 'plain' });
assert.deepEqual(services.annotationSubtitleWsService, {
kind: 'ws',
payloadMode: 'annotated',
});
assert.deepEqual(services.appState, {
mpvSocketPath: '/tmp/subminer.sock',
texthookerPort: 5174,
+3 -3
View File
@@ -64,7 +64,7 @@ export interface MainBootServicesParams<
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
createSubtitleWebSocket: (payloadMode: 'plain' | 'annotated') => TSubtitleWebSocket;
createSubtitleWebSocket: () => TSubtitleWebSocket;
createLogger: (scope: string) => TLogger & {
warn: (message: string) => void;
info: (message: string) => void;
@@ -205,8 +205,8 @@ export function createMainBootServices<
const anilistUpdateQueue = params.createAnilistUpdateQueue(
params.joinPath(userDataPath, 'anilist-retry-queue.json'),
);
const subtitleWsService = params.createSubtitleWebSocket('plain');
const annotationSubtitleWsService = params.createSubtitleWebSocket('annotated');
const subtitleWsService = params.createSubtitleWebSocket();
const annotationSubtitleWsService = params.createSubtitleWebSocket();
const logger = params.createLogger('main');
const runtimeRegistry = params.createMainRuntimeRegistry();
const overlayManager = params.createOverlayManager();
-131
View File
@@ -1,131 +0,0 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { sendAppControlCommand } from '../../shared/app-control-client';
import { startAppControlServer } from './app-control-server';
async function waitForSocketPath(socketPath: string): Promise<void> {
const timeoutMs = 1000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (fs.existsSync(socketPath)) return;
await new Promise<void>((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timed out waiting for control socket ${socketPath} after ${timeoutMs}ms`);
}
test('app control server dispatches argv requests and replies ok', async () => {
if (process.platform === 'win32') return;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-control-test-'));
const socketPath = path.join(dir, 'control.sock');
const received: string[][] = [];
const server = startAppControlServer({
socketPath,
platform: 'linux',
handleArgv: (argv) => {
received.push(argv);
},
});
try {
await waitForSocketPath(socketPath);
const result = await sendAppControlCommand(['--start', '--socket', '/tmp/mpv.sock'], {
socketPath,
});
assert.deepEqual(result, { ok: true });
assert.deepEqual(received, [['--start', '--socket', '/tmp/mpv.sock']]);
} finally {
server.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('app control server rejects requests larger than 64KB by UTF-8 byte length', async () => {
if (process.platform === 'win32') return;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-control-test-'));
const socketPath = path.join(dir, 'control.sock');
const received: string[][] = [];
const server = startAppControlServer({
socketPath,
platform: 'linux',
handleArgv: (argv) => {
received.push(argv);
},
});
try {
await waitForSocketPath(socketPath);
const result = await sendAppControlCommand(
Array.from({ length: 4 }, () => 'あ'.repeat(6000)),
{
socketPath,
},
);
assert.deepEqual(result, { ok: false, error: 'App control request too large' });
assert.deepEqual(received, []);
} finally {
server.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('app control server logs and closes errored client sockets', () => {
const originalCreateServer = net.createServer;
let socketHandler: ((socket: net.Socket) => void) | null = null;
const fakeServer = new EventEmitter() as net.Server;
fakeServer.listen = (() => fakeServer) as net.Server['listen'];
fakeServer.close = ((callback?: (err?: Error) => void) => {
callback?.();
return fakeServer;
}) as net.Server['close'];
const received: string[][] = [];
const warnings: Array<{ message: string; error?: unknown }> = [];
try {
net.createServer = ((handler?: (socket: net.Socket) => void) => {
socketHandler = handler ?? null;
return fakeServer;
}) as typeof net.createServer;
const server = startAppControlServer({
socketPath: '\\\\.\\pipe\\subminer-test-control',
platform: 'win32',
handleArgv: (argv) => {
received.push(argv);
},
logWarn: (message, error) => {
warnings.push({ message, error });
},
});
const error = new Error('client reset');
let destroyed = false;
const socket = new EventEmitter() as net.Socket;
socket.destroy = (() => {
destroyed = true;
return socket;
}) as net.Socket['destroy'];
const handler = socketHandler as ((socket: net.Socket) => void) | null;
assert.ok(handler);
handler(socket);
socket.emit('error', error);
socket.emit('data', Buffer.from('{"argv":["--start"]}\n'));
assert.equal(destroyed, true);
assert.deepEqual(received, []);
assert.deepEqual(warnings, [{ message: 'App control client socket error.', error }]);
server.close();
} finally {
net.createServer = originalCreateServer;
}
});
-105
View File
@@ -1,105 +0,0 @@
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import {
encodeAppControlResponse,
parseAppControlRequestLine,
type AppControlResponse,
} from '../../shared/app-control';
export interface AppControlServerOptions {
socketPath: string;
platform?: NodeJS.Platform;
handleArgv: (argv: string[]) => void;
logDebug?: (message: string) => void;
logWarn?: (message: string, error?: unknown) => void;
}
export interface AppControlServerHandle {
close: () => void;
}
function prepareSocketPath(socketPath: string, platform: NodeJS.Platform): void {
if (platform === 'win32') return;
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
fs.rmSync(socketPath, { force: true });
}
function cleanupSocketPath(socketPath: string, platform: NodeJS.Platform): void {
if (platform === 'win32') return;
try {
fs.rmSync(socketPath, { force: true });
} catch {
// ignore
}
}
function writeResponse(socket: net.Socket, response: AppControlResponse): void {
socket.end(encodeAppControlResponse(response));
}
export function startAppControlServer(options: AppControlServerOptions): AppControlServerHandle {
const platform = options.platform ?? process.platform;
prepareSocketPath(options.socketPath, platform);
const server = net.createServer((socket) => {
let buffer = '';
let byteCount = 0;
let handled = false;
socket.on('error', (error) => {
if (handled) return;
handled = true;
options.logWarn?.('App control client socket error.', error);
socket.destroy();
});
socket.on('data', (chunk) => {
if (handled) return;
byteCount += chunk.length;
buffer += chunk.toString('utf8');
if (byteCount > 65536) {
handled = true;
writeResponse(socket, { ok: false, error: 'App control request too large' });
return;
}
const newlineIndex = buffer.indexOf('\n');
if (newlineIndex < 0) return;
handled = true;
try {
const request = parseAppControlRequestLine(buffer.slice(0, newlineIndex));
options.handleArgv(request.argv);
writeResponse(socket, { ok: true });
} catch (error) {
options.logWarn?.('Failed to handle app control command.', error);
writeResponse(socket, {
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
});
server.on('error', (error) => {
options.logWarn?.(`App control socket failed: ${options.socketPath}`, error);
});
server.listen(options.socketPath, () => {
options.logDebug?.(`App control socket listening: ${options.socketPath}`);
});
let closed = false;
return {
close: () => {
if (closed) return;
closed = true;
try {
server.close();
} catch {
// ignore
}
cleanupSocketPath(options.socketPath, platform);
},
};
}
@@ -2,10 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { createAutoplayTokenizationWarmRelease } from './autoplay-tokenization-warm-release';
function flushMicrotasks(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
test('autoplay tokenization warm release signals immediately when warmups are ready', () => {
const calls: string[] = [];
const release = createAutoplayTokenizationWarmRelease({
@@ -49,17 +45,14 @@ test('autoplay tokenization warm release primes subtitles before waiting for war
resolveWarmup();
await warmup;
await flushMicrotasks();
await Promise.resolve();
assert.deepEqual(calls, ['prime', 'warmup', 'signal']);
});
test('autoplay tokenization warm release waits for subtitle priming before signaling ready media', async () => {
test('autoplay tokenization warm release does not await subtitle priming before signaling ready media', async () => {
const calls: string[] = [];
let resolvePrime!: () => void;
const prime = new Promise<void>((resolve) => {
resolvePrime = resolve;
});
const never = new Promise<void>(() => {});
const release = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => true,
startTokenizationWarmups: async () => {
@@ -68,7 +61,7 @@ test('autoplay tokenization warm release waits for subtitle priming before signa
getCurrentMediaPath: () => '/tmp/video.mkv',
primeCurrentSubtitle: () => {
calls.push('prime');
return prime;
return never;
},
signalAutoplayReady: () => calls.push('signal'),
warn: () => {},
@@ -77,12 +70,6 @@ test('autoplay tokenization warm release waits for subtitle priming before signa
release('/tmp/video.mkv');
await Promise.resolve();
assert.deepEqual(calls, ['prime']);
resolvePrime();
await prime;
await Promise.resolve();
assert.deepEqual(calls, ['prime', 'signal']);
});
@@ -22,41 +22,24 @@ export function createAutoplayTokenizationWarmRelease(deps: {
deps.signalAutoplayReady();
};
const primeSubtitleForRelease = (mediaPath: string): Promise<void> | null => {
if (!deps.primeCurrentSubtitle) {
return null;
}
try {
return Promise.resolve(deps.primeCurrentSubtitle(mediaPath)).catch((error) => {
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
});
} catch (error) {
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
return null;
}
};
return (mediaPath) => {
const normalizedPath = normalizeMediaPath(mediaPath);
if (!normalizedPath) {
return;
}
const primePromise = primeSubtitleForRelease(normalizedPath);
if (deps.isTokenizationWarmupReady()) {
if (!primePromise) {
signalIfCurrent(normalizedPath);
return;
}
void primePromise.then(() => {
signalIfCurrent(normalizedPath);
try {
void Promise.resolve(deps.primeCurrentSubtitle?.(normalizedPath)).catch((error) => {
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
});
} catch (error) {
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
}
if (deps.isTokenizationWarmupReady()) {
signalIfCurrent(normalizedPath);
return;
}
const warmupPromise = deps.startTokenizationWarmups();
const readinessPromise = primePromise
? Promise.all([primePromise, warmupPromise]).then(() => {})
: warmupPromise;
void readinessPromise
void deps
.startTokenizationWarmups()
.then(() => {
signalIfCurrent(normalizedPath);
})
+1 -1
View File
@@ -10,7 +10,7 @@ const fields: ConfigSettingsField[] = [
description: 'Launch mode setting.',
configPath: 'mpv.launchMode',
category: 'behavior',
section: 'mpv Playback',
section: 'MPV Launcher',
control: 'select',
defaultValue: 'windowed',
restartBehavior: 'restart',
+1 -1
View File
@@ -27,7 +27,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
const window = deps.createSettingsWindow();
void Promise.resolve(window.loadFile(deps.settingsHtmlPath)).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
deps.log?.(`Failed to load settings window: ${message}`);
deps.log?.(`Failed to load configuration settings window: ${message}`);
deps.setSettingsWindow(null);
window.destroy?.();
});
@@ -29,8 +29,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,
@@ -122,12 +122,12 @@ function createCommandLineLauncherSnapshot(
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, settings: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, configSettings: true })), false);
assert.equal(
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })),
false,
);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ yomitan: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, update: true })), false);
});
+1 -1
View File
@@ -71,8 +71,8 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.launchMpv ||
args.yomitan ||
args.settings ||
args.configSettings ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
+5 -101
View File
@@ -59,15 +59,10 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
assert.match(html, /SubMiner setup/);
assert.doesNotMatch(html, /Install legacy mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
assert.doesNotMatch(html, /mpv runtime plugin/);
assert.match(html, /Ready/);
assert.doesNotMatch(html, /Bundled ready/);
assert.doesNotMatch(html, /Managed mpv launches use the bundled runtime plugin\./);
assert.match(html, /Managed mpv launches use the bundled runtime plugin\./);
assert.match(html, /Open Yomitan Settings/);
assert.match(html, /Open SubMiner Settings/);
assert.match(
html,
/action=open-yomitan-settings'">Open Yomitan Settings<\/button>\s*<button class="ghost" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=refresh'">Refresh status<\/button>\s*<button onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=open-config-settings'">Open SubMiner Settings<\/button>\s*<button class="primary" disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/,
);
assert.match(html, /Finish setup/);
assert.match(html, /disabled/);
assert.match(html, /html,\s*body\s*{\s*min-height:\s*100%;/);
@@ -75,7 +70,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
assert.match(html, /box-sizing:\s*border-box;/);
});
test('buildFirstRunSetupHtml omits bundled mpv plugin readiness when already installed', () => {
test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
@@ -99,11 +94,10 @@ test('buildFirstRunSetupHtml omits bundled mpv plugin readiness when already ins
assert.doesNotMatch(html, /Reinstall mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
assert.doesNotMatch(html, /mpv runtime plugin/);
assert.match(html, /mpv executable path/);
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
assert.match(html, /aria-label="Path to mpv\.exe"/);
assert.doesNotMatch(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
assert.match(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
});
test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirmation', () => {
@@ -130,8 +124,7 @@ test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirm
});
assert.match(html, /Legacy mpv plugin/);
assert.doesNotMatch(html, /mpv runtime plugin/);
assert.match(html, /Found/);
assert.match(html, /Legacy detected/);
assert.match(html, /\/tmp\/mpv\/scripts\/subminer/);
assert.match(html, /\/tmp\/mpv\/scripts\/subminer\.lua/);
assert.match(html, /Remove legacy mpv plugin/);
@@ -258,12 +251,6 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
action: 'remove-legacy-plugin',
},
);
assert.deepEqual(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=open-config-settings'),
{
action: 'open-config-settings',
},
);
assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
null,
@@ -555,89 +542,6 @@ test('opening first-run setup skips rendering if window is destroyed after snaps
assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'snapshot']);
});
test('first-run setup action can skip rerender after launching another window', async () => {
const calls: string[] = [];
let navigateHandler: ((event: unknown, url: string) => void) | undefined;
const handler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () =>
({
webContents: {
on: (_event: 'will-navigate', callback: (event: unknown, url: string) => void) => {
navigateHandler = callback;
},
},
loadURL: async () => {
calls.push('load');
},
on: () => {},
isDestroyed: () => false,
close: () => {},
show: () => calls.push('show'),
focus: () => calls.push('focus'),
}) as never,
getSetupSnapshot: async () => ({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
}),
buildSetupHtml: () => '<html></html>',
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
handleAction: async () => {
calls.push('action');
return { skipRender: true };
},
markSetupInProgress: async () => {
calls.push('in-progress');
},
markSetupCancelled: async () => undefined,
isSetupCompleted: () => true,
shouldQuitWhenClosedIncomplete: () => false,
quitApp: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {
calls.push('set');
},
encodeURIComponent: (value) => value,
logError: () => {},
});
handler();
await new Promise((resolve) => setTimeout(resolve, 0));
navigateHandler?.(
{ preventDefault: () => calls.push('preventDefault') },
'subminer://first-run-setup?action=open-config-settings',
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
'set',
'show',
'focus',
'in-progress',
'load',
'show',
'focus',
'preventDefault',
'action',
]);
});
test('closing incomplete first-run setup quits app outside background mode', async () => {
const calls: string[] = [];
let closedHandler: (() => void) | undefined;
+18 -10
View File
@@ -29,7 +29,6 @@ export type FirstRunSetupAction =
| 'install-bun'
| 'install-command-line-launcher'
| 'open-yomitan-settings'
| 'open-config-settings'
| 'refresh'
| 'finish';
@@ -201,6 +200,14 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
legacyMpvPluginPaths.length > 0 && model.canFinish
? 'Continue without removing'
: 'Finish setup';
const pluginLabel =
legacyMpvPluginPaths.length > 0
? 'Legacy detected'
: model.pluginStatus === 'failed'
? 'Failed'
: 'Ready';
const pluginTone =
legacyMpvPluginPaths.length > 0 ? 'warn' : model.pluginStatus === 'failed' ? 'danger' : 'ready';
const windowsShortcutLabel =
model.windowsMpvShortcuts.status === 'installed'
? 'Installed'
@@ -319,7 +326,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
: model.canFinish
? model.externalYomitanConfigured
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary.'
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary. SubMiner-managed mpv launches use the bundled runtime plugin.'
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
return `<!doctype html>
@@ -515,6 +522,14 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>
${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')}
</div>
<div class="card">
<div>
<strong>mpv runtime plugin</strong>
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
<div class="meta">Managed mpv launches use the bundled runtime plugin.</div>
</div>
${renderStatusBadge(pluginLabel, pluginTone)}
</div>
<div class="card">
<div>
<strong>Yomitan dictionaries</strong>
@@ -529,7 +544,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-config-settings'">Open SubMiner Settings</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
</div>
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
@@ -552,7 +566,6 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
action !== 'install-bun' &&
action !== 'install-command-line-launcher' &&
action !== 'open-yomitan-settings' &&
action !== 'open-config-settings' &&
action !== 'refresh' &&
action !== 'finish'
) {
@@ -619,9 +632,7 @@ export function createOpenFirstRunSetupWindowHandler<
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
handleAction: (
submission: FirstRunSetupSubmission,
) => Promise<{ closeWindow?: boolean; skipRender?: boolean } | void>;
handleAction: (submission: FirstRunSetupSubmission) => Promise<{ closeWindow?: boolean } | void>;
markSetupInProgress: () => Promise<unknown>;
markSetupCancelled: () => Promise<unknown>;
isSetupCompleted: () => boolean;
@@ -669,9 +680,6 @@ export function createOpenFirstRunSetupWindowHandler<
}
return;
}
if (result?.skipRender) {
return;
}
if (!setupWindow.isDestroyed()) {
await render();
}
@@ -144,34 +144,3 @@ test('managed local subtitle selection runtime promotes a single unlabeled exter
['set_property', 'secondary-sid', 1],
]);
});
test('managed local subtitle selection keeps waiting for primary after early secondary-only track list', () => {
const commands: Array<Array<string | number>> = [];
const runtime = createManagedLocalSubtitleSelectionRuntime({
getCurrentMediaPath: () => '/videos/example.mkv',
getMpvClient: () => null,
getPrimarySubtitleLanguages: () => [],
getSecondarySubtitleLanguages: () => [],
sendMpvCommand: (command) => {
commands.push(command);
},
schedule: () => 1 as never,
clearScheduled: () => {},
});
runtime.handleMediaPathChange('/videos/example.mkv');
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 1, lang: 'eng', title: 'ASS', external: false },
{ type: 'sub', id: 2, lang: 'en', title: 'en.srt', external: true },
]);
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 1, lang: 'eng', title: 'ASS', external: false },
{ type: 'sub', id: 2, lang: 'en', title: 'en.srt', external: true },
{ type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true },
]);
assert.deepEqual(commands, [
['set_property', 'secondary-sid', 2],
['set_property', 'sid', 3],
]);
});
+8 -17
View File
@@ -200,8 +200,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
}) {
const delayMs = deps.delayMs ?? 400;
let currentMediaPath: string | null = null;
let appliedPrimaryMediaPath: string | null = null;
let appliedSecondaryMediaPath: string | null = null;
let appliedMediaPath: string | null = null;
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
const clearPendingTimer = (): void => {
@@ -213,11 +212,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
};
const maybeApplySelection = (trackList: unknown[] | null): void => {
if (
!currentMediaPath ||
(appliedPrimaryMediaPath === currentMediaPath &&
appliedSecondaryMediaPath === currentMediaPath)
) {
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
return;
}
const selection = resolveManagedLocalSubtitleSelection({
@@ -228,17 +223,14 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
if (!selection.hasPrimaryMatch && !selection.hasSecondaryMatch) {
return;
}
if (selection.primaryTrackId !== null && appliedPrimaryMediaPath !== currentMediaPath) {
if (selection.primaryTrackId !== null) {
deps.sendMpvCommand(['set_property', 'sid', selection.primaryTrackId]);
appliedPrimaryMediaPath = currentMediaPath;
}
if (selection.secondaryTrackId !== null && appliedSecondaryMediaPath !== currentMediaPath) {
if (selection.secondaryTrackId !== null) {
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
appliedSecondaryMediaPath = currentMediaPath;
}
if (appliedPrimaryMediaPath === currentMediaPath) {
clearPendingTimer();
}
appliedMediaPath = currentMediaPath;
clearPendingTimer();
};
const refreshFromMpv = async (): Promise<void> => {
@@ -260,7 +252,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
const scheduleRefresh = (): void => {
clearPendingTimer();
if (!currentMediaPath || appliedPrimaryMediaPath === currentMediaPath) {
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
return;
}
pendingTimer = deps.schedule(() => {
@@ -273,8 +265,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
handleMediaPathChange: (mediaPath: string | null | undefined): void => {
const normalizedPath = normalizeLocalMediaPath(mediaPath);
if (normalizedPath !== currentMediaPath) {
appliedPrimaryMediaPath = null;
appliedSecondaryMediaPath = null;
appliedMediaPath = null;
}
currentMediaPath = normalizedPath;
if (!currentMediaPath) {

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