Compare commits

...

7 Commits

Author SHA1 Message Date
sudacode efe50ed1e4 docs: add startup flow diagram and document new config options (#90) 2026-05-26 23:51:36 -07:00
sudacode 5b44981688 docs: remove legacy option refs and modernize config docs (#89) 2026-05-26 18:15:23 -07:00
sudacode 2add95d541 fix(anilist): dedupe failures during retry cooldown and block dead-lette
- Ignore markFailure calls while an item is still within its retry backoff window
- Prevent enqueue from re-adding keys already in the dead-letter queue
2026-05-26 01:57:25 -07:00
sudacode f62fff2585 chore(release): 0.15.0-beta.9 2026-05-26 00:55:34 -07:00
sudacode 11c196821d Fix Windows mpv logging and add log export (#88) 2026-05-26 00:31:38 -07:00
sudacode 43ebc7d371 chore: prepare 0.15.0-beta.8 prerelease 2026-05-25 20:34:56 -07:00
sudacode 639e331f24 fix(character-dictionary): add surname honorifics for Japanese localized aliases (#87) 2026-05-25 20:12:27 -07:00
170 changed files with 3145 additions and 827 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anilist
- Prevent repeated missing-token checks from rapidly exhausting AniList retry attempts or duplicating dead-letter entries for the same episode.
@@ -0,0 +1,4 @@
type: fixed
area: character-dictionary
- Block the character dictionary manager when character dictionary annotations are disabled, and notify through the configured OSD/system notification surfaces.
@@ -0,0 +1,7 @@
type: changed
area: docs
- Documented all config options that were present in `config.example.jsonc` but missing from the configuration reference: `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options (`socketPath`, `backend`, `autoStartSubMiner`, `pauseUntilOverlayReady`, `subminerBinaryPath`, `aniskipEnabled`, `aniskipButtonKey`).
- Added a **Playback Startup Flow** diagram to the Architecture page showing how the managed launch (`subminer` CLI, app, Windows shortcut) injects the plugin, establishes the IPC socket, and brings up the overlay via the two convergent triggers.
- Added a **Runtime Sockets** section and diagram to the IPC + Runtime Contracts page showing the mpv IPC socket and app control socket topology.
- Added cross-reference pointers in the MPV Plugin and Troubleshooting pages.
@@ -0,0 +1,4 @@
type: fixed
area: character-dictionary
- Added surname honorific matches for Japanese localized character aliases embedded in AniList alternative names, such as Korean-source characters with Japanese names in parentheses, and refresh cached snapshots so those aliases are regenerated.
@@ -0,0 +1,4 @@
type: fixed
area: mpv
- Pass generated session-action CLI args to the mpv plugin.
@@ -0,0 +1,7 @@
type: fixed
area: logging
- Forward SubMiner `logging.level` into launcher-started and Windows shortcut-started mpv sessions, including mpv log verbosity, plugin script logging, and plugin-launched app logging.
- Add numeric `logging.rotation`, defaulting to 7 days of retained daily app, launcher, and mpv logs.
- Log Windows mpv launch diagnostics, IPC socket connection state, subtitle track summaries, Yomitan extension load state, dictionary counts, and expected/active IPC socket values when plugin auto-start skips due to a socket mismatch.
- Add `logging.files` toggles for app, launcher, and mpv logs, with mpv logs disabled by default unless explicitly enabled for debugging.
+5
View File
@@ -0,0 +1,5 @@
type: changed
area: anki
- `ankiConnect.nPlusOne.enabled` is no longer implicitly set to `true` when `ankiConnect.knownWords.highlightEnabled` is `true`. Users who rely on known-word highlighting and want N+1 target highlighting must now set `ankiConnect.nPlusOne.enabled: true` explicitly.
- Updated known-word cache docs and examples to recommend expression/word fields and removed legacy-option references from user-facing config docs.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: logs
- Add sanitized log ZIP exports from the tray menu and `subminer logs -e`, with home-directory usernames redacted from exported log contents.
@@ -0,0 +1,4 @@
type: fixed
area: logging
- Stop repeated MPV IPC socket warning spam while the app waits in the background for mpv to recreate the IPC socket.
@@ -0,0 +1,4 @@
type: fixed
area: character-dictionary
- Use `subtitleStyle.nameMatchEnabled` as the only switch for character-dictionary sync/builds and hide the legacy `anilist.characterDictionary.enabled` option.
+13 -10
View File
@@ -46,10 +46,16 @@
// Logging // Logging
// Controls logging verbosity. // Controls logging verbosity.
// Set to debug for full runtime diagnostics. // Set to debug for full runtime diagnostics.
// Hot-reload: logging.level applies live while SubMiner is running. // Hot-reload: logging.level and logging.files apply live while SubMiner is running.
// ========================================== // ==========================================
"logging": { "logging": {
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error "level": "warn", // Minimum log level for runtime logging. Values: debug | info | warn | error
"rotation": 7, // Number of days of app, launcher, and mpv logs to retain.
"files": {
"app": true, // Write SubMiner app runtime logs. Values: true | false
"launcher": true, // Write launcher command logs. Values: true | false
"mpv": false // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false
} // Files setting.
}, // Controls logging verbosity. }, // Controls logging verbosity.
// ========================================== // ==========================================
@@ -83,7 +89,7 @@
"rightStickPress": 10, // Raw button index used for controller R3 input. "rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input. "leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 input. "rightTrigger": 7 // Raw button index used for controller R2 input.
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors. }, // Semantic button-name reference mapping used for debug output. Updating it does not rewrite existing raw binding descriptors.
"bindings": { "bindings": {
"toggleLookup": { "toggleLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
@@ -383,7 +389,7 @@
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false "autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false "autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false "nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false "nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary. "nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight. "nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
@@ -525,7 +531,7 @@
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. "decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
}, // Known words setting. }, // Known words setting.
"behavior": { "behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
@@ -588,11 +594,8 @@
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false "enabled": false, // Enable AniList post-watch progress updates. Values: true | false
"accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup. "accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
"characterDictionary": { "characterDictionary": {
"enabled": false, // Enable automatic Yomitan character dictionary sync for currently watched AniList media. Values: true | false
"refreshTtlHours": 168, // Legacy setting; merged character dictionary retention is now usage-based and this value is ignored.
"maxLoaded": 3, // Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary. "maxLoaded": 3, // Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary.
"evictionPolicy": "delete", // Legacy setting; merged character dictionary eviction is usage-based and this value is ignored. Values: disable | delete "profileScope": "all", // Yomitan profile scope for character dictionary settings updates. Values: all | active
"profileScope": "all", // Yomitan profile scope for dictionary enable/disable updates. Values: all | active
"collapsibleSections": { "collapsibleSections": {
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false "description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
"characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false "characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false
@@ -625,7 +628,7 @@
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH. "executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen "launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile. "profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin. "socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows "backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false "autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false "pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
+25 -12
View File
@@ -19,18 +19,26 @@ type VersionManifest = {
versions: Array<{ version: string; path: string }>; versions: Array<{ version: string; path: string }>;
}; };
const base = normalizeBase(process.env.SUBMINER_DOCS_BASE ?? '/'); function optionalEnv(value: string | undefined): string | undefined {
const outDir = process.env.SUBMINER_DOCS_OUT_DIR; return value && value !== 'undefined' ? value : undefined;
const docsSourceDir = process.env.SUBMINER_DOCS_SOURCE_DIR ?? process.cwd(); }
const localArchiveDir = resolve(
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR ?? const base = normalizeBase(optionalEnv(process.env.SUBMINER_DOCS_BASE) ?? '/');
const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.14.0';
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
const versionLinkOrigin =
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
function getLocalArchiveDir(): string {
return resolve(
optionalEnv(process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR) ??
join(docsSourceDir, '..', '.tmp/docs-versioned-site'), join(docsSourceDir, '..', '.tmp/docs-versioned-site'),
); );
const channel = normalizeChannel(process.env.SUBMINER_DOCS_CHANNEL); }
const docsVersion = process.env.SUBMINER_DOCS_VERSION;
const latestStable = process.env.SUBMINER_DOCS_LATEST_STABLE ?? 'v0.14.0';
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
const versionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN ?? 'production';
function normalizeBase(value: string): string { function normalizeBase(value: string): string {
if (!value || value === '/') return '/'; if (!value || value === '/') return '/';
@@ -43,7 +51,7 @@ function normalizeChannel(value: string | undefined): DocsChannel {
} }
function parseVersionManifest(value: string | undefined): VersionManifest { function parseVersionManifest(value: string | undefined): VersionManifest {
if (!value) { if (!value || value === 'undefined') {
return { return {
latestStable, latestStable,
channels: [ channels: [
@@ -218,6 +226,7 @@ function isFile(path: string): boolean {
function archiveFileForPathname(pathname: string): string | null { function archiveFileForPathname(pathname: string): string | null {
if (!shouldHandleLocalVersionRoute(pathname)) return null; if (!shouldHandleLocalVersionRoute(pathname)) return null;
const localArchiveDir = getLocalArchiveDir();
const routePath = decodeURIComponent(pathname).replace(/^\/+/, ''); const routePath = decodeURIComponent(pathname).replace(/^\/+/, '');
const filePath = resolve(localArchiveDir, routePath); const filePath = resolve(localArchiveDir, routePath);
if (filePath !== localArchiveDir && !filePath.startsWith(`${localArchiveDir}${sep}`)) { if (filePath !== localArchiveDir && !filePath.startsWith(`${localArchiveDir}${sep}`)) {
@@ -234,7 +243,11 @@ function archiveFileForPathname(pathname: string): string | null {
} }
function serveLocalArchiveRoute(pathname: string, response: DevServerResponse): boolean { function serveLocalArchiveRoute(pathname: string, response: DevServerResponse): boolean {
if (versionLinkOrigin !== 'local') return false; if (
(optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? versionLinkOrigin) !== 'local'
) {
return false;
}
const filePath = archiveFileForPathname(pathname); const filePath = archiveFileForPathname(pathname);
if (!filePath) return false; if (!filePath) return false;
+1 -2
View File
@@ -98,12 +98,11 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ | | ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | | `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) | | `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) | | `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one | | `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded | | `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format. See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format. Character dictionary sync follows `subtitleStyle.nameMatchEnabled`.
## CLI Commands ## CLI Commands
+1 -1
View File
@@ -37,7 +37,7 @@ In both modes, the enrichment workflow is the same:
5. Writes metadata to the miscInfo field. 5. Writes metadata to the miscInfo field.
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks. Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks.
Known-word sync scope is controlled by `ankiConnect.knownWords.decks` (object map), with `ankiConnect.deck` used as legacy fallback. Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
### Proxy Mode Setup (Yomitan / Texthooker) ### Proxy Mode Setup (Yomitan / Texthooker)
+37
View File
@@ -269,6 +269,43 @@ For domains migrated to reducer-style transitions (for example AniList token/que
- Reducer boundary: when a domain has transition helpers in `src/main/state.ts`, new callsites should route updates through those helpers instead of ad-hoc object mutation in `main.ts` or composers. - Reducer boundary: when a domain has transition helpers in `src/main/state.ts`, new callsites should route updates through those helpers instead of ad-hoc object mutation in `main.ts` or composers.
- Tests for migrated domains should assert both the intended field changes and non-targeted field invariants. - Tests for migrated domains should assert both the intended field changes and non-targeted field invariants.
## Playback Startup Flow
Before the app boots, something has to launch mpv, inject the plugin, and bring the overlay up. SubMiner-managed launches own this step — the `subminer` launcher, the app's own playback, and the packaged Windows shortcut all follow the same path. The launcher reads `config.jsonc`, spawns mpv with the IPC socket and the bundled plugin, and passes runtime settings as `--script-opts`. The plugin never reads a config file: the shipped `subminer.conf` is intentionally empty so command-line opts always win.
Once mpv is up, exactly one of two triggers brings up the overlay. On a first launch the plugin's `file-loaded` hook self-starts the app once the socket is ready (because the launcher injected `auto_start=yes`). When the app is already running — or for explicit `--start-overlay` and YouTube flows — the launcher instead attaches over the control socket and suppresses the plugin's auto-start, so the two never fire together. Both converge on the same app bring-up, which then runs the Program Lifecycle below.
```mermaid
flowchart TB
classDef entry fill:#c6a0f6,stroke:#494d64,color:#24273a,stroke-width:2px,font-weight:bold
classDef extrt fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef decision fill:#f5a97f,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef proc fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef app fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef overlay fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
Entry["Managed launch<br/>subminer CLI · app · Windows shortcut"]:::entry
Entry --> Cfg["Launcher reads config.jsonc<br/>→ plugin runtime config"]:::extrt
Cfg --> Spawn["Spawn mpv<br/>--input-ipc-server=/tmp/subminer-socket<br/>--script=plugin/subminer/main.lua<br/>--script-opts=subminer-… (auto_start, backend, …)"]:::proc
Spawn --> Boot["Plugin boot · read_options('subminer')<br/>empty subminer.conf; CLI opts win"]:::extrt
Boot --> Sock["mpv IPC socket ready"]:::proc
Sock --> Who{"Overlay trigger"}:::decision
Who -->|"app already running, or<br/>--start-overlay / YouTube"| Attach["Launcher startOverlay()<br/>attach via control socket<br/>plugin auto-start suppressed"]:::proc
Who -->|"first launch, auto_start=yes"| Self["Plugin file-loaded hook<br/>polls socket → process.start_overlay()"]:::extrt
Attach --> AppUp
Self --> AppUp
AppUp["Spawn / attach SubMiner app<br/>--start --managed-playback --socket … --backend …"]:::app
AppUp --> Ctrl["App control server up<br/>/tmp/subminer-control-* dedupes a 2nd launch"]:::app
Ctrl --> Life["app.whenReady → Program Lifecycle (below)"]:::app
Life --> Conn["MpvIpcClient connects to /tmp/subminer-socket"]:::overlay
Conn --> Show["Transparent overlay over mpv<br/>Yomitan lookup · mine"]:::overlay
```
The runtime sockets in this flow are detailed in [IPC + Runtime Contracts](./ipc-contracts#runtime-sockets).
## Program Lifecycle ## Program Lifecycle
- **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend. - **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
+9 -10
View File
@@ -21,7 +21,7 @@ The feature has three stages: **snapshot**, **merge**, and **match**.
Character dictionary sync is disabled by default. To turn it on: Character dictionary sync is disabled by default. To turn it on:
1. Authenticate with AniList (see [AniList Integration](/anilist-integration#setup)). 1. Authenticate with AniList (see [AniList Integration](/anilist-integration#setup)).
2. Set `anilist.characterDictionary.enabled` to `true` in your config. 2. Set `subtitleStyle.nameMatchEnabled` to `true` in your config or enable **Name Match Enabled** in Settings.
3. Start watching — SubMiner will generate a snapshot for the current media and import the merged dictionary into Yomitan automatically. 3. Start watching — SubMiner will generate a snapshot for the current media and import the merged dictionary into Yomitan automatically.
```jsonc ```jsonc
@@ -29,9 +29,9 @@ Character dictionary sync is disabled by default. To turn it on:
"anilist": { "anilist": {
"enabled": true, "enabled": true,
"accessToken": "your-token", "accessToken": "your-token",
"characterDictionary": {
"enabled": true,
}, },
"subtitleStyle": {
"nameMatchEnabled": true,
}, },
} }
``` ```
@@ -102,7 +102,7 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting,
| Option | Default | Description | | Option | Default | Description |
| -------------------------------------- | --------- | ----------------------------------------- | | -------------------------------------- | --------- | ----------------------------------------- |
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting | | `subtitleStyle.nameMatchEnabled` | `false` | Enable dictionary sync and highlighting |
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names | | `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names | | `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
@@ -136,7 +136,7 @@ The three collapsible sections can be configured to start open or closed:
## Auto-Sync Lifecycle ## Auto-Sync Lifecycle
When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes. When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes.
**Phases:** **Phases:**
@@ -179,7 +179,7 @@ SubMiner uses `guessit` to infer the anime title from the active filename before
Use the in-app selector or CLI to pin the correct AniList media for the whole series: Use the in-app selector or CLI to pin the correct AniList media for the whole series:
- In-app: open the manager with `Ctrl/Cmd+D`, use the **Override** tab/button, edit the prefilled title if needed, then search and choose the correct result. The CLI flag `--open-character-dictionary` still opens the selector directly. - In-app: open the manager with `Ctrl/Cmd+D`, use the **Override** tab/button, edit the prefilled title if needed, then search and choose the correct result.
- CLI: `--dictionary-candidates` still lists matches for the current filename guess. - CLI: `--dictionary-candidates` still lists matches for the current filename guess.
```bash ```bash
@@ -194,7 +194,7 @@ SubMiner.AppImage --dictionary-candidates --dictionary-target "/path/to/episode.
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary-target "/path/to/episode.mkv" SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary-target "/path/to/episode.mkv"
# Open the in-app selector from the running app # Open the in-app selector from the running app
subminer app --open-character-dictionary subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
``` ```
Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the episode's parent directory plus the filename guess. Later episodes in the same directory use the selected AniList ID automatically, while separate season directories can keep separate overrides and character dictionaries. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary. Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the episode's parent directory plus the filename guess. Later episodes in the same directory use the selected AniList ID automatically, while separate season directories can keep separate overrides and character dictionaries. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
@@ -243,13 +243,12 @@ merged.zip
| Option | Default | Description | | Option | Default | Description |
| ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------- | | ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------- |
| `anilist.characterDictionary.enabled` | `false` | Enable auto-sync of character dictionary from AniList |
| `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary | | `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary |
| `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only | | `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only |
| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded | | `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded | | `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded | | `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting in subtitles | | `subtitleStyle.nameMatchEnabled` | `false` | Enable character-dictionary sync and name highlighting |
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside matched names | | `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside matched names |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches | | `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
@@ -272,7 +271,7 @@ If you work with visual novels or want a standalone dictionary generator indepen
## Troubleshooting ## Troubleshooting
- **Names not highlighting:** Confirm `anilist.characterDictionary.enabled` is `true` and `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry — SubMiner needs a media ID to fetch characters. - **Names not highlighting:** Confirm `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry — SubMiner needs a media ID to fetch characters.
- **Inline portraits missing:** Confirm `subtitleStyle.nameMatchImagesEnabled` is `true`. On the next character dictionary sync, SubMiner refreshes current-version snapshots that do not contain usable cached character portrait data. Portraits still require AniList to return an image and the image download to succeed. - **Inline portraits missing:** Confirm `subtitleStyle.nameMatchImagesEnabled` is `true`. On the next character dictionary sync, SubMiner refreshes current-version snapshots that do not contain usable cached character portrait data. Portraits still require AniList to return an image and the image download to succeed.
- **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase. - **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase.
- **Wrong characters showing:** Open the in-app character dictionary manager (`Ctrl/Cmd+D`) to remove/reorder loaded titles, then use **Override** to correct the active AniList match. You can also run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id <id>`. SubMiner ignores character entries from other loaded titles for subtitle name matching and inline portraits once the current media ID is known. - **Wrong characters showing:** Open the in-app character dictionary manager (`Ctrl/Cmd+D`) to remove/reorder loaded titles, then use **Override** to correct the active AniList match. You can also run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id <id>`. SubMiner ignores character entries from other loaded titles for subtitle name matching and inline portraits once the current media ID is known.
+132 -133
View File
@@ -21,7 +21,7 @@ For most users, start with this minimal configuration:
"deck": "YourDeckName", "deck": "YourDeckName",
"knownWords": { "knownWords": {
"decks": { "decks": {
"YourDeckName": ["Word", "Word Reading", "Expression"] "YourDeckName": ["Word"]
} }
}, },
"fields": { "fields": {
@@ -33,7 +33,7 @@ For most users, start with this minimal configuration:
} }
``` ```
`ankiConnect.deck` is still accepted for backward-compatible polling scope and legacy known-word fallback behavior. For known-word cache scope, prefer `ankiConnect.knownWords.decks` with deck-to-fields mapping. Use the known-word deck map to choose which Anki decks and note fields feed the known-word cache.
Then customize as needed using the sections below. Then customize as needed using the sections below.
@@ -54,7 +54,7 @@ The Settings window groups options by workflow instead of mirroring the raw conf
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names, and keybinding fields use click-to-learn controls instead of raw text boxes. Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names, and keybinding fields use click-to-learn controls instead of raw text boxes.
The Settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies. The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies.
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available. Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
@@ -94,35 +94,10 @@ On macOS, these validation warnings also open a native dialog with full details
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically. SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
Hot-reloadable fields: Hot-reloadable settings include subtitle appearance, sidebar controls, keybindings,
logging level, selected source-language preferences, Jimaku/Subsync settings, and
- `subtitleStyle` the Anki known-word, N+1, field, sentence-card, and Kiku options listed in the
- `subtitleSidebar` reference tables below.
- `keybindings`
- `shortcuts`
- `secondarySub.defaultMode`
- `stats.toggleKey`
- `stats.markWatchedKey`
- `logging.level`
- `youtube.primarySubLanguages`
- `jimaku.*`
- `subsync.*`
- `ankiConnect.ai.enabled`
- `ankiConnect.behavior.autoUpdateNewCards`
- `ankiConnect.knownWords.highlightEnabled`
- `ankiConnect.knownWords.refreshMinutes`
- `ankiConnect.knownWords.addMinedWordsImmediately`
- `ankiConnect.knownWords.matchMode`
- `ankiConnect.knownWords.decks`
- `ankiConnect.nPlusOne.enabled`
- `ankiConnect.nPlusOne.minSentenceWords`
- `ankiConnect.fields.word`
- `ankiConnect.fields.audio`
- `ankiConnect.fields.image`
- `ankiConnect.fields.sentence`
- `ankiConnect.fields.miscInfo`
- `ankiConnect.isLapis.sentenceCardModel`
- `ankiConnect.isKiku.fieldGrouping`
When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active. When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
@@ -175,7 +150,7 @@ The configuration file includes several main sections:
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` - [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**AniList**](#anilist) - Optional post-watch progress updates - [**AniList**](#anilist) - Optional post-watch progress updates
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath` - [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch - [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates - [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite - [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
@@ -193,14 +168,24 @@ Control the minimum log level for runtime output:
```json ```json
{ {
"logging": { "logging": {
"level": "info" "level": "warn",
"rotation": 7,
"files": {
"app": true,
"launcher": true,
"mpv": false
}
} }
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------- | ---------------------------------------- | --------------------------------------------------------- | | ---------------- | ---------------------------------------- | -------------------------------------------------------------------- |
| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) | | `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"warn"`) |
| `rotation` | positive integer | Number of days of app, launcher, and mpv logs to retain (default: 7) |
| `files.app` | boolean | Write SubMiner app runtime logs (default: `true`) |
| `files.launcher` | boolean | Write launcher command logs (default: `true`) |
| `files.mpv` | boolean | Write mpv player logs. Enable temporarily for mpv/plugin debugging. |
### Updates ### Updates
@@ -219,7 +204,7 @@ Configure automatic update checks and update notifications:
| Option | Values | Description | | Option | Values | Description |
| -------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------- | | -------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. | | `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. | | `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
| `notificationType` | `"system"` \| `"osd"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. | | `notificationType` | `"system"` \| `"osd"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. |
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. | | `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
@@ -289,8 +274,8 @@ See `config.example.jsonc` for detailed configuration options.
| Option | Values | Description | | Option | Values | Description |
| --------- | ------------------------- | --------------------------------------------------- | | --------- | ------------------------- | --------------------------------------------------- |
| `enabled` | `true`, `false`, `"auto"` | Built-in subtitle websocket mode (default: `false`) | | `websocket.enabled` | `true`, `false`, `"auto"` | Built-in subtitle websocket mode (default: `false`) |
| `port` | number | WebSocket server port (default: 6677) | | `websocket.port` | number | WebSocket server port (default: 6677) |
### Annotation WebSocket ### Annotation WebSocket
@@ -309,8 +294,8 @@ This stream includes subtitle text plus token metadata (N+1, known-word, frequen
| Option | Values | Description | | Option | Values | Description |
| --------- | --------------- | -------------------------------------------------------------- | | --------- | --------------- | -------------------------------------------------------------- |
| `enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) | | `annotationWebsocket.enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) |
| `port` | number | Annotation websocket port (default: 6678) | | `annotationWebsocket.port` | number | Annotation websocket port (default: 6678) |
### Texthooker ### Texthooker
@@ -343,10 +328,10 @@ See `config.example.jsonc` for detailed configuration options.
```json ```json
{ {
"subtitleStyle": { "subtitleStyle": {
"fontColor": "#cad3f5",
"backgroundColor": "transparent",
"css": { "css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", "font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
"color": "#cad3f5",
"background-color": "transparent",
"font-size": "35px", "font-size": "35px",
"font-weight": "600", "font-weight": "600",
"line-height": "1.35", "line-height": "1.35",
@@ -356,13 +341,15 @@ See `config.example.jsonc` for detailed configuration options.
"text-rendering": "geometricPrecision", "text-rendering": "geometricPrecision",
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", "text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
"font-style": "normal", "font-style": "normal",
"backdrop-filter": "blur(6px)" "backdrop-filter": "blur(6px)",
"--subtitle-hover-token-color": "#f4dbd6",
"--subtitle-hover-token-background-color": "transparent"
}, },
"secondary": { "secondary": {
"fontColor": "#cad3f5",
"backgroundColor": "transparent",
"css": { "css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", "font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
"color": "#cad3f5",
"background-color": "transparent",
"font-size": "24px", "font-size": "24px",
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)" "text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"
} }
@@ -372,21 +359,15 @@ See `config.example.jsonc` for detailed configuration options.
``` ```
| Option | Values | Description | | Option | Values | Description |
| ---------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | | ---------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) | | `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
| `fontSize` | number (px) | Font size in pixels (default: `35`) | | `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) | | `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
| `css` | object | CSS declarations applied to subtitles after normal style defaults; the settings window writes textbox edits here |
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) | | `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. | | `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). | | `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). | | `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) | | `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: `"transparent"`); `hoverBackground` is accepted as an alias |
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`false` by default) |
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) | | `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) | | `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) | | `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
@@ -399,33 +380,38 @@ See `config.example.jsonc` for detailed configuration options.
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode | | `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode | | `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) | | `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
| `secondary` | object | Override any of the above for secondary subtitles (optional), including `secondary.css` declarations |
Subtitle CSS custom properties:
| CSS Property | Default | Description |
| --------------------------------------------- | ------------- | ---------------------------------------- |
| `--subtitle-hover-token-color` | `#f4dbd6` | Hovered subtitle token text color |
| `--subtitle-hover-token-background-color` | `transparent` | Hovered subtitle token background color |
The Settings window keeps subtitle color controls separate, then saves CSS textboxes to The Settings window keeps subtitle color controls separate, then saves CSS textboxes to
`subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`. The generated example the primary subtitle, secondary subtitle, and sidebar CSS objects. The generated example
uses that same CSS declaration shape; existing top-level style keys such as `fontSize` and uses that same CSS declaration shape.
`textShadow` remain supported for hand-written or older configs.
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`. Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
Lookup behavior: Lookup behavior:
- Set `frequencyDictionary.sourcePath` to a directory containing `term_meta_bank_*.json` for a fully custom source. - Point the source path at a directory containing `term_meta_bank_*.json` for a fully custom source.
- If `sourcePath` is missing or empty, SubMiner searches default install/runtime locations for `frequency-dictionary` directories (for example app resources, user data paths, and current working directory). - If `sourcePath` is missing or empty, SubMiner searches default install/runtime locations for `frequency-dictionary` directories (for example app resources, user data paths, and current working directory).
- In both cases, only terms with a valid `frequencyRank` are used; everything else falls back to no highlighting. - In both cases, only terms with a valid `frequencyRank` are used; everything else falls back to no highlighting.
- `frequencyDictionary.matchMode` controls which token text is used for frequency lookups: `headword` (dictionary form) or `surface` (visible subtitle text). - Match mode controls which token text is used for frequency lookups: `headword` (dictionary form) or `surface` (visible subtitle text).
- Frequency highlighting skips tokens that look like non-lexical SFX/interjection noise (for example kana reduplication or short kana endings like `っ`), even when dictionary ranks exist. - Frequency highlighting skips tokens that look like non-lexical SFX/interjection noise (for example kana reduplication or short kana endings like `っ`), even when dictionary ranks exist.
In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window. In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window.
Character-name highlighting is separate from N+1 and frequency highlighting: Character-name highlighting is separate from N+1 and frequency highlighting:
- `nameMatchEnabled` controls whether SubMiner includes character-dictionary name matches in subtitle token metadata and renderer styling. - `nameMatchEnabled` controls whether SubMiner syncs the character dictionary and includes character-dictionary name matches in subtitle token metadata and renderer styling.
- `nameMatchImagesEnabled` adds small circular portraits beside matched names using the AniList images already cached with character dictionary snapshots. - `nameMatchImagesEnabled` adds small circular portraits beside matched names using the AniList images already cached with character dictionary snapshots.
- `nameMatchColor` sets the highlight color for those matched character names. - `nameMatchColor` sets the highlight color for those matched character names.
- Matches come from the bundled SubMiner character dictionary, including AniList-synced merged dictionaries when enabled. - Matches come from the bundled SubMiner character dictionary, including AniList-synced merged dictionaries when name matching is enabled.
Secondary subtitle defaults: `fontFamily: "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`, `fontSize: 24`, `fontColor: "#cad3f5"`, `textShadow: "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"`, `backgroundColor: "transparent"`, `fontWeight: "600"`. Any property not set in `secondary` falls back to the CSS defaults. Secondary subtitle styling lives in the secondary subtitle CSS object. Any CSS property not set there falls back to the secondary subtitle defaults, then the normal renderer defaults.
**See `config.example.jsonc`** for the complete list of subtitle style configuration options. **See `config.example.jsonc`** for the complete list of subtitle style configuration options.
@@ -442,30 +428,36 @@ Configure the parsed-subtitle sidebar modal.
"toggleKey": "Backslash", "toggleKey": "Backslash",
"pauseVideoOnHover": true, "pauseVideoOnHover": true,
"autoScroll": true, "autoScroll": true,
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", "css": {
"fontSize": 16 "font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
"font-size": "16px",
"color": "#cad3f5",
"background-color": "rgba(73, 77, 100, 0.9)",
"--subtitle-sidebar-max-width": "420px"
}
} }
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------------------------- | --------- | ------------------------------------------------------------------------------------------------------- | | ------------------- | ------- | ------------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | Enable subtitle sidebar support (`true` by default) | | `subtitleSidebar.enabled` | boolean | Enable subtitle sidebar support (`true` by default) |
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) | | `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout | | `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) | | `subtitleSidebar.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 (`true` by default) |
| `autoScroll` | boolean | Keep the active cue in view while playback advances | | `autoScroll` | boolean | Keep the active cue in view while playback advances |
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) | | `subtitleSidebar.css` | object | CSS declaration object applied to the sidebar. Use CSS properties plus sidebar custom properties below. |
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
| `backgroundColor` | string | Sidebar shell background color | Sidebar CSS custom properties:
| `textColor` | hex color | Default cue text color |
| `fontFamily` | string | CSS `font-family` value applied to sidebar cue text | | CSS Property | Default | Description |
| `fontSize` | number | Base sidebar cue font size in CSS pixels (default: `16`) | | ------------------------------------------------- | ------------------------------- | ------------------------------------- |
| `timestampColor` | hex color | Cue timestamp color | | `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width |
| `activeLineColor` | hex color | Active cue text color | | `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color |
| `activeLineBackgroundColor` | string | Active cue background color | | `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color |
| `hoverLineBackgroundColor` | string | Hovered cue background color | | `--subtitle-sidebar-active-background-color` | `rgba(138, 173, 244, 0.22)` | Active cue background color |
| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog. The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog.
@@ -529,7 +521,7 @@ See `config.example.jsonc` for detailed configuration options.
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track | | `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) | | `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
`secondarySub.secondarySubLanguages` also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback. The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
**Display modes:** **Display modes:**
@@ -631,12 +623,12 @@ See `config.example.jsonc` for detailed configuration options.
``` ```
| Option | Values | Description | | Option | Values | Description |
| ----------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) | | `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) | | `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | | `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) | | `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when `behavior.autoUpdateNewCards` is `false`) | | `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) | | `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) | | `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) | | `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
@@ -674,7 +666,7 @@ Important behavior:
- Learned bindings are saved under `controller.profiles` for the selected controller id. Global `controller.bindings` remains the fallback for controllers without a profile. - Learned bindings are saved under `controller.profiles` for the selected controller id. Global `controller.bindings` remains the fallback for controllers without a profile.
- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`. - `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block. - The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`. - The button-index map is a semantic reference mapping. Changing it does not rewrite the raw numeric descriptor values already stored under controller bindings.
- Turning keyboard-only mode off clears the keyboard-only token highlight state. - Turning keyboard-only mode off clears the keyboard-only token highlight state.
- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active. - Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active.
@@ -763,11 +755,11 @@ If you bind a discrete action to an axis manually, include `direction`:
} }
``` ```
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings` or `controller.profiles.*.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct. Treat the button-index map as reference-only unless you are copying values from the debug modal. Updating it alone does not rewrite the hardcoded raw numeric values already present in controller bindings or controller profiles. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default. If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
If one controller reports non-standard raw button numbers, override `controller.profiles["<controller id>"].buttonIndices` using values from the `Alt+Shift+C` debug modal. Use global `controller.buttonIndices` only when the mapping should apply to every controller without a profile. If one controller reports non-standard raw button numbers, override that controller profile's button-index map using values from the `Alt+Shift+C` debug modal. Use the global button-index map only when the mapping should apply to every controller without a profile.
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging. If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
@@ -775,14 +767,14 @@ Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing,
### Manual Card Update Shortcuts ### Manual Card Update Shortcuts
When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control: When automatic card updates are disabled, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
| Shortcut | Action | | Shortcut | Action |
| -------------- | ------------------------------------------------------------------------------------------------------------------ | | -------------- | ------------------------------------------------------------------------------------------------------------------ |
| `Ctrl+C` | Copy the current subtitle line to clipboard (preserves line breaks) | | `Ctrl+C` | Copy the current subtitle line to clipboard (preserves line breaks) |
| `Ctrl+Shift+C` | Enter multi-copy mode. Press `1-9` to copy that many recent lines, or `Esc` to cancel. Timeout: 3 seconds | | `Ctrl+Shift+C` | Enter multi-copy mode. Press `1-9` to copy that many recent lines, or `Esc` to cancel. Timeout: 3 seconds |
| `Ctrl+V` | Update the last added Anki card using subtitles from clipboard | | `Ctrl+V` | Update the last added Anki card using subtitles from clipboard |
| `Ctrl+G` | Trigger Kiku duplicate field grouping for the last added card (only when `behavior.autoUpdateNewCards` is `false`) | | `Ctrl+G` | Trigger Kiku duplicate field grouping for the last added card (only when automatic card updates are disabled) |
| `Ctrl+S` | Create a sentence card from the current subtitle line | | `Ctrl+S` | Create a sentence card from the current subtitle line |
| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel | | `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel |
| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) | | `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) |
@@ -828,16 +820,11 @@ When config hot-reload updates shortcut/keybinding/style values, close and reope
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart. Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
Current runtime options: Current runtime options cover automatic card updates, known-word highlighting,
JLPT underlines, frequency highlighting, known-word match mode, and Kiku field
grouping mode.
- `ankiConnect.behavior.autoUpdateNewCards` (`On` / `Off`) Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
- `subtitleStyle.enableJlpt` (`On` / `Off`)
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
- `ankiConnect.knownWords.matchMode` (`headword` / `surface`)
- `ankiConnect.isKiku.fieldGrouping` (`auto` / `manual` / `disabled`)
Annotation toggles (`nPlusOne`, `enableJlpt`, `frequencyDictionary.enabled`) only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
Default shortcut: `Ctrl+Shift+O` Default shortcut: `Ctrl+Shift+O`
@@ -869,7 +856,7 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf
| Option | Values | Description | | Option | Values | Description |
| ------------------ | -------------------- | ------------------------------------------------------------------------------------ | | ------------------ | -------------------- | ------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) | | `ai.enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) |
| `apiKey` | string | Static API key for the shared provider | | `apiKey` | string | Static API key for the shared provider |
| `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) | | `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) |
| `model` | string | Default model identifier requested from the provider (default: `openai/gpt-4o-mini`) | | `model` | string | Default model identifier requested from the provider (default: `openai/gpt-4o-mini`) |
@@ -879,7 +866,7 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf
SubMiner uses the shared provider for: SubMiner uses the shared provider for:
- Anki translation/enrichment when `ankiConnect.ai.enabled` is `true` - Anki translation/enrichment when Anki AI is enabled
### AnkiConnect ### AnkiConnect
@@ -955,7 +942,7 @@ This example is intentionally compact. The option table below documents availabl
| Option | Values | Description | | Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | | `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | | `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | | `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) | | `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
@@ -963,8 +950,7 @@ This example is intentionally compact. The option table below documents availabl
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) | | `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | | `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | | `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. | | `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | | `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | | `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) | | `fields.image` | string | Card field for images (default: `Picture`) |
@@ -989,15 +975,15 @@ This example is intentionally compact. The option table below documents availabl
| `media.audioPadding` | number (seconds) | Optional padding around audio clip timing (default: `0`). Animated AVIF clips freeze the first frame during leading audio padding. | | `media.audioPadding` | number (seconds) | Optional padding around audio clip timing (default: `0`). Animated AVIF clips freeze the first frame during leading audio padding. |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | | `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | | `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode`; manual clipboard updates always replace generated sentence audio (default: `true`) | | `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) | | `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | | `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | | `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) | | `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) | | `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. | | `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) | | `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). | | `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). | | `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). | | `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | | `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
@@ -1034,22 +1020,21 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
### N+1 Word Highlighting ### N+1 Word Highlighting
When `ankiConnect.knownWords.highlightEnabled` is enabled, SubMiner builds a local cache of known words from Anki to highlight already learned tokens in subtitle rendering. When known-word highlighting is enabled, SubMiner builds a local cache of known words from Anki to highlight already learned tokens in subtitle rendering.
Known-word cache policy: Known-word cache policy:
- Initial sync runs when the integration starts if the cache is missing or stale. - Initial sync runs when the integration starts if the cache is missing or stale.
- `ankiConnect.knownWords.refreshMinutes` controls the minimum time between refreshes; between refreshes, cached words are reused without querying Anki. - The refresh interval controls the minimum time between syncs; between refreshes, cached words are reused without querying Anki.
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists. - `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
- `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`). - The N+1 minimum sentence-word setting controls the token count required before N+1 highlighting can trigger.
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki. - `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
- `ankiConnect.knownWords.decks` accepts an object keyed by deck name. If omitted or empty, it falls back to the legacy `ankiConnect.deck` single-deck scope. - The known-word deck map accepts an object keyed by deck name.
- Prefer expression/word fields such as `Expression` or `Word`. Avoid reading-only fields unless you intentionally want homophone readings to count as known words.
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory. - Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes). - The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
- Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.knownWords.matchMode` to `"surface"` for raw subtitle text matching. - Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set known-word matching to `"surface"` for raw subtitle text matching.
- A known-word cache match always receives known-word highlighting, even when part-of-speech filters suppress N+1, frequency, or JLPT annotations for that token. - A known-word cache match always receives known-word highlighting, even when part-of-speech filters suppress N+1, frequency, or JLPT annotations for that token.
- Legacy moved keys under `ankiConnect.nPlusOne` (`highlightEnabled`, `refreshMinutes`, `matchMode`, `decks`, `knownWord`) and older `ankiConnect.behavior.nPlusOne*` keys are deprecated and only kept for backward compatibility.
- Legacy top-level `ankiConnect` migration keys (for example `audioField`, `generateAudio`, `imageType`) are compatibility-only, validated before mapping, and ignored with a warning when invalid.
- If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown. - If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown.
- Known-word sync activity is logged at `INFO`/`DEBUG` level with the `anki` logger scope and includes scope, notes returned, and word counts. - Known-word sync activity is logged at `INFO`/`DEBUG` level with the `anki` logger scope and includes scope, notes returned, and word counts.
@@ -1148,9 +1133,7 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
"accessToken": "", "accessToken": "",
"characterDictionary": { "characterDictionary": {
"enabled": false, "enabled": false,
"refreshTtlHours": 168,
"maxLoaded": 3, "maxLoaded": 3,
"evictionPolicy": "delete",
"profileScope": "all", "profileScope": "all",
"collapsibleSections": { "collapsibleSections": {
"description": false, "description": false,
@@ -1164,12 +1147,9 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
| Option | Values | Description | | Option | Values | Description |
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- | | -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | | `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) | | `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
| `characterDictionary.enabled` | `true`, `false` | Enable automatic import/update of the merged SubMiner character dictionary for recent AniList media |
| `characterDictionary.refreshTtlHours` | number | Legacy compatibility setting. Parsed and preserved, but merged dictionary retention is now usage-based |
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) | | `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | Legacy compatibility setting. Parsed and preserved, but merged dictionary eviction is now usage-based |
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries | | `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries | | `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries | | `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
@@ -1196,7 +1176,7 @@ Current post-watch behavior:
Setup flow details: Setup flow details:
1. Set `anilist.enabled` to `true`. 1. Set `anilist.enabled` to `true`.
2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup. 2. Leave the AniList access-token field empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
3. Approve access in AniList. 3. Approve access in AniList.
4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically. 4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically.
- Encryption backend: Linux defaults to `gnome-libsecret`. - Encryption backend: Linux defaults to `gnome-libsecret`.
@@ -1204,7 +1184,7 @@ Setup flow details:
Token + detection notes: Token + detection notes:
- `anilist.accessToken` can be set directly in config; when blank, SubMiner uses the locally stored encrypted token from setup. - The AniList access token can be set directly in config; when blank, SubMiner uses the locally stored encrypted token from setup.
- Detection quality is best when `guessit` is installed and available on `PATH`. - Detection quality is best when `guessit` is installed and available on `PATH`.
- When `guessit` cannot parse or is missing, SubMiner falls back automatically to internal filename parsing. - When `guessit` cannot parse or is missing, SubMiner falls back automatically to internal filename parsing.
@@ -1240,7 +1220,7 @@ External-profile mode behavior:
- SubMiner does not open its own Yomitan settings window in this mode. - SubMiner does not open its own Yomitan settings window in this mode.
- SubMiner does not import, delete, or update dictionaries/settings in the external profile. - SubMiner does not import, delete, or update dictionaries/settings in the external profile.
- SubMiner character-dictionary features are fully disabled in this mode, including auto-sync, manual generation, and subtitle-side character-dictionary annotations. - SubMiner character-dictionary features are fully disabled in this mode, including auto-sync, manual generation, and subtitle-side character-dictionary annotations.
- First-run setup does not require any internal dictionaries while this mode is configured. If you later launch without `yomitan.externalProfilePath`, setup will require at least one internal Yomitan dictionary unless SubMiner already finds one. - First-run setup does not require any internal dictionaries while this mode is configured. If you later launch without an external Yomitan profile, setup will require at least one internal Yomitan dictionary unless SubMiner already finds one.
### Jellyfin ### Jellyfin
@@ -1266,13 +1246,13 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
| Option | Values | Description | | Option | Values | Description |
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ | | -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) | | `jellyfin.enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) |
| `serverUrl` | string (URL) | Jellyfin server base URL | | `serverUrl` | string (URL) | Jellyfin server base URL |
| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 | | `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 |
| `username` | string | Default username used by `--jellyfin-login` | | `username` | string | Default username used by `--jellyfin-login` |
| `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted | | `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted |
| `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support | | `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support |
| `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires `jellyfin.enabled` and `remoteControlEnabled`) | | `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires Jellyfin integration and remote control) |
| `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) | | `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) |
| `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers | | `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers |
| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons | | `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons |
@@ -1280,7 +1260,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
| `directPlayContainers` | string[] | Container allowlist for direct play decisions | | `directPlayContainers` | string[] | Container allowlist for direct play decisions |
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) | | `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken`, `jellyfin.userId`, `jellyfin.clientName`, `jellyfin.deviceId`, `jellyfin.clientVersion`, and `jellyfin.remoteControlDeviceName` config keys are not resolver-backed settings in the current runtime. SubMiner reports the Jellyfin client as `SubMiner`, derives the Jellyfin device id and visible device name from the OS hostname, and owns the client version internally. The Settings window also hides low-level default library fields (`defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior. Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. SubMiner reports the Jellyfin client as `SubMiner`, derives the Jellyfin device id and visible device name from the OS hostname, and owns the client version internally. The Settings window also hides low-level default library fields (`defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed. - On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
@@ -1295,7 +1275,7 @@ Launcher subcommands:
See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide. See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide.
Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`. Jellyfin remote auto-connect runs only when Jellyfin integration, remote control, and remote auto-connect are all enabled.
Jellyfin playback auto-launched through SubMiner loads the mpv plugin the same way regular playback does, and shows the visible subtitle overlay automatically so `subtitleStyle` applies to subtitles selected from Jellyfin. Jellyfin playback auto-launched through SubMiner loads the mpv plugin the same way regular playback does, and shows the visible subtitle overlay automatically so `subtitleStyle` applies to subtitles selected from Jellyfin.
@@ -1318,7 +1298,7 @@ Discord Rich Presence is enabled by default. SubMiner publishes a polished activ
| Option | Values | Description | | Option | Values | Description |
| ------------------ | ------------------------------------------------ | ---------------------------------------------------------- | | ------------------ | ------------------------------------------------ | ---------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) | | `discordPresence.enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) |
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) | | `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | | `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | | `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
@@ -1383,7 +1363,7 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
| Option | Values | Description | | Option | Values | Description |
| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | | ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. | | `immersionTracking.enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. |
| `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `<config dir>/immersion.sqlite`. | | `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `<config dir>/immersion.sqlite`. |
| `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. | | `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. |
| `flushIntervalMs` | integer (`50`-`60000`) | Maximum queue delay before flush. Default `500ms`. | | `flushIntervalMs` | integer (`50`-`60000`) | Maximum queue delay before flush. Default `500ms`. |
@@ -1398,6 +1378,9 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
| `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). | | `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). |
| `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). | | `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). |
| `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). | | `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). |
| `lifetimeSummaries.global` | `true`, `false` | Maintain global lifetime stats rows (default: `true`). |
| `lifetimeSummaries.anime` | `true`, `false` | Maintain per-anime lifetime stats rows (default: `true`). |
| `lifetimeSummaries.media` | `true`, `false` | Maintain per-media lifetime stats rows (default: `true`). |
You can also disable immersion tracking for a single session using: You can also disable immersion tracking for a single session using:
@@ -1427,6 +1410,7 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
{ {
"stats": { "stats": {
"toggleKey": "Backquote", "toggleKey": "Backquote",
"markWatchedKey": "KeyW",
"serverPort": 6969, "serverPort": 6969,
"autoStartServer": true, "autoStartServer": true,
"autoOpenBrowser": false "autoOpenBrowser": false
@@ -1436,7 +1420,8 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
| Option | Values | Description | | Option | Values | Description |
| ----------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------- | | ----------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------- |
| `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. | | `stats.toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
| `markWatchedKey` | Electron key code | Key code to mark the current video as watched and advance to the next playlist entry. Default `KeyW`. |
| `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. | | `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. |
| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. | | `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. |
| `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `false`. | | `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `false`. |
@@ -1456,17 +1441,31 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
{ {
"mpv": { "mpv": {
"executablePath": "", "executablePath": "",
"launchMode": "normal",
"profile": "", "profile": "",
"launchMode": "normal" "socketPath": "\\\\.\\pipe\\subminer-socket",
"backend": "auto",
"autoStartSubMiner": true,
"pauseUntilOverlayReady": true,
"subminerBinaryPath": "",
"aniskipEnabled": true,
"aniskipButtonKey": "TAB"
} }
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ---------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ----------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) | | `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) | | `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) | | `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) |
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
| `pauseUntilOverlayReady`| `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) |
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection and skip markers in the bundled mpv plugin (default: `true`) |
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) |
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list. If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
@@ -1498,14 +1497,14 @@ Current launcher behavior:
- If YouTube/mpv already exposes an authoritative matching subtitle track, SubMiner reuses it; otherwise it downloads and injects only the missing side. - If YouTube/mpv already exposes an authoritative matching subtitle track, SubMiner reuses it; otherwise it downloads and injects only the missing side.
- SubMiner loads the primary subtitle plus a best-effort secondary subtitle. - SubMiner loads the primary subtitle plus a best-effort secondary subtitle.
- Playback waits only for primary subtitle readiness; secondary failures do not block playback. - Playback waits only for primary subtitle readiness; secondary failures do not block playback.
- English secondary subtitles are selected from `secondarySub.secondarySubLanguages` when primary language matches are unavailable. - English secondary subtitles are selected from the secondary-subtitle language list when primary language matches are unavailable.
- Native mpv secondary subtitle rendering stays hidden during this flow so the SubMiner overlay remains the visible secondary subtitle surface. - Native mpv secondary subtitle rendering stays hidden during this flow so the SubMiner overlay remains the visible secondary subtitle surface.
- If primary subtitle loading fails, use `Ctrl+Alt+C` to open the subtitle modal and pick a track. - If primary subtitle loading fails, use `Ctrl+Alt+C` to open the subtitle modal and pick a track.
Language targets are derived from subtitle config: Language targets are derived from subtitle config:
- primary track: `youtube.primarySubLanguages` (falls back to `["ja","jpn"]`) - primary track: `youtube.primarySubLanguages` (falls back to `["ja","jpn"]`)
- secondary track: `secondarySub.secondarySubLanguages` (falls back to English when empty) - secondary track: secondary-subtitle language list (falls back to English when empty)
- Local playback uses the same priorities after mpv reports subtitle track metadata, so sidecar/internal mixed sets can override an incorrect initial `sid=auto` pick. - Local playback uses the same priorities after mpv reports subtitle track metadata, so sidecar/internal mixed sets can override an incorrect initial `sid=auto` pick.
- Tracks are resolved and loaded before mpv starts; the older launcher mode switch has been removed. - Tracks are resolved and loaded before mpv starts; the older launcher mode switch has been removed.
+31
View File
@@ -36,6 +36,37 @@ flowchart TB
style E fill:#ed8796,stroke:#494d64,color:#24273a,stroke-width:1.5px style E fill:#ed8796,stroke:#494d64,color:#24273a,stroke-width:1.5px
``` ```
## Runtime Sockets
The renderer↔main bridge above lives *inside* the Electron app. A separate set of OS sockets connects the app to the other runtimes — mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes.
- **mpv IPC socket** (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows): the `MpvIpcClient` in the main process connects here to send JSON commands and subscribe to playback/subtitle properties via `observe_property`. Created by mpv's `--input-ipc-server`.
- **App control socket** (`/tmp/subminer-control-<uid>-<hash>.sock`, or a named pipe on Windows): the launcher and the mpv plugin send CLI-style commands (`--start`, `--show-visible-overlay`, `--texthooker`) to a running app here. It also dedupes a second `subminer` invocation into the existing instance instead of launching twice.
```mermaid
flowchart LR
classDef extrt fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef app fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef ext fill:#a6da95,stroke:#494d64,color:#24273a,stroke-width:1.5px
subgraph MpvProc["mpv process"]
direction TB
Mpv["mpv core"]:::ext
Plugin["SubMiner plugin (Lua)"]:::extrt
end
Launcher["Launcher CLI"]:::extrt
App["SubMiner app (Electron main)"]:::app
App <-->|"mpv IPC socket · /tmp/subminer-socket<br/>JSON commands + property observe"| Mpv
Launcher -->|"app control socket · /tmp/subminer-control-*<br/>--start, --show-visible-overlay, …"| App
Plugin -->|"app control socket<br/>spawn / attach"| App
style MpvProc fill:#363a4f,stroke:#494d64,color:#cad3f5
```
How these sockets are established during launch is covered in [Playback Startup Flow](./architecture#playback-startup-flow).
## Core Surfaces ## Core Surfaces
| File | Role | | File | Role |
+2
View File
@@ -64,6 +64,7 @@ subminer video.mkv # play a specific file (default plugin c
subminer https://youtu.be/... # YouTube playback (requires yt-dlp) subminer https://youtu.be/... # YouTube playback (requires yt-dlp)
subminer --backend x11 video.mkv # Force x11 backend for a specific file subminer --backend x11 video.mkv # Force x11 backend for a specific file
subminer -u # check for SubMiner updates subminer -u # check for SubMiner updates
subminer logs -e # export sanitized log ZIP
subminer stats # open immersion dashboard subminer stats # open immersion dashboard
subminer stats -b # start background stats daemon subminer stats -b # start background stats daemon
``` ```
@@ -78,6 +79,7 @@ subminer stats -b # start background stats daemon
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows | | `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
| `subminer doctor` | Dependency + config + socket diagnostics | | `subminer doctor` | Dependency + config + socket diagnostics |
| `subminer settings` | Open the SubMiner settings window | | `subminer settings` | Open the SubMiner settings window |
| `subminer logs -e` | Export a sanitized log ZIP and print its path |
| `subminer config path` | Print active config file path | | `subminer config path` | Print active config file path |
| `subminer config show` | Print active config contents | | `subminer config show` | Print active config contents |
| `subminer mpv status` | Check mpv socket readiness | | `subminer mpv status` | Check mpv socket readiness |
+2
View File
@@ -163,6 +163,8 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
## Lifecycle ## Lifecycle
For how the plugin's auto-start fits into the full launch sequence — including when the launcher starts the overlay instead of the plugin — see [Playback Startup Flow](./architecture#playback-startup-flow).
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay. - **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback). - **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
- **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts). - **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts).
+13 -10
View File
@@ -46,10 +46,16 @@
// Logging // Logging
// Controls logging verbosity. // Controls logging verbosity.
// Set to debug for full runtime diagnostics. // Set to debug for full runtime diagnostics.
// Hot-reload: logging.level applies live while SubMiner is running. // Hot-reload: logging.level and logging.files apply live while SubMiner is running.
// ========================================== // ==========================================
"logging": { "logging": {
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error "level": "warn", // Minimum log level for runtime logging. Values: debug | info | warn | error
"rotation": 7, // Number of days of app, launcher, and mpv logs to retain.
"files": {
"app": true, // Write SubMiner app runtime logs. Values: true | false
"launcher": true, // Write launcher command logs. Values: true | false
"mpv": false // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false
} // Files setting.
}, // Controls logging verbosity. }, // Controls logging verbosity.
// ========================================== // ==========================================
@@ -83,7 +89,7 @@
"rightStickPress": 10, // Raw button index used for controller R3 input. "rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input. "leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 input. "rightTrigger": 7 // Raw button index used for controller R2 input.
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors. }, // Semantic button-name reference mapping used for debug output. Updating it does not rewrite existing raw binding descriptors.
"bindings": { "bindings": {
"toggleLookup": { "toggleLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
@@ -383,7 +389,7 @@
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false "autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false "autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false "nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false "nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary. "nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight. "nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
@@ -525,7 +531,7 @@
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. "decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
}, // Known words setting. }, // Known words setting.
"behavior": { "behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
@@ -588,11 +594,8 @@
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false "enabled": false, // Enable AniList post-watch progress updates. Values: true | false
"accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup. "accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
"characterDictionary": { "characterDictionary": {
"enabled": false, // Enable automatic Yomitan character dictionary sync for currently watched AniList media. Values: true | false
"refreshTtlHours": 168, // Legacy setting; merged character dictionary retention is now usage-based and this value is ignored.
"maxLoaded": 3, // Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary. "maxLoaded": 3, // Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary.
"evictionPolicy": "delete", // Legacy setting; merged character dictionary eviction is usage-based and this value is ignored. Values: disable | delete "profileScope": "all", // Yomitan profile scope for character dictionary settings updates. Values: all | active
"profileScope": "all", // Yomitan profile scope for dictionary enable/disable updates. Values: all | active
"collapsibleSections": { "collapsibleSections": {
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false "description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
"characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false "characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false
@@ -625,7 +628,7 @@
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH. "executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen "launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile. "profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin. "socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows "backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false "autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false "pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
+3 -1
View File
@@ -362,7 +362,9 @@ test('dev server serves local archive files for local version links', async () =
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local'; process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = archiveDir; process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = archiveDir;
try { try {
const { default: localDevConfig } = await import('./.vitepress/config?local-dev-redirects'); const { default: localDevConfig } = await import(
`./.vitepress/config?local-dev-redirects-${Date.now()}`
);
let routeHandler: let routeHandler:
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void) | ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
| undefined; | undefined;
+5 -3
View File
@@ -12,7 +12,7 @@ N+1 highlighting identifies sentences where you know every word except one, maki
**How it works:** **How it works:**
1. SubMiner queries your Anki decks for existing `Expression` / `Word` field values. 1. SubMiner queries your configured Anki decks for expression/word fields such as `Expression` or `Word`.
2. The results are cached locally (`known-words-cache.json`) and refreshed on a configurable interval. 2. The results are cached locally (`known-words-cache.json`) and refreshed on a configurable interval.
3. When a subtitle line appears, each token is checked against the cache. 3. When a subtitle line appears, each token is checked against the cache.
4. If exactly one unknown word remains in the sentence, it is highlighted with `subtitleStyle.nPlusOneColor` (default: `#c6a0f6`). 4. If exactly one unknown word remains in the sentence, it is highlighted with `subtitleStyle.nPlusOneColor` (default: `#c6a0f6`).
@@ -24,13 +24,15 @@ N+1 highlighting identifies sentences where you know every word except one, maki
| ----------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting | | `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes | | `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) | | `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries |
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) | | `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting. Existing configs with known-word highlighting enabled are treated as enabled for compatibility unless this is explicitly set. | | `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting |
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger | | `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word | | `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens | | `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
Prefer expression/word fields for `ankiConnect.knownWords.decks`. Reading-only fields can mark unrelated homophones as known, so only include them when that tradeoff is intentional.
::: tip ::: tip
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large. Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
::: :::
+2
View File
@@ -14,6 +14,8 @@ SubMiner connects to mpv via a Unix socket (or named pipe on Windows). If the so
SubMiner retries the connection automatically with increasing delays (200 ms, 500 ms, 1 s, 2 s on first connect; 1 s, 2 s, 5 s, 10 s on reconnect). If mpv exits and restarts, the overlay reconnects without needing a restart. SubMiner retries the connection automatically with increasing delays (200 ms, 500 ms, 1 s, 2 s on first connect; 1 s, 2 s, 5 s, 10 s on reconnect). If mpv exits and restarts, the overlay reconnects without needing a restart.
If the overlay never appears at all, see [Playback Startup Flow](./architecture#playback-startup-flow) for how a managed launch starts mpv and brings up the overlay.
## Logging and App Mode ## Logging and App Mode
- Default log output is `info`. - Default log output is `info`.
+6 -3
View File
@@ -49,7 +49,7 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
### Ways to Launch ### Ways to Launch
| Approach | Use when | How | | Approach | Use when | How |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| **`subminer` launcher** | You want SubMiner to handle everything — launch mpv, set up the socket, start the overlay. **Recommended for most users.** | `subminer video.mkv` | | **`subminer` launcher** | You want SubMiner to handle everything — launch mpv, set up the socket, start the overlay. **Recommended for most users.** | `subminer video.mkv` |
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` | | **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut | | **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
@@ -105,6 +105,7 @@ subminer jellyfin -p # Interactive Jellyfin library/item picker + p
subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app) subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app)
subminer app --stop # Stop background app (including Jellyfin cast broadcast) subminer app --stop # Stop background app (including Jellyfin cast broadcast)
subminer doctor # Dependency + config + socket diagnostics subminer doctor # Dependency + config + socket diagnostics
subminer logs -e # Export a sanitized log ZIP and print its path
subminer config path # Print active config path subminer config path # Print active config path
subminer config show # Print active config contents subminer config show # Print active config contents
subminer mpv socket # Print active mpv socket path subminer mpv socket # Print active mpv socket path
@@ -143,10 +144,11 @@ SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability ann
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series
SubMiner.AppImage --open-character-dictionary # Open in-app AniList selector
SubMiner.AppImage --help # Show all options SubMiner.AppImage --help # Show all options
``` ```
The tray menu includes `Export Logs`, which creates the same sanitized log ZIP as `subminer logs -e` and shows the archive path when complete.
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config. Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
### Logging and App Mode ### Logging and App Mode
@@ -187,6 +189,7 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases. - `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
- `subminer doctor`: health checks for core dependencies and runtime paths. - `subminer doctor`: health checks for core dependencies and runtime paths.
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`). - `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
- `subminer logs -e`: export a sanitized ZIP of today's logs, or the most recent logs when no current-day log exists.
- `subminer config`: config file helpers (`path`, `show`). - `subminer config`: config file helpers (`path`, `show`).
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`). - `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target. - `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
@@ -220,7 +223,7 @@ Setup flow:
AniList character dictionary auto-sync (optional): AniList character dictionary auto-sync (optional):
- Enable with `anilist.characterDictionary.enabled=true` in config. - Enable with `subtitleStyle.nameMatchEnabled=true` in config or **Name Match Enabled** in Settings.
- SubMiner syncs the currently watched AniList media into a per-media snapshot, then rebuilds one merged `SubMiner Character Dictionary` from the most recently used snapshots. - SubMiner syncs the currently watched AniList media into a per-media snapshot, then rebuilds one merged `SubMiner Character Dictionary` from the most recently used snapshots.
- Rotation limit defaults to 3 recent media snapshots in that merged dictionary (`maxLoaded`). - Rotation limit defaults to 3 recent media snapshots in that merged dictionary (`maxLoaded`).
+1 -1
View File
@@ -163,7 +163,7 @@ test('buildSubminerScriptOpts includes aniskip payload fields', () => {
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/); const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/); assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/); assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
assert.match(opts, /subminer-log_level=debug/); assert.doesNotMatch(opts, /subminer-log_level=/);
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/); assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
assert.match(opts, /subminer-aniskip_season=1/); assert.match(opts, /subminer-aniskip_season=1/);
assert.match(opts, /subminer-aniskip_episode=5/); assert.match(opts, /subminer-aniskip_episode=5/);
+1 -4
View File
@@ -564,7 +564,7 @@ export function buildSubminerScriptOpts(
appPath: string, appPath: string,
socketPath: string, socketPath: string,
aniSkipMetadata: AniSkipMetadata | null, aniSkipMetadata: AniSkipMetadata | null,
logLevel: LogLevel = 'info', _logLevel: LogLevel = 'info',
extraParts: string[] = [], extraParts: string[] = [],
): string { ): string {
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path=')); const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
@@ -574,9 +574,6 @@ export function buildSubminerScriptOpts(
...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]), ...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]),
...extraParts.map(sanitizeScriptOptValue), ...extraParts.map(sanitizeScriptOptValue),
]; ];
if (logLevel !== 'info') {
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);
}
if (aniSkipMetadata && aniSkipMetadata.title) { if (aniSkipMetadata && aniSkipMetadata.title) {
parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`); parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`);
} }
+30 -2
View File
@@ -6,6 +6,7 @@ import type { LauncherCommandContext } from './context.js';
import { runConfigCommand } from './config-command.js'; import { runConfigCommand } from './config-command.js';
import { runDictionaryCommand } from './dictionary-command.js'; import { runDictionaryCommand } from './dictionary-command.js';
import { runDoctorCommand } from './doctor-command.js'; import { runDoctorCommand } from './doctor-command.js';
import { runLogsCommand } from './logs-command.js';
import { runMpvPreAppCommand } from './mpv-command.js'; import { runMpvPreAppCommand } from './mpv-command.js';
import { runAppPassthroughCommand } from './app-command.js'; import { runAppPassthroughCommand } from './app-command.js';
import { runStatsCommand } from './stats-command.js'; import { runStatsCommand } from './stats-command.js';
@@ -169,6 +170,33 @@ test('doctor command forwards refresh-known-words to app binary', () => {
assert.deepEqual(forwarded, [['--refresh-known-words']]); assert.deepEqual(forwarded, [['--refresh-known-words']]);
}); });
test('logs command exports logs and writes archive path', () => {
const writes: string[] = [];
const context = createContext();
context.args.logsExport = true;
context.processAdapter = {
...context.processAdapter,
writeStdout: (text) => writes.push(text),
};
const handled = runLogsCommand(context, {
exportLogsArchive: () => ({
zipPath: '/tmp/subminer-logs.zip',
exportedFiles: ['/tmp/app.log'],
mode: 'current-day',
}),
});
assert.equal(handled, true);
assert.deepEqual(writes, ['/tmp/subminer-logs.zip\n']);
});
test('logs command ignores unrelated launcher commands', () => {
const context = createContext();
assert.equal(runLogsCommand(context), false);
});
test('app command starts default macOS background app detached from launcher', () => { test('app command starts default macOS background app detached from launcher', () => {
const context = createContext(); const context = createContext();
context.args.appPassthrough = true; context.args.appPassthrough = true;
@@ -185,7 +213,7 @@ test('app command starts default macOS background app detached from launcher', (
}); });
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(calls, ['detached:/tmp/subminer.app:info']); assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']);
}); });
test('app command starts default Linux background app detached from launcher', () => { test('app command starts default Linux background app detached from launcher', () => {
@@ -204,7 +232,7 @@ test('app command starts default Linux background app detached from launcher', (
}); });
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(calls, ['detached:/tmp/subminer.app:info']); assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']);
}); });
test('app command keeps explicit passthrough args attached', () => { test('app command keeps explicit passthrough args attached', () => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { runAppCommandWithInherit } from '../mpv.js'; import { runAppCommandWithInherit } from '../mpv.js';
import { shouldForwardLogLevel } from '../types.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
interface DictionaryCommandDeps { interface DictionaryCommandDeps {
@@ -35,7 +36,7 @@ export function runDictionaryCommand(
if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) { if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) {
forwarded.push('--dictionary-target', args.dictionaryTarget); forwarded.push('--dictionary-target', args.dictionaryTarget);
} }
if (args.logLevel !== 'info') { if (shouldForwardLogLevel(args.logLevel)) {
forwarded.push('--log-level', args.logLevel); forwarded.push('--log-level', args.logLevel);
} }
+5 -4
View File
@@ -2,6 +2,7 @@ import { fail } from '../log.js';
import { runAppCommandWithInherit } from '../mpv.js'; import { runAppCommandWithInherit } from '../mpv.js';
import { commandExists } from '../util.js'; import { commandExists } from '../util.js';
import { runJellyfinPlayMenu } from '../jellyfin.js'; import { runJellyfinPlayMenu } from '../jellyfin.js';
import { shouldForwardLogLevel } from '../types.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
export async function runJellyfinCommand(context: LauncherCommandContext): Promise<boolean> { export async function runJellyfinCommand(context: LauncherCommandContext): Promise<boolean> {
@@ -18,7 +19,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.jellyfin) { if (args.jellyfin) {
const forwarded = ['--jellyfin']; const forwarded = ['--jellyfin'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded); appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded); runAppCommandWithInherit(appPath, forwarded);
return true; return true;
@@ -42,7 +43,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
'--jellyfin-password', '--jellyfin-password',
password, password,
]; ];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded); appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded); runAppCommandWithInherit(appPath, forwarded);
return true; return true;
@@ -50,7 +51,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.jellyfinLogout) { if (args.jellyfinLogout) {
const forwarded = ['--jellyfin-logout']; const forwarded = ['--jellyfin-logout'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded); appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded); runAppCommandWithInherit(appPath, forwarded);
return true; return true;
@@ -69,7 +70,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.jellyfinDiscovery) { if (args.jellyfinDiscovery) {
const forwarded = ['--background', '--jellyfin-remote-announce']; const forwarded = ['--background', '--jellyfin-remote-announce'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded); appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded); runAppCommandWithInherit(appPath, forwarded);
return true; return true;
+24
View File
@@ -0,0 +1,24 @@
import { exportLogsArchiveForCurrentUser } from '../../src/main/runtime/log-export.js';
import type { ExportLogsResult } from '../../src/main/runtime/log-export.js';
import type { LauncherCommandContext } from './context.js';
interface LogsCommandDeps {
exportLogsArchive(): ExportLogsResult;
}
const defaultDeps: LogsCommandDeps = {
exportLogsArchive: () => exportLogsArchiveForCurrentUser(),
};
export function runLogsCommand(
context: LauncherCommandContext,
deps: LogsCommandDeps = defaultDeps,
): boolean {
if (!context.args.logsExport) {
return false;
}
const result = deps.exportLogsArchive();
context.processAdapter.writeStdout(`${result.zipPath}\n`);
return true;
}
@@ -36,6 +36,7 @@ function createContext(): LauncherCommandContext {
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
logLevel: 'info', logLevel: 'info',
logRotation: 7,
passwordStore: '', passwordStore: '',
target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw', target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
targetKind: 'url', targetKind: 'url',
@@ -55,6 +56,7 @@ function createContext(): LauncherCommandContext {
stats: false, stats: false,
doctor: false, doctor: false,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsExport: false,
version: false, version: false,
settings: false, settings: false,
configPath: false, configPath: false,
@@ -321,6 +323,7 @@ test('plugin auto-start playback attaches a warm background app through the laun
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => { test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
const context = createContext(); const context = createContext();
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
const originalAppData = process.env.APPDATA;
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-')); const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner'); const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
fs.mkdirSync(expectedConfigDir, { recursive: true }); fs.mkdirSync(expectedConfigDir, { recursive: true });
@@ -347,6 +350,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
try { try {
process.env.XDG_CONFIG_HOME = xdgConfigHome; process.env.XDG_CONFIG_HOME = xdgConfigHome;
process.env.APPDATA = xdgConfigHome;
await runPlaybackCommandWithDeps(context, { await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {}, ensurePlaybackSetupReady: async () => {},
@@ -376,6 +380,11 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
} else { } else {
process.env.XDG_CONFIG_HOME = originalXdgConfigHome; process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
} }
if (originalAppData === undefined) {
delete process.env.APPDATA;
} else {
process.env.APPDATA = originalAppData;
}
fs.rmSync(xdgConfigHome, { recursive: true, force: true }); fs.rmSync(xdgConfigHome, { recursive: true, force: true });
} }
}); });
+2 -1
View File
@@ -4,6 +4,7 @@ import path from 'node:path';
import { runAppCommandAttached } from '../mpv.js'; import { runAppCommandAttached } from '../mpv.js';
import { nowMs } from '../time.js'; import { nowMs } from '../time.js';
import { sleep } from '../util.js'; import { sleep } from '../util.js';
import { shouldForwardLogLevel } from '../types.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
type StatsCommandResponse = { type StatsCommandResponse = {
@@ -156,7 +157,7 @@ export async function runStatsCommand(
if (args.statsCleanupLifetime) { if (args.statsCleanupLifetime) {
forwarded.push('--stats-cleanup-lifetime'); forwarded.push('--stats-cleanup-lifetime');
} }
if (args.logLevel !== 'info') { if (shouldForwardLogLevel(args.logLevel)) {
forwarded.push('--log-level', args.logLevel); forwarded.push('--log-level', args.logLevel);
} }
const attachedExitPromise = resolvedDeps.runAppCommandAttached( const attachedExitPromise = resolvedDeps.runAppCommandAttached(
+1
View File
@@ -13,6 +13,7 @@ test('launcher root help lists subcommands', () => {
assert.match(output, /doctor/); assert.match(output, /doctor/);
assert.match(output, /config/); assert.match(output, /config/);
assert.match(output, /mpv/); assert.match(output, /mpv/);
assert.match(output, /logs/);
assert.match(output, /dictionary\|dict/); assert.match(output, /dictionary\|dict/);
assert.match(output, /texthooker/); assert.match(output, /texthooker/);
assert.match(output, /app\|bin/); assert.match(output, /app\|bin/);
+50 -1
View File
@@ -1,12 +1,14 @@
import { fail } from './log.js'; import { fail } from './log.js';
import type { import type {
Args, Args,
LauncherLoggingConfig,
LauncherJellyfinConfig, LauncherJellyfinConfig,
LauncherMpvConfig, LauncherMpvConfig,
LauncherYoutubeSubgenConfig, LauncherYoutubeSubgenConfig,
LogLevel, LogLevel,
PluginRuntimeConfig, PluginRuntimeConfig,
} from './types.js'; } from './types.js';
import { normalizeLogRotation } from '../src/shared/log-files.js';
import { import {
applyInvocationsToArgs, applyInvocationsToArgs,
applyRootOptionsToArgs, applyRootOptionsToArgs,
@@ -52,6 +54,52 @@ export function loadLauncherMpvConfig(): LauncherMpvConfig {
return parseLauncherMpvConfig(root); return parseLauncherMpvConfig(root);
} }
function parseLogLevelConfig(value: unknown): LogLevel | undefined {
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (
normalized === 'debug' ||
normalized === 'info' ||
normalized === 'warn' ||
normalized === 'error'
) {
return normalized;
}
return undefined;
}
function parseLogRotationConfig(value: unknown): LauncherLoggingConfig['rotation'] {
return normalizeLogRotation(value);
}
function parseLogFileConfig(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined;
}
export function loadLauncherLoggingConfig(): LauncherLoggingConfig {
const root = readLauncherMainConfigObject();
if (!root) return {};
const logging =
root.logging && typeof root.logging === 'object' && !Array.isArray(root.logging)
? (root.logging as Record<string, unknown>)
: null;
const files =
logging?.files && typeof logging.files === 'object' && !Array.isArray(logging.files)
? (logging.files as Record<string, unknown>)
: null;
return {
level: parseLogLevelConfig(logging?.level),
rotation: parseLogRotationConfig(logging?.rotation),
files: files
? {
app: parseLogFileConfig(files.app),
launcher: parseLogFileConfig(files.launcher),
mpv: parseLogFileConfig(files.mpv),
}
: undefined,
};
}
export function hasLauncherExternalYomitanProfileConfig(): boolean { export function hasLauncherExternalYomitanProfileConfig(): boolean {
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null; return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
} }
@@ -65,9 +113,10 @@ export function parseArgs(
scriptName: string, scriptName: string,
launcherConfig: LauncherYoutubeSubgenConfig, launcherConfig: LauncherYoutubeSubgenConfig,
launcherMpvConfig: LauncherMpvConfig = {}, launcherMpvConfig: LauncherMpvConfig = {},
launcherLoggingConfig: LauncherLoggingConfig = {},
): Args { ): Args {
const topLevelCommand = resolveTopLevelCommand(argv); const topLevelCommand = resolveTopLevelCommand(argv);
const parsed = createDefaultArgs(launcherConfig, launcherMpvConfig); const parsed = createDefaultArgs(launcherConfig, launcherMpvConfig, launcherLoggingConfig);
if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) { if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) {
parsed.appPassthrough = true; parsed.appPassthrough = true;
+15
View File
@@ -51,6 +51,13 @@ test('createDefaultArgs seeds mpv profile from launcher config', () => {
assert.equal(parsed.profile, 'anime'); assert.equal(parsed.profile, 'anime');
}); });
test('createDefaultArgs seeds log level from launcher logging config', () => {
const parsed = createDefaultArgs({}, {}, { level: 'debug', rotation: 14 });
assert.equal(parsed.logLevel, 'debug');
assert.equal(parsed.logRotation, 14);
});
test('applyRootOptionsToArgs appends CLI mpv profile to configured profile', () => { test('applyRootOptionsToArgs appends CLI mpv profile to configured profile', () => {
const parsed = createDefaultArgs({}, { profile: 'anime' }); const parsed = createDefaultArgs({}, { profile: 'anime' });
@@ -131,6 +138,8 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
doctorTriggered: false, doctorTriggered: false,
doctorLogLevel: null, doctorLogLevel: null,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false, texthookerTriggered: false,
texthookerLogLevel: null, texthookerLogLevel: null,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
@@ -175,6 +184,8 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
doctorTriggered: false, doctorTriggered: false,
doctorLogLevel: null, doctorLogLevel: null,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false, texthookerTriggered: false,
texthookerLogLevel: null, texthookerLogLevel: null,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
@@ -212,6 +223,8 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
doctorTriggered: false, doctorTriggered: false,
doctorLogLevel: null, doctorLogLevel: null,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false, texthookerTriggered: false,
texthookerLogLevel: null, texthookerLogLevel: null,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
@@ -247,6 +260,8 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
doctorTriggered: false, doctorTriggered: false,
doctorLogLevel: null, doctorLogLevel: null,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: true, texthookerTriggered: true,
texthookerLogLevel: null, texthookerLogLevel: null,
texthookerOpenBrowser: true, texthookerOpenBrowser: true,
+9 -1
View File
@@ -4,6 +4,7 @@ import { fail } from '../log.js';
import type { import type {
Args, Args,
Backend, Backend,
LauncherLoggingConfig,
LauncherMpvConfig, LauncherMpvConfig,
LauncherYoutubeSubgenConfig, LauncherYoutubeSubgenConfig,
LogLevel, LogLevel,
@@ -106,6 +107,7 @@ function parseDictionaryAnilistId(value: string): number {
export function createDefaultArgs( export function createDefaultArgs(
launcherConfig: LauncherYoutubeSubgenConfig, launcherConfig: LauncherYoutubeSubgenConfig,
mpvConfig: LauncherMpvConfig = {}, mpvConfig: LauncherMpvConfig = {},
loggingConfig: LauncherLoggingConfig = {},
): Args { ): Args {
const configuredSecondaryLangs = uniqueNormalizedLangCodes( const configuredSecondaryLangs = uniqueNormalizedLangCodes(
launcherConfig.secondarySubLanguages ?? [], launcherConfig.secondarySubLanguages ?? [],
@@ -162,6 +164,7 @@ export function createDefaultArgs(
statsCleanupLifetime: false, statsCleanupLifetime: false,
doctor: false, doctor: false,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsExport: false,
version: false, version: false,
update: false, update: false,
settings: false, settings: false,
@@ -195,7 +198,8 @@ export function createDefaultArgs(
texthookerOnly: false, texthookerOnly: false,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
logLevel: 'info', logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7,
passwordStore: '', passwordStore: '',
target: '', target: '',
targetKind: '', targetKind: '',
@@ -260,6 +264,10 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
} }
if (invocations.doctorTriggered) parsed.doctor = true; if (invocations.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true; if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
if (invocations.logsTriggered && !invocations.logsExport) {
fail('Logs command requires -e or --export.');
}
if (invocations.logsExport) parsed.logsExport = true;
if (invocations.texthookerTriggered) parsed.texthookerOnly = true; if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
if (invocations.texthookerOpenBrowser) parsed.texthookerOpenBrowser = true; if (invocations.texthookerOpenBrowser) parsed.texthookerOpenBrowser = true;
+16
View File
@@ -41,6 +41,8 @@ export interface CliInvocations {
doctorTriggered: boolean; doctorTriggered: boolean;
doctorLogLevel: string | null; doctorLogLevel: string | null;
doctorRefreshKnownWords: boolean; doctorRefreshKnownWords: boolean;
logsTriggered: boolean;
logsExport: boolean;
texthookerTriggered: boolean; texthookerTriggered: boolean;
texthookerLogLevel: string | null; texthookerLogLevel: string | null;
texthookerOpenBrowser: boolean; texthookerOpenBrowser: boolean;
@@ -91,6 +93,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'config', 'config',
'settings', 'settings',
'mpv', 'mpv',
'logs',
'dictionary', 'dictionary',
'dict', 'dict',
'stats', 'stats',
@@ -158,6 +161,8 @@ export function parseCliPrograms(
let statsLogLevel: string | null = null; let statsLogLevel: string | null = null;
let doctorLogLevel: string | null = null; let doctorLogLevel: string | null = null;
let doctorRefreshKnownWords = false; let doctorRefreshKnownWords = false;
let logsTriggered = false;
let logsExport = false;
let texthookerLogLevel: string | null = null; let texthookerLogLevel: string | null = null;
let texthookerOpenBrowser = false; let texthookerOpenBrowser = false;
let doctorTriggered = false; let doctorTriggered = false;
@@ -294,6 +299,15 @@ export function parseCliPrograms(
doctorRefreshKnownWords = options.refreshKnownWords === true; doctorRefreshKnownWords = options.refreshKnownWords === true;
}); });
commandProgram
.command('logs')
.description('Log file helpers')
.option('-e, --export', 'Export sanitized log archive')
.action((options: Record<string, unknown>) => {
logsTriggered = true;
logsExport = options.export === true;
});
commandProgram commandProgram
.command('config') .command('config')
.description('Config file helpers (path|show)') .description('Config file helpers (path|show)')
@@ -388,6 +402,8 @@ export function parseCliPrograms(
doctorTriggered, doctorTriggered,
doctorLogLevel, doctorLogLevel,
doctorRefreshKnownWords, doctorRefreshKnownWords,
logsTriggered,
logsExport,
texthookerTriggered, texthookerTriggered,
texthookerLogLevel, texthookerLogLevel,
texthookerOpenBrowser, texthookerOpenBrowser,
+3 -1
View File
@@ -40,6 +40,7 @@ function validBackendOrDefault(value: unknown, fallback: Backend): Backend {
export function parsePluginRuntimeConfigFromMainConfig( export function parsePluginRuntimeConfigFromMainConfig(
root: Record<string, unknown> | null, root: Record<string, unknown> | null,
logLevel: LogLevel = 'info',
): PluginRuntimeConfig { ): PluginRuntimeConfig {
const mpvConfig = root ? parseLauncherMpvConfig(root) : {}; const mpvConfig = root ? parseLauncherMpvConfig(root) : {};
const texthooker = rootObject(root, 'texthooker'); const texthooker = rootObject(root, 'texthooker');
@@ -48,6 +49,7 @@ export function parsePluginRuntimeConfigFromMainConfig(
socketPath: mpvConfig.socketPath ?? DEFAULT_SOCKET_PATH, socketPath: mpvConfig.socketPath ?? DEFAULT_SOCKET_PATH,
binaryPath: mpvConfig.subminerBinaryPath ?? '', binaryPath: mpvConfig.subminerBinaryPath ?? '',
backend: validBackendOrDefault(mpvConfig.backend, 'auto'), backend: validBackendOrDefault(mpvConfig.backend, 'auto'),
logLevel,
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true), autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false), autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true), autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
@@ -65,7 +67,7 @@ export function buildPluginRuntimeScriptOptParts(
} }
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject()); const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject(), logLevel);
log( log(
'debug', 'debug',
+2 -1
View File
@@ -9,6 +9,7 @@ import type {
JellyfinItemEntry, JellyfinItemEntry,
JellyfinGroupEntry, JellyfinGroupEntry,
} from './types.js'; } from './types.js';
import { shouldForwardLogLevel } from './types.js';
import { log, fail, getMpvLogPath } from './log.js'; import { log, fail, getMpvLogPath } from './log.js';
import { nowMs } from './time.js'; import { nowMs } from './time.js';
import { commandExists, resolvePathMaybe, sleep } from './util.js'; import { commandExists, resolvePathMaybe, sleep } from './util.js';
@@ -1036,7 +1037,7 @@ export async function runJellyfinPlayMenu(
fail(`MPV IPC socket not ready: ${mpvSocketPath}`); fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
} }
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`]; const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore); if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play'); runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
} }
+24 -5
View File
@@ -1,6 +1,14 @@
import type { LogLevel } from './types.js'; import type { LogLevel } from './types.js';
import { DEFAULT_MPV_LOG_FILE, getDefaultLauncherLogFile } from './types.js'; import { getDefaultLauncherLogFile } from './types.js';
import { appendLogLine, resolveDefaultLogFilePath } from '../src/shared/log-files.js'; import {
appendLogLine,
DEFAULT_LOG_ROTATION,
isLogFileEnabled,
normalizeLogRotation,
pruneLogDirectoryForPath,
resolveDefaultLogFilePath,
type LogRotation,
} from '../src/shared/log-files.js';
export const COLORS = { export const COLORS = {
red: '\x1b[0;31m', red: '\x1b[0;31m',
@@ -22,25 +30,36 @@ export function shouldLog(level: LogLevel, configured: LogLevel): boolean {
} }
export function getMpvLogPath(): string { export function getMpvLogPath(): string {
if (!isLogFileEnabled('mpv')) return '';
const envPath = process.env.SUBMINER_MPV_LOG?.trim(); const envPath = process.env.SUBMINER_MPV_LOG?.trim();
if (envPath) return envPath; const logPath = envPath || resolveDefaultLogFilePath('mpv');
return DEFAULT_MPV_LOG_FILE; pruneLogDirectoryForPath(logPath, getLogRotation());
return logPath;
} }
export function getLauncherLogPath(): string { export function getLauncherLogPath(): string {
if (!isLogFileEnabled('launcher')) return '';
const envPath = process.env.SUBMINER_LAUNCHER_LOG?.trim(); const envPath = process.env.SUBMINER_LAUNCHER_LOG?.trim();
if (envPath) return envPath; if (envPath) return envPath;
return getDefaultLauncherLogFile(); return getDefaultLauncherLogFile();
} }
export function getAppLogPath(): string { export function getAppLogPath(): string {
if (!isLogFileEnabled('app')) return '';
const envPath = process.env.SUBMINER_APP_LOG?.trim(); const envPath = process.env.SUBMINER_APP_LOG?.trim();
if (envPath) return envPath; if (envPath) return envPath;
return resolveDefaultLogFilePath('app'); return resolveDefaultLogFilePath('app');
} }
function getLogRotation(): LogRotation {
return normalizeLogRotation(process.env.SUBMINER_LOG_ROTATION) ?? DEFAULT_LOG_ROTATION;
}
function appendTimestampedLog(logPath: string, message: string): void { function appendTimestampedLog(logPath: string, message: string): void {
appendLogLine(logPath, `[${new Date().toISOString()}] ${message}`); if (!logPath.trim()) return;
appendLogLine(logPath, `[${new Date().toISOString()}] ${message}`, {
rotation: getLogRotation(),
});
} }
export function appendToMpvLog(message: string): void { export function appendToMpvLog(message: string): void {
+32 -2
View File
@@ -124,6 +124,29 @@ test('short version flag prints installed app version without requiring app bina
}); });
}); });
test('logs export writes sanitized archive without requiring app binary', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const logsDir =
process.platform === 'win32'
? path.join(xdgConfigHome, 'SubMiner', 'logs')
: path.join(homeDir, '.config', 'SubMiner', 'logs');
fs.mkdirSync(logsDir, { recursive: true });
fs.writeFileSync(path.join(logsDir, 'app-2026-W21.log'), `/home/kyle/video.mkv\n`, 'utf8');
const result = runLauncher(['logs', '-e'], makeTestEnv(homeDir, xdgConfigHome));
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
const zipPath = result.stdout.trim();
assert.match(zipPath, /subminer-logs-.+\.zip$/);
assert.equal(fs.existsSync(zipPath), true);
const archive = fs.readFileSync(zipPath);
assert.equal(archive.includes(Buffer.from('/home/kyle')), false);
assert.equal(archive.includes(Buffer.from('/home/<user>')), true);
});
});
test('config path prefers jsonc over json for same directory', () => { test('config path prefers jsonc over json for same directory', () => {
withTempDir((root) => { withTempDir((root) => {
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');
@@ -395,7 +418,7 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
}); });
}); });
test('launcher forwards non-info log level into mpv plugin script opts', { timeout: 15000 }, () => { test('launcher forwards non-info log level into mpv logging args', { timeout: 15000 }, () => {
withTempDir((root) => { withTempDir((root) => {
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg'); const xdgConfigHome = path.join(root, 'xdg');
@@ -430,6 +453,11 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo
autoStartSubMiner: true, autoStartSubMiner: true,
pauseUntilOverlayReady: true, pauseUntilOverlayReady: true,
}, },
logging: {
files: {
mpv: true,
},
},
}), }),
); );
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n'); fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
@@ -468,7 +496,9 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
const result = runLauncher(['--log-level', 'debug', videoPath], env); const result = runLauncher(['--log-level', 'debug', videoPath], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
assert.match(fs.readFileSync(mpvArgsPath, 'utf8'), /--script-opts=.*subminer-log_level=debug/); const mpvArgs = fs.readFileSync(mpvArgsPath, 'utf8');
assert.match(mpvArgs, /--msg-level=all=warn,subminer=debug/);
assert.doesNotMatch(mpvArgs, /--script-opts=.*subminer-log_level=debug/);
}); });
}); });
+20 -1
View File
@@ -1,7 +1,9 @@
import path from 'node:path'; import path from 'node:path';
import packageJson from '../package.json'; import packageJson from '../package.json';
import { applyLogFileTogglesToEnv } from '../src/shared/log-files.js';
import { import {
loadLauncherJellyfinConfig, loadLauncherJellyfinConfig,
loadLauncherLoggingConfig,
loadLauncherMpvConfig, loadLauncherMpvConfig,
loadLauncherYoutubeSubgenConfig, loadLauncherYoutubeSubgenConfig,
parseArgs, parseArgs,
@@ -16,6 +18,7 @@ import { runConfigCommand } from './commands/config-command.js';
import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js'; import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js';
import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js'; import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js';
import { runDictionaryCommand } from './commands/dictionary-command.js'; import { runDictionaryCommand } from './commands/dictionary-command.js';
import { runLogsCommand } from './commands/logs-command.js';
import { runStatsCommand } from './commands/stats-command.js'; import { runStatsCommand } from './commands/stats-command.js';
import { runJellyfinCommand } from './commands/jellyfin-command.js'; import { runJellyfinCommand } from './commands/jellyfin-command.js';
import { runPlaybackCommand } from './commands/playback-command.js'; import { runPlaybackCommand } from './commands/playback-command.js';
@@ -61,7 +64,19 @@ async function main(): Promise<void> {
const scriptName = path.basename(scriptPath); const scriptName = path.basename(scriptPath);
const launcherConfig = loadLauncherYoutubeSubgenConfig(); const launcherConfig = loadLauncherYoutubeSubgenConfig();
const launcherMpvConfig = loadLauncherMpvConfig(); const launcherMpvConfig = loadLauncherMpvConfig();
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig, launcherMpvConfig); const launcherLoggingConfig = loadLauncherLoggingConfig();
applyLogFileTogglesToEnv(launcherLoggingConfig.files);
process.env.SUBMINER_LOG_ROTATION =
launcherLoggingConfig.rotation !== undefined
? String(launcherLoggingConfig.rotation)
: (process.env.SUBMINER_LOG_ROTATION ?? '7');
const args = parseArgs(
process.argv.slice(2),
scriptName,
launcherConfig,
launcherMpvConfig,
launcherLoggingConfig,
);
if (args.version) { if (args.version) {
console.log(`SubMiner ${APP_VERSION}`); console.log(`SubMiner ${APP_VERSION}`);
@@ -87,6 +102,10 @@ async function main(): Promise<void> {
return; return;
} }
if (runLogsCommand(context)) {
return;
}
const resolvedAppPath = ensureAppPath(context); const resolvedAppPath = ensureAppPath(context);
state.appPath = resolvedAppPath; state.appPath = resolvedAppPath;
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`); log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
+2
View File
@@ -566,6 +566,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
logLevel: 'error', logLevel: 'error',
logRotation: 7,
passwordStore: '', passwordStore: '',
target: '', target: '',
targetKind: '', targetKind: '',
@@ -585,6 +586,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
stats: false, stats: false,
doctor: false, doctor: false,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsExport: false,
version: false, version: false,
settings: false, settings: false,
configPath: false, configPath: false,
+36 -10
View File
@@ -4,6 +4,7 @@ import os from 'node:os';
import net from 'node:net'; import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js'; import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js';
import { import {
isAppControlServerAvailable as checkAppControlServerAvailable, isAppControlServerAvailable as checkAppControlServerAvailable,
sendAppControlCommand, sendAppControlCommand,
@@ -14,7 +15,11 @@ import {
type InstalledMpvPluginDetection, type InstalledMpvPluginDetection,
} from '../src/main/runtime/first-run-setup-plugin.js'; } from '../src/main/runtime/first-run-setup-plugin.js';
import type { LogLevel, Backend, Args, MpvTrack, PluginRuntimeConfig } from './types.js'; import type { LogLevel, Backend, Args, MpvTrack, PluginRuntimeConfig } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js'; import {
DEFAULT_MPV_SUBMINER_ARGS,
DEFAULT_YOUTUBE_YTDL_FORMAT,
shouldForwardLogLevel,
} from './types.js';
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js'; import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js'; import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js'; import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
@@ -951,7 +956,7 @@ export async function startMpv(
); );
} }
mpvArgs.push(`--script-opts=${scriptOpts}`); mpvArgs.push(`--script-opts=${scriptOpts}`);
mpvArgs.push(`--log-file=${getMpvLogPath()}`); mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
try { try {
fs.rmSync(socketPath, { force: true }); fs.rmSync(socketPath, { force: true });
@@ -1031,7 +1036,7 @@ export async function startOverlay(
socketPath, socketPath,
...extraAppArgs, ...extraAppArgs,
]; ];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) overlayArgs.push('--log-level', args.logLevel);
if (args.useTexthooker) overlayArgs.push('--texthooker'); if (args.useTexthooker) overlayArgs.push('--texthooker');
const controlResult = await sendAppControlCommand(overlayArgs, { const controlResult = await sendAppControlCommand(overlayArgs, {
@@ -1176,7 +1181,7 @@ export function launchTexthookerOnly(
): never { ): never {
const overlayArgs = ['--texthooker']; const overlayArgs = ['--texthooker'];
if (args.texthookerOpenBrowser) overlayArgs.push('--open-browser'); if (args.texthookerOpenBrowser) overlayArgs.push('--open-browser');
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) overlayArgs.push('--log-level', args.logLevel);
log('info', args.logLevel, 'Launching texthooker mode...'); log('info', args.logLevel, 'Launching texthooker mode...');
const result = runSyncAppCommand(appPath, overlayArgs, true); const result = runSyncAppCommand(appPath, overlayArgs, true);
@@ -1254,7 +1259,7 @@ function stopManagedOverlayApp(args: Args): void {
log('info', args.logLevel, 'Stopping SubMiner overlay...'); log('info', args.logLevel, 'Stopping SubMiner overlay...');
const stopArgs = ['--stop']; const stopArgs = ['--stop'];
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) stopArgs.push('--log-level', args.logLevel);
const target = resolveAppSpawnTarget(state.appPath, stopArgs); const target = resolveAppSpawnTarget(state.appPath, stopArgs);
const result = spawnSync(target.command, target.args, { const result = spawnSync(target.command, target.args, {
@@ -1306,6 +1311,8 @@ function buildAppEnv(
...baseEnv, ...baseEnv,
SUBMINER_APP_LOG: getAppLogPath(), SUBMINER_APP_LOG: getAppLogPath(),
SUBMINER_MPV_LOG: getMpvLogPath(), SUBMINER_MPV_LOG: getMpvLogPath(),
SUBMINER_LOG_LEVEL: extraEnv.SUBMINER_LOG_LEVEL ?? baseEnv.SUBMINER_LOG_LEVEL,
SUBMINER_LOG_ROTATION: extraEnv.SUBMINER_LOG_ROTATION ?? baseEnv.SUBMINER_LOG_ROTATION,
}; };
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RUN_AS_NODE;
clearTransportedAppArgs(env); clearTransportedAppArgs(env);
@@ -1326,10 +1333,13 @@ function buildAppEnv(
} }
export function buildMpvEnv( export function buildMpvEnv(
args: Pick<Args, 'backend'>, args: Pick<Args, 'backend' | 'logLevel' | 'logRotation'>,
baseEnv: NodeJS.ProcessEnv = process.env, baseEnv: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv { ): NodeJS.ProcessEnv {
const env = buildAppEnv(baseEnv); const env = buildAppEnv(baseEnv, {
SUBMINER_LOG_LEVEL: args.logLevel,
SUBMINER_LOG_ROTATION: String(args.logRotation),
});
if (!shouldForceX11MpvBackend(args, env)) { if (!shouldForceX11MpvBackend(args, env)) {
return env; return env;
} }
@@ -1586,13 +1596,13 @@ export function runAppCommandWithInheritLogged(
export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void { export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void {
const startArgs = ['--start']; const startArgs = ['--start'];
if (logLevel !== 'info') startArgs.push('--log-level', logLevel); if (shouldForwardLogLevel(logLevel)) startArgs.push('--log-level', logLevel);
launchAppCommandDetached(appPath, startArgs, logLevel, 'start'); launchAppCommandDetached(appPath, startArgs, logLevel, 'start');
} }
export function launchAppBackgroundDetached(appPath: string, logLevel: LogLevel): void { export function launchAppBackgroundDetached(appPath: string, logLevel: LogLevel): void {
const startArgs = ['--start', '--background']; const startArgs = ['--start', '--background'];
if (logLevel !== 'info') startArgs.push('--log-level', logLevel); if (shouldForwardLogLevel(logLevel)) startArgs.push('--log-level', logLevel);
launchAppCommandDetached(appPath, startArgs, logLevel, 'app', { launchAppCommandDetached(appPath, startArgs, logLevel, 'app', {
[BACKGROUND_CHILD_ENV]: '1', [BACKGROUND_CHILD_ENV]: '1',
}); });
@@ -1615,6 +1625,22 @@ export function launchAppCommandDetached(
`${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`, `${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`,
); );
const appLogPath = getAppLogPath(); const appLogPath = getAppLogPath();
if (!appLogPath) {
try {
const proc = spawn(target.command, target.args, {
stdio: 'ignore',
detached: true,
env: buildAppEnv(process.env, { ...target.env, ...extraEnv }),
});
proc.once('error', (error) => {
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
});
proc.unref();
} catch (error) {
log('warn', logLevel, `${label}: failed to launch detached app: ${(error as Error).message}`);
}
return;
}
fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); fs.mkdirSync(path.dirname(appLogPath), { recursive: true });
const stdoutFd = fs.openSync(appLogPath, 'a'); const stdoutFd = fs.openSync(appLogPath, 'a');
const stderrFd = fs.openSync(appLogPath, 'a'); const stderrFd = fs.openSync(appLogPath, 'a');
@@ -1673,7 +1699,7 @@ export function launchMpvIdleDetached(
runtimeScriptOpts, runtimeScriptOpts,
)}`, )}`,
); );
mpvArgs.push(`--log-file=${getMpvLogPath()}`); mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
mpvArgs.push(`--input-ipc-server=${socketPath}`); mpvArgs.push(`--input-ipc-server=${socketPath}`);
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, { const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, {
normalizeWindowsShellArgs: false, normalizeWindowsShellArgs: false,
+15
View File
@@ -244,3 +244,18 @@ test('parseArgs maps doctor refresh-known-words flag', () => {
assert.equal(parsed.doctor, true); assert.equal(parsed.doctor, true);
assert.equal(parsed.doctorRefreshKnownWords, true); assert.equal(parsed.doctorRefreshKnownWords, true);
}); });
test('parseArgs maps logs export flag', () => {
const parsed = parseArgs(['logs', '-e'], 'subminer', {});
assert.equal(parsed.logsExport, true);
});
test('parseArgs requires an explicit logs action', () => {
const exit = withProcessExitIntercept(() => {
parseArgs(['logs'], 'subminer', {});
});
assert.equal(exit.code, 1);
assert.match(exit.stderr, /Logs command requires -e or --export/);
});
+10 -2
View File
@@ -23,6 +23,8 @@ type SmokeCase = {
artifactsDir: string; artifactsDir: string;
binDir: string; binDir: string;
xdgConfigHome: string; xdgConfigHome: string;
appDataDir: string;
localAppDataDir: string;
homeDir: string; homeDir: string;
socketDir: string; socketDir: string;
socketPath: string; socketPath: string;
@@ -61,6 +63,8 @@ function createSmokeCase(name: string): SmokeCase {
const artifactsDir = path.join(root, 'artifacts'); const artifactsDir = path.join(root, 'artifacts');
const binDir = path.join(root, 'bin'); const binDir = path.join(root, 'bin');
const xdgConfigHome = path.join(root, 'xdg'); const xdgConfigHome = path.join(root, 'xdg');
const appDataDir = path.join(root, 'AppData', 'Roaming');
const localAppDataDir = path.join(root, 'AppData', 'Local');
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');
const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-smoke-sock-')); const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-smoke-sock-'));
const socketPath = path.join(socketDir, 'subminer.sock'); const socketPath = path.join(socketDir, 'subminer.sock');
@@ -73,7 +77,7 @@ function createSmokeCase(name: string): SmokeCase {
fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(videoPath, 'fake video fixture'); fs.writeFileSync(videoPath, 'fake video fixture');
const configDir = getDefaultConfigDir({ xdgConfigHome, homeDir }); const configDir = getDefaultConfigDir({ xdgConfigHome, appDataDir, homeDir });
fs.mkdirSync(configDir, { recursive: true }); fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), JSON.stringify({ mpv: { socketPath } })); fs.writeFileSync(path.join(configDir, 'config.jsonc'), JSON.stringify({ mpv: { socketPath } }));
const setupState = createDefaultSetupState(); const setupState = createDefaultSetupState();
@@ -159,6 +163,8 @@ process.exit(0);
artifactsDir, artifactsDir,
binDir, binDir,
xdgConfigHome, xdgConfigHome,
appDataDir,
localAppDataDir,
homeDir, homeDir,
socketDir, socketDir,
socketPath, socketPath,
@@ -174,6 +180,8 @@ function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv {
...process.env, ...process.env,
HOME: smokeCase.homeDir, HOME: smokeCase.homeDir,
XDG_CONFIG_HOME: smokeCase.xdgConfigHome, XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
APPDATA: smokeCase.appDataDir,
LOCALAPPDATA: smokeCase.localAppDataDir,
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath, SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
SUBMINER_MPV_LOG: smokeCase.mpvOverlayLogPath, SUBMINER_MPV_LOG: smokeCase.mpvOverlayLogPath,
}; };
@@ -410,7 +418,7 @@ test(
const env = makeTestEnv(smokeCase); const env = makeTestEnv(smokeCase);
const result = runLauncher( const result = runLauncher(
smokeCase, smokeCase,
['--backend', 'x11', '--start-overlay', smokeCase.videoPath], ['--backend', 'x11', '--log-level', 'info', '--start-overlay', smokeCase.videoPath],
env, env,
'overlay-start-stop', 'overlay-start-stop',
); );
+17 -1
View File
@@ -1,7 +1,11 @@
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js'; import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js';
import { resolveDefaultLogFilePath } from '../src/shared/log-files.js'; import {
resolveDefaultLogFilePath,
type LogFileToggles,
type LogRotation,
} from '../src/shared/log-files.js';
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js'; export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
export const ROFI_THEME_FILE = 'subminer.rasi'; export const ROFI_THEME_FILE = 'subminer.rasi';
@@ -67,6 +71,9 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [
] as const; ] as const;
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export function shouldForwardLogLevel(level: LogLevel): boolean {
return level === 'debug' || level === 'error';
}
export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows'; export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows';
export type JimakuLanguagePreference = 'ja' | 'en' | 'none'; export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
@@ -106,6 +113,7 @@ export interface Args {
texthookerOpenBrowser: boolean; texthookerOpenBrowser: boolean;
useRofi: boolean; useRofi: boolean;
logLevel: LogLevel; logLevel: LogLevel;
logRotation: LogRotation;
passwordStore: string; passwordStore: string;
target: string; target: string;
targetKind: '' | 'file' | 'url'; targetKind: '' | 'file' | 'url';
@@ -132,6 +140,7 @@ export interface Args {
dictionaryTarget?: string; dictionaryTarget?: string;
doctor: boolean; doctor: boolean;
doctorRefreshKnownWords: boolean; doctorRefreshKnownWords: boolean;
logsExport: boolean;
version: boolean; version: boolean;
update?: boolean; update?: boolean;
settings: boolean; settings: boolean;
@@ -186,10 +195,17 @@ export interface LauncherMpvConfig {
aniskipButtonKey?: string; aniskipButtonKey?: string;
} }
export interface LauncherLoggingConfig {
level?: LogLevel;
rotation?: LogRotation;
files?: Partial<LogFileToggles>;
}
export interface PluginRuntimeConfig { export interface PluginRuntimeConfig {
socketPath: string; socketPath: string;
binaryPath: string; binaryPath: string;
backend: Backend; backend: Backend;
logLevel?: LogLevel;
autoStart: boolean; autoStart: boolean;
autoStartVisibleOverlay: boolean; autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean; autoStartPauseUntilReady: boolean;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "subminer", "name": "subminer",
"version": "0.15.0-beta.7", "version": "0.15.0-beta.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "subminer", "name": "subminer",
"version": "0.15.0-beta.7", "version": "0.15.0-beta.8",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
+3 -3
View File
@@ -2,7 +2,7 @@
"name": "subminer", "name": "subminer",
"productName": "SubMiner", "productName": "SubMiner",
"desktopName": "SubMiner.desktop", "desktopName": "SubMiner.desktop",
"version": "0.15.0-beta.7", "version": "0.15.0-beta.9",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "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:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.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-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.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/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.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: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/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.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-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.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/main/runtime/character-dictionary-manager-gate.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/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.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/log-export.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/main/character-dictionary-runtime/term-building.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/stats-window-lifecycle.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: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/stats-window-lifecycle.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/main/runtime/log-export.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
+3 -1
View File
@@ -126,7 +126,9 @@ function M.create(ctx)
subminer_log( subminer_log(
"info", "info",
"lifecycle", "lifecycle",
"Skipping auto-start: input-ipc-server does not match configured socket_path" "Skipping auto-start: input-ipc-server does not match configured socket_path ("
.. process.describe_mpv_ipc_socket_match(opts.socket_path)
.. ")"
) )
schedule_aniskip_fetch("file-loaded", 0) schedule_aniskip_fetch("file-loaded", 0)
return return
+1 -1
View File
@@ -21,7 +21,7 @@ function M.create(ctx)
end end
local function should_log(level) local function should_log(level)
local current = normalize_log_level(opts.log_level) local current = normalize_log_level(os.getenv("SUBMINER_LOG_LEVEL"))
local target = normalize_log_level(level) local target = normalize_log_level(level)
return LOG_LEVEL_PRIORITY[target] >= LOG_LEVEL_PRIORITY[current] return LOG_LEVEL_PRIORITY[target] >= LOG_LEVEL_PRIORITY[current]
end end
+21 -4
View File
@@ -172,13 +172,29 @@ function M.create(ctx)
return trimmed return trimmed
end end
local function has_matching_mpv_ipc_socket(target_socket_path) local function get_mpv_ipc_socket_match(target_socket_path)
local expected_socket = normalize_socket_path(target_socket_path or opts.socket_path) local expected_socket = normalize_socket_path(target_socket_path or opts.socket_path)
local active_socket = normalize_socket_path(mp.get_property("input-ipc-server")) local active_socket = normalize_socket_path(mp.get_property("input-ipc-server"))
if expected_socket == nil or active_socket == nil then return {
return false expected_socket = expected_socket,
active_socket = active_socket,
matching = expected_socket ~= nil and active_socket ~= nil and expected_socket == active_socket,
}
end end
return expected_socket == active_socket
local function has_matching_mpv_ipc_socket(target_socket_path)
local match = get_mpv_ipc_socket_match(target_socket_path)
return match.matching
end
local function describe_mpv_ipc_socket_match(target_socket_path)
local match = get_mpv_ipc_socket_match(target_socket_path)
local expected_socket = match.expected_socket or "<empty>"
local active_socket = match.active_socket or "<empty>"
if expected_socket == nil or active_socket == nil then
return "expected=" .. expected_socket .. "; active=" .. active_socket .. "; matching=no"
end
return "expected=" .. expected_socket .. "; active=" .. active_socket .. "; matching=" .. (match.matching and "yes" or "no")
end end
local function resolve_backend(override_backend) local function resolve_backend(override_backend)
@@ -822,6 +838,7 @@ function M.create(ctx)
return { return {
build_command_args = build_command_args, build_command_args = build_command_args,
describe_mpv_ipc_socket_match = describe_mpv_ipc_socket_match,
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket, has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
run_control_command_async = run_control_command_async, run_control_command_async = run_control_command_async,
record_visible_overlay_visibility = record_visible_overlay_visibility, record_visible_overlay_visibility = record_visible_overlay_visibility,
+34 -6
View File
@@ -182,7 +182,35 @@ function M.create(ctx)
return bindings return bindings
end end
local function build_cli_args(action_id, payload) local function normalize_cli_args(cli_args)
if type(cli_args) ~= "table" then
return nil
end
local normalized = {}
for _, arg in ipairs(cli_args) do
if type(arg) ~= "string" and type(arg) ~= "number" then
return nil
end
local value = tostring(arg)
if value == "" then
return nil
end
normalized[#normalized + 1] = value
end
if #normalized == 0 then
return nil
end
return normalized
end
local function build_cli_args(action_id, payload, artifact_cli_args)
local cli_args = normalize_cli_args(artifact_cli_args)
if cli_args then
return cli_args
end
if action_id == "toggleVisibleOverlay" then if action_id == "toggleVisibleOverlay" then
return { "--toggle-visible-overlay" } return { "--toggle-visible-overlay" }
elseif action_id == "toggleStatsOverlay" then elseif action_id == "toggleStatsOverlay" then
@@ -223,8 +251,8 @@ function M.create(ctx)
return { "--open-youtube-picker" } return { "--open-youtube-picker" }
elseif action_id == "openSessionHelp" then elseif action_id == "openSessionHelp" then
return { "--open-session-help" } return { "--open-session-help" }
elseif action_id == "openCharacterDictionary" then elseif action_id == "openCharacterDictionaryManager" then
return { "--open-character-dictionary" } return { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' }
elseif action_id == "openControllerSelect" then elseif action_id == "openControllerSelect" then
return { "--open-controller-select" } return { "--open-controller-select" }
elseif action_id == "openControllerDebug" then elseif action_id == "openControllerDebug" then
@@ -251,13 +279,13 @@ function M.create(ctx)
return nil return nil
end end
local function invoke_cli_action(action_id, payload) local function invoke_cli_action(action_id, payload, artifact_cli_args)
if not process.check_binary_available() then if not process.check_binary_available() then
show_osd("Error: binary not found") show_osd("Error: binary not found")
return return
end end
local cli_args = build_cli_args(action_id, payload) local cli_args = build_cli_args(action_id, payload, artifact_cli_args)
if not cli_args then if not cli_args then
subminer_log("warn", "session-bindings", "No CLI mapping for action: " .. tostring(action_id)) subminer_log("warn", "session-bindings", "No CLI mapping for action: " .. tostring(action_id))
return return
@@ -312,7 +340,7 @@ function M.create(ctx)
return return
end end
invoke_cli_action(binding.actionId, binding.payload) invoke_cli_action(binding.actionId, binding.payload, binding.cliArgs)
end end
local function load_artifact() local function load_artifact()
+6 -2
View File
@@ -13,13 +13,17 @@
- **Character Portraits:** Character-name subtitle matches can now show optional inline AniList character portraits. Manual AniList title overrides are scoped per media directory so separate season folders keep independent character dictionary selections. - **Character Portraits:** Character-name subtitle matches can now show optional inline AniList character portraits. Manual AniList title overrides are scoped per media directory so separate season folders keep independent character dictionary selections.
- **Log Export:** Sanitized log ZIP archives can be exported from the tray menu or by running `subminer logs -e`, with home-directory usernames redacted from the exported contents.
- **Logging Configuration:** SubMiner's logging level is now forwarded into launcher-started and Windows shortcut-started mpv sessions, controlling mpv log verbosity and plugin script logging. The new `logging.rotation` config sets daily log retention (default 7 days), and `logging.files` toggles let you enable or disable per-component log files; mpv logs are off by default unless explicitly enabled for debugging.
### Changed ### Changed
- **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`. Sidebar appearance is configured via `subtitleSidebar.css`. The default subtitle font stack is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`. Existing configs are migrated automatically. - **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`. Sidebar appearance is configured via `subtitleSidebar.css`. The default subtitle font stack is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`. Existing configs are migrated automatically.
- **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys remain accepted with deprecation warnings. N+1 highlighting is preserved for configs that already had it enabled; new configs leave it disabled unless `ankiConnect.nPlusOne.enabled` is set explicitly. - **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys remain accepted with deprecation warnings. N+1 highlighting is preserved for configs that already had it enabled; new configs leave it disabled unless `ankiConnect.nPlusOne.enabled` is set explicitly.
- **Character Dictionary:** A new `Ctrl/Cmd+D` manager modal lets you remove, reorder, or override loaded dictionary entries. The in-app AniList title selector now waits for an explicit search rather than triggering automatically; the search box is prefilled from the current filename guess so it can be edited before confirming an override. Lookup entries are scoped to generated Japanese name aliases only, so raw romanized or English aliases no longer appear as separate results. - **Character Dictionary:** A new `Ctrl/Cmd+D` manager modal lets you remove, reorder, or override loaded dictionary entries. Entries are scoped to the current AniList media and generated Japanese name aliases only, so raw romanized or English aliases no longer appear as separate results. The in-app AniList title selector now waits for an explicit search rather than triggering automatically; the search box is prefilled from the current filename guess. The manager is blocked with a notice when character dictionary annotations are disabled, and `subtitleStyle.nameMatchEnabled` is the sole switch for enabling name matching and dictionary builds.
- **Linux Updater:** Tray "Check for Updates" now installs the new AppImage automatically via `electron-updater`, matching the macOS and Windows update flow. System-package-managed AppImages (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow. - **Linux Updater:** Tray "Check for Updates" now installs the new AppImage automatically via `electron-updater`, matching the macOS and Windows update flow. System-package-managed AppImages (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow.
@@ -65,7 +69,7 @@
- **YouTube:** Primary subtitles are now downloaded to temporary local files so the primary bar and sidebar read the same source, with cleanup on reload and quit. False subtitle load failure notifications are suppressed after SubMiner confirms the selected track loaded. Launcher-managed playback commands create the tray icon even when attaching to an already-running process, and app-owned YouTube playback no longer lets the mpv plugin start a second SubMiner instance. - **YouTube:** Primary subtitles are now downloaded to temporary local files so the primary bar and sidebar read the same source, with cleanup on reload and quit. False subtitle load failure notifications are suppressed after SubMiner confirms the selected track loaded. Launcher-managed playback commands create the tray icon even when attaching to an already-running process, and app-owned YouTube playback no longer lets the mpv plugin start a second SubMiner instance.
- **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits. The visible subtitle overlay is suppressed as soon as the character dictionary modal opens, including while AniList lookup is loading or returns no results. - **Character Dictionary:** Surname honorifics are now matched for Japanese localized aliases embedded in AniList alternative names (e.g. Korean-source characters whose Japanese name appears in parentheses), and cached snapshots are regenerated to include them. Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits. The visible subtitle overlay is suppressed as soon as the character dictionary modal opens, including while AniList lookup is loading or returns no results.
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata instead of the native Electron updater; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground; builds that cannot apply native updates show a manual-install message instead of a restart prompt; Windows retains the native NSIS update path while routing updater HTTP through the main process; and macOS updater metadata mismatches from conflicting ZIP filenames are resolved. - **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata instead of the native Electron updater; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground; builds that cannot apply native updates show a manual-install message instead of a restart prompt; Windows retains the native NSIS update path while routing updater HTTP through the main process; and macOS updater metadata mismatches from conflicting ZIP filenames are resolved.
+46 -11
View File
@@ -239,11 +239,21 @@ local ctx = {
}, },
{ {
key = { key = {
code = "KeyA", code = "KeyD",
modifiers = { "alt", "meta" }, modifiers = { "ctrl" },
}, },
actionType = "session-action", actionType = "session-action",
actionId = "openCharacterDictionary", actionId = "openCharacterDictionaryManager",
cliArgs = { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' },
},
{
key = {
code = "F12",
modifiers = { "ctrl", "alt" },
},
actionType = "session-action",
actionId = "openFuturePanel",
cliArgs = { "--session-action", '{"actionId":"openFuturePanel"}' },
}, },
}, },
}, nil }, nil
@@ -357,15 +367,40 @@ local play_next_call = recorded.async_calls[#recorded.async_calls]
assert_true(play_next_call ~= nil, "play-next binding should invoke CLI action") assert_true(play_next_call ~= nil, "play-next binding should invoke CLI action")
assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag") assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag")
local character_dictionary = find_binding("Alt+Meta+a") local character_dictionary_manager = find_binding("Ctrl+d")
assert_true(character_dictionary ~= nil, "character dictionary binding should be registered")
character_dictionary.fn()
local character_dictionary_call = recorded.async_calls[#recorded.async_calls]
assert_true(character_dictionary_call ~= nil, "character dictionary binding should invoke CLI action")
assert_true( assert_true(
character_dictionary_call[2] == "--open-character-dictionary", character_dictionary_manager ~= nil,
"character dictionary binding should pass CLI flag" "character dictionary manager binding should be registered"
)
character_dictionary_manager.fn()
local character_dictionary_manager_call = recorded.async_calls[#recorded.async_calls]
assert_true(
character_dictionary_manager_call ~= nil,
"character dictionary manager binding should invoke CLI action"
)
assert_true(
character_dictionary_manager_call[2] == "--session-action",
"character dictionary manager binding should use generic session action CLI flag"
)
assert_true(
character_dictionary_manager_call[3] == '{"actionId":"openCharacterDictionaryManager"}',
"character dictionary manager binding should pass generated session action payload"
)
local future_panel = find_binding("Ctrl+Alt+F12")
assert_true(future_panel ~= nil, "artifact CLI binding should be registered without plugin mapping")
future_panel.fn()
local future_panel_call = recorded.async_calls[#recorded.async_calls]
assert_true(future_panel_call ~= nil, "artifact CLI binding should invoke CLI action")
assert_true(
future_panel_call[2] == "--session-action",
"artifact CLI binding should pass generic session action CLI flag"
)
assert_true(
future_panel_call[3] == '{"actionId":"openFuturePanel"}',
"artifact CLI binding should pass generated session action payload"
) )
starter.fn() starter.fn()
+16
View File
@@ -547,6 +547,15 @@ local function has_osd_message(messages, target)
return false return false
end end
local function has_log_containing(logs, target)
for _, message in ipairs(logs) do
if type(message) == "string" and message:find(target, 1, true) then
return true
end
end
return false
end
local function count_osd_message(messages, target) local function count_osd_message(messages, target)
local count = 0 local count = 0
for _, message in ipairs(messages) do for _, message in ipairs(messages) do
@@ -2169,6 +2178,13 @@ do
not has_property_set(recorded.property_sets, "pause", true), not has_property_set(recorded.property_sets, "pause", true),
"pause-until-ready gate should not arm when socket_path does not match" "pause-until-ready gate should not arm when socket_path does not match"
) )
assert_true(
has_log_containing(
recorded.logs,
"Skipping auto-start: input-ipc-server does not match configured socket_path (expected=/tmp/subminer-socket; active=/tmp/other.sock; matching=no)"
),
"socket mismatch log should include expected and active ipc sockets"
)
end end
do do
+3
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { AnkiConnectClient } from './anki-connect'; import { AnkiConnectClient } from './anki-connect';
import { setLogLevel } from './logger';
test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => { test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as { const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
@@ -36,6 +37,7 @@ test('AnkiConnectClient includes action name in retry logs', async () => {
const originalInfo = console.info; const originalInfo = console.info;
const messages: string[] = []; const messages: string[] = [];
setLogLevel('info');
try { try {
console.info = (...args: unknown[]) => { console.info = (...args: unknown[]) => {
messages.push(args.map((value) => String(value)).join(' ')); messages.push(args.map((value) => String(value)).join(' '));
@@ -46,6 +48,7 @@ test('AnkiConnectClient includes action name in retry logs', async () => {
assert.match(messages.join('\n'), /AnkiConnect notesInfo retry 1\/3 after 200ms delay/); assert.match(messages.join('\n'), /AnkiConnect notesInfo retry 1\/3 after 200ms delay/);
} finally { } finally {
console.info = originalInfo; console.info = originalInfo;
setLogLevel(undefined);
} }
}); });
+15
View File
@@ -105,6 +105,8 @@ test('parseArgs captures session action forwarding flags', () => {
'--shift-sub-delay-next-line', '--shift-sub-delay-next-line',
'--cycle-runtime-option', '--cycle-runtime-option',
'anki.autoUpdateNewCards:prev', 'anki.autoUpdateNewCards:prev',
'--session-action',
'{"actionId":"openCharacterDictionaryManager"}',
'--copy-subtitle-count', '--copy-subtitle-count',
'3', '3',
'--mine-sentence-count=2', '--mine-sentence-count=2',
@@ -122,6 +124,7 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(args.shiftSubDelayNextLine, true); assert.equal(args.shiftSubDelayNextLine, true);
assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards'); assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
assert.equal(args.cycleRuntimeOptionDirection, -1); assert.equal(args.cycleRuntimeOptionDirection, -1);
assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' });
assert.equal(args.copySubtitleCount, 3); assert.equal(args.copySubtitleCount, 3);
assert.equal(args.mineSentenceCount, 2); assert.equal(args.mineSentenceCount, 2);
assert.equal(hasExplicitCommand(args), true); assert.equal(hasExplicitCommand(args), true);
@@ -282,6 +285,18 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(cycleRuntimeOption), true); assert.equal(shouldStartApp(cycleRuntimeOption), true);
assert.equal(commandNeedsOverlayRuntime(cycleRuntimeOption), true); assert.equal(commandNeedsOverlayRuntime(cycleRuntimeOption), true);
const sessionAction = parseArgs([
'--session-action',
'{"actionId":"cycleRuntimeOption","payload":{"runtimeOptionId":"anki.autoUpdateNewCards","direction":-1}}',
]);
assert.deepEqual(sessionAction.sessionAction, {
actionId: 'cycleRuntimeOption',
payload: { runtimeOptionId: 'anki.autoUpdateNewCards', direction: -1 },
});
assert.equal(hasExplicitCommand(sessionAction), true);
assert.equal(shouldStartApp(sessionAction), true);
assert.equal(commandNeedsOverlayRuntime(sessionAction), true);
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']); const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true); assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
+39 -8
View File
@@ -1,3 +1,5 @@
import type { SessionActionDispatchRequest } from '../types/runtime';
export interface CliArgs { export interface CliArgs {
background: boolean; background: boolean;
managedPlayback: boolean; managedPlayback: boolean;
@@ -32,7 +34,6 @@ export interface CliArgs {
toggleSubtitleSidebar: boolean; toggleSubtitleSidebar: boolean;
openRuntimeOptions: boolean; openRuntimeOptions: boolean;
openSessionHelp: boolean; openSessionHelp: boolean;
openCharacterDictionary: boolean;
openControllerSelect: boolean; openControllerSelect: boolean;
openControllerDebug: boolean; openControllerDebug: boolean;
openJimaku: boolean; openJimaku: boolean;
@@ -44,6 +45,7 @@ export interface CliArgs {
shiftSubDelayNextLine: boolean; shiftSubDelayNextLine: boolean;
cycleRuntimeOptionId?: string; cycleRuntimeOptionId?: string;
cycleRuntimeOptionDirection?: 1 | -1; cycleRuntimeOptionDirection?: 1 | -1;
sessionAction?: SessionActionDispatchRequest;
copySubtitleCount?: number; copySubtitleCount?: number;
mineSentenceCount?: number; mineSentenceCount?: number;
anilistStatus: boolean; anilistStatus: boolean;
@@ -139,7 +141,6 @@ export function parseArgs(argv: string[]): CliArgs {
toggleSubtitleSidebar: false, toggleSubtitleSidebar: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openSessionHelp: false, openSessionHelp: false,
openCharacterDictionary: false,
openControllerSelect: false, openControllerSelect: false,
openControllerDebug: false, openControllerDebug: false,
openJimaku: false, openJimaku: false,
@@ -212,6 +213,31 @@ export function parseArgs(argv: string[]): CliArgs {
return null; return null;
}; };
const parseSessionAction = (value: string | undefined): SessionActionDispatchRequest | null => {
if (!value) return null;
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const actionId = (parsed as { actionId?: unknown }).actionId;
if (typeof actionId !== 'string' || actionId.length === 0) return null;
const payload = (parsed as { payload?: unknown }).payload;
if (
payload !== undefined &&
(!payload || typeof payload !== 'object' || Array.isArray(payload))
) {
return null;
}
return payload === undefined
? { actionId: actionId as SessionActionDispatchRequest['actionId'] }
: {
actionId: actionId as SessionActionDispatchRequest['actionId'],
payload: payload as SessionActionDispatchRequest['payload'],
};
} catch {
return null;
}
};
for (let i = 0; i < argv.length; i += 1) { for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]; const arg = argv[i];
if (!arg || !arg.startsWith('--')) continue; if (!arg || !arg.startsWith('--')) continue;
@@ -261,7 +287,6 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true; else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true;
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true; else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
else if (arg === '--open-session-help') args.openSessionHelp = true; else if (arg === '--open-session-help') args.openSessionHelp = true;
else if (arg === '--open-character-dictionary') args.openCharacterDictionary = true;
else if (arg === '--open-controller-select') args.openControllerSelect = true; else if (arg === '--open-controller-select') args.openControllerSelect = true;
else if (arg === '--open-controller-debug') args.openControllerDebug = true; else if (arg === '--open-controller-debug') args.openControllerDebug = true;
else if (arg === '--open-jimaku') args.openJimaku = true; else if (arg === '--open-jimaku') args.openJimaku = true;
@@ -283,6 +308,12 @@ export function parseArgs(argv: string[]): CliArgs {
args.cycleRuntimeOptionId = parsed.id; args.cycleRuntimeOptionId = parsed.id;
args.cycleRuntimeOptionDirection = parsed.direction; args.cycleRuntimeOptionDirection = parsed.direction;
} }
} else if (arg.startsWith('--session-action=')) {
const parsed = parseSessionAction(arg.slice('--session-action='.length));
if (parsed) args.sessionAction = parsed;
} else if (arg === '--session-action') {
const parsed = parseSessionAction(readValue(argv[i + 1]));
if (parsed) args.sessionAction = parsed;
} else if (arg.startsWith('--copy-subtitle-count=')) { } else if (arg.startsWith('--copy-subtitle-count=')) {
const value = Number(arg.split('=', 2)[1]); const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value; if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
@@ -516,7 +547,6 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.toggleSubtitleSidebar || args.toggleSubtitleSidebar ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.openSessionHelp || args.openSessionHelp ||
args.openCharacterDictionary ||
args.openControllerSelect || args.openControllerSelect ||
args.openControllerDebug || args.openControllerDebug ||
args.openJimaku || args.openJimaku ||
@@ -527,6 +557,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.shiftSubDelayPrevLine || args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine || args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined || args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined || args.mineSentenceCount !== undefined ||
args.anilistStatus || args.anilistStatus ||
@@ -591,7 +622,6 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.toggleSubtitleSidebar && !args.toggleSubtitleSidebar &&
!args.openRuntimeOptions && !args.openRuntimeOptions &&
!args.openSessionHelp && !args.openSessionHelp &&
!args.openCharacterDictionary &&
!args.openControllerSelect && !args.openControllerSelect &&
!args.openControllerDebug && !args.openControllerDebug &&
!args.openJimaku && !args.openJimaku &&
@@ -602,6 +632,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.shiftSubDelayPrevLine && !args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine && !args.shiftSubDelayNextLine &&
args.cycleRuntimeOptionId === undefined && args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined &&
args.copySubtitleCount === undefined && args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined && args.mineSentenceCount === undefined &&
!args.anilistStatus && !args.anilistStatus &&
@@ -657,7 +688,6 @@ export function shouldStartApp(args: CliArgs): boolean {
args.toggleSubtitleSidebar || args.toggleSubtitleSidebar ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.openSessionHelp || args.openSessionHelp ||
args.openCharacterDictionary ||
args.openControllerSelect || args.openControllerSelect ||
args.openControllerDebug || args.openControllerDebug ||
args.openJimaku || args.openJimaku ||
@@ -668,6 +698,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.shiftSubDelayPrevLine || args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine || args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined || args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined || args.mineSentenceCount !== undefined ||
args.dictionary || args.dictionary ||
@@ -717,7 +748,6 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.toggleSubtitleSidebar && !args.toggleSubtitleSidebar &&
!args.openRuntimeOptions && !args.openRuntimeOptions &&
!args.openSessionHelp && !args.openSessionHelp &&
!args.openCharacterDictionary &&
!args.openControllerSelect && !args.openControllerSelect &&
!args.openControllerDebug && !args.openControllerDebug &&
!args.openJimaku && !args.openJimaku &&
@@ -728,6 +758,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.shiftSubDelayPrevLine && !args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine && !args.shiftSubDelayNextLine &&
args.cycleRuntimeOptionId === undefined && args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined &&
args.copySubtitleCount === undefined && args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined && args.mineSentenceCount === undefined &&
!args.anilistStatus && !args.anilistStatus &&
@@ -782,7 +813,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.markAudioCard || args.markAudioCard ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.openSessionHelp || args.openSessionHelp ||
args.openCharacterDictionary ||
args.openControllerSelect || args.openControllerSelect ||
args.openControllerDebug || args.openControllerDebug ||
args.openJimaku || args.openJimaku ||
@@ -793,6 +823,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.shiftSubDelayPrevLine || args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine || args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined || args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined args.mineSentenceCount !== undefined
); );
-1
View File
@@ -43,7 +43,6 @@ ${B}Mining${R}
--toggle-subtitle-sidebar Toggle subtitle sidebar panel --toggle-subtitle-sidebar Toggle subtitle sidebar panel
--open-runtime-options Open runtime options palette --open-runtime-options Open runtime options palette
--open-session-help Open session help modal --open-session-help Open session help modal
--open-character-dictionary Open character dictionary management modal
--open-controller-select Open controller select modal --open-controller-select Open controller select modal
--open-controller-debug Open controller debug modal --open-controller-debug Open controller debug modal
+110 -6
View File
@@ -63,7 +63,6 @@ test('loads defaults when config is missing', () => {
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']); assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
assert.equal(config.ankiConnect.media.audioPadding, 0); assert.equal(config.ankiConnect.media.audioPadding, 0);
assert.equal(config.anilist.enabled, false); assert.equal(config.anilist.enabled, false);
assert.equal(config.anilist.characterDictionary.enabled, false);
assert.equal(config.subtitleStyle.nameMatchImagesEnabled, false); assert.equal(config.subtitleStyle.nameMatchImagesEnabled, false);
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168); assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
assert.equal(config.anilist.characterDictionary.maxLoaded, 3); assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
@@ -96,7 +95,6 @@ test('loads defaults when config is missing', () => {
assert.equal(config.startupWarmups.subtitleDictionaries, true); assert.equal(config.startupWarmups.subtitleDictionaries, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, false); assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A'); assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
assert.equal('openCharacterDictionary' in config.shortcuts, false);
assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D'); assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D');
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash'); assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
assert.equal(config.discordPresence.enabled, true); assert.equal(config.discordPresence.enabled, true);
@@ -825,7 +823,6 @@ test('parses anilist.characterDictionary config with clamping and enum validatio
const config = service.getConfig(); const config = service.getConfig();
const warnings = service.getWarnings(); const warnings = service.getWarnings();
assert.equal(config.anilist.characterDictionary.enabled, true);
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 1); assert.equal(config.anilist.characterDictionary.refreshTtlHours, 1);
assert.equal(config.anilist.characterDictionary.maxLoaded, 20); assert.equal(config.anilist.characterDictionary.maxLoaded, 20);
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete'); assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
@@ -1462,6 +1459,50 @@ test('accepts valid logging.level', () => {
assert.equal(config.logging.level, 'warn'); assert.equal(config.logging.level, 'warn');
}); });
test('accepts valid logging.rotation', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"rotation": 14
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.logging.rotation, 14);
});
test('accepts valid logging file toggles', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"files": {
"app": false,
"launcher": true,
"mpv": true
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.logging.files, {
app: false,
launcher: true,
mpv: true,
});
});
test('falls back for invalid logging.level and reports warning', () => { test('falls back for invalid logging.level and reports warning', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
@@ -1482,6 +1523,68 @@ test('falls back for invalid logging.level and reports warning', () => {
assert.ok(warnings.some((warning) => warning.path === 'logging.level')); assert.ok(warnings.some((warning) => warning.path === 'logging.level'));
}); });
test('falls back for invalid logging.rotation and reports warning', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"rotation": 0
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.logging.rotation, DEFAULT_CONFIG.logging.rotation);
assert.ok(warnings.some((warning) => warning.path === 'logging.rotation'));
});
test('falls back for invalid logging file toggles and reports warning', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"files": {
"mpv": "yes"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.logging.files.mpv, DEFAULT_CONFIG.logging.files.mpv);
assert.ok(warnings.some((warning) => warning.path === 'logging.files.mpv'));
});
test('falls back for invalid logging files object and reports warning', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"files": false
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.deepEqual(config.logging.files, DEFAULT_CONFIG.logging.files);
assert.ok(warnings.some((warning) => warning.path === 'logging.files'));
});
test('warns and ignores unknown top-level config keys', () => { test('warns and ignores unknown top-level config keys', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
@@ -2138,7 +2241,7 @@ test('resolves legacy ankiConnect nPlusOne known-word settings without rewriting
const warnings = service.getWarnings(); const warnings = service.getWarnings();
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true); assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.enabled, true); assert.equal(config.ankiConnect.nPlusOne.enabled, DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90); assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface'); assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.deepEqual(config.ankiConnect.knownWords.decks, { assert.deepEqual(config.ankiConnect.knownWords.decks, {
@@ -2197,7 +2300,7 @@ test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
const warnings = service.getWarnings(); const warnings = service.getWarnings();
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true); assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.enabled, true); assert.equal(config.ankiConnect.nPlusOne.enabled, DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90); assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface'); assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.ok( assert.ok(
@@ -2518,6 +2621,7 @@ test('template generator includes known keys', () => {
assert.doesNotMatch(output, /"clientVersion":/); assert.doesNotMatch(output, /"clientVersion":/);
assert.doesNotMatch(output, /"youtubeSubgen":/); assert.doesNotMatch(output, /"youtubeSubgen":/);
assert.match(output, /"characterDictionary":\s*\{/); assert.match(output, /"characterDictionary":\s*\{/);
assert.doesNotMatch(output, /"characterDictionary":\s*\{\s*"enabled":/);
assert.match(output, /"preserveLineBreaks": false/); assert.match(output, /"preserveLineBreaks": false/);
assert.match(output, /"knownWords"\s*:\s*\{/); assert.match(output, /"knownWords"\s*:\s*\{/);
assert.match(output, /"knownWordColor": "#a6da95"/); assert.match(output, /"knownWordColor": "#a6da95"/);
@@ -2527,7 +2631,7 @@ test('template generator includes known keys', () => {
assert.match(output, /auto-generated from src\/config\/definitions.ts/); assert.match(output, /auto-generated from src\/config\/definitions.ts/);
assert.match( assert.match(
output, output,
/"level": "info",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/, /"level": "warn",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/,
); );
assert.match( assert.match(
output, output,
+7 -1
View File
@@ -28,7 +28,13 @@ export const CORE_DEFAULT_CONFIG: Pick<
port: 6678, port: 6678,
}, },
logging: { logging: {
level: 'info', level: 'warn',
rotation: 7,
files: {
app: true,
launcher: true,
mpv: false,
},
}, },
texthooker: { texthooker: {
launchAtStartup: false, launchAtStartup: false,
@@ -110,7 +110,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
enabled: false, enabled: false,
accessToken: '', accessToken: '',
characterDictionary: { characterDictionary: {
enabled: false,
refreshTtlHours: 168, refreshTtlHours: 168,
maxLoaded: 3, maxLoaded: 3,
evictionPolicy: 'delete', evictionPolicy: 'delete',
+12 -4
View File
@@ -35,9 +35,7 @@ function collectConfigLeafPaths(config: ResolvedConfig): string[] {
} }
// DEFAULT_CONFIG leaves that intentionally do not have a curated // DEFAULT_CONFIG leaves that intentionally do not have a curated
// CONFIG_OPTION_REGISTRY entry. The generated config.example.jsonc still // CONFIG_OPTION_REGISTRY entry.
// includes these paths, but their inline comments fall back to an auto-
// humanized key name instead of a written description.
// //
// Current intentional gaps: // Current intentional gaps:
// - subtitleStyle.*: thin wrappers around standard CSS properties; the // - subtitleStyle.*: thin wrappers around standard CSS properties; the
@@ -49,6 +47,8 @@ function collectConfigLeafPaths(config: ResolvedConfig): string[] {
// an allowlist entry. Only allowlist a path when the registry is genuinely // an allowlist entry. Only allowlist a path when the registry is genuinely
// the wrong surface for it. // the wrong surface for it.
const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
'anilist.characterDictionary.evictionPolicy',
'anilist.characterDictionary.refreshTtlHours',
'keybindings', 'keybindings',
'subtitleStyle.backdropFilter', 'subtitleStyle.backdropFilter',
'subtitleStyle.backgroundColor', 'subtitleStyle.backgroundColor',
@@ -85,6 +85,13 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
'subtitleStyle.textShadow', 'subtitleStyle.textShadow',
'subtitleStyle.WebkitTextStroke', 'subtitleStyle.WebkitTextStroke',
'subtitleStyle.wordSpacing', 'subtitleStyle.wordSpacing',
'youtubeSubgen.ai.model',
'youtubeSubgen.ai.systemPrompt',
'youtubeSubgen.fixWithAi',
'youtubeSubgen.whisperBin',
'youtubeSubgen.whisperModel',
'youtubeSubgen.whisperThreads',
'youtubeSubgen.whisperVadModel',
]); ]);
test('config option registry includes critical paths and has unique entries', () => { test('config option registry includes critical paths and has unique entries', () => {
@@ -92,6 +99,7 @@ test('config option registry includes critical paths and has unique entries', ()
for (const requiredPath of [ for (const requiredPath of [
'logging.level', 'logging.level',
'logging.files.mpv',
'annotationWebsocket.enabled', 'annotationWebsocket.enabled',
'controller.enabled', 'controller.enabled',
'controller.scrollPixelsPerSecond', 'controller.scrollPixelsPerSecond',
@@ -101,7 +109,7 @@ test('config option registry includes critical paths and has unique entries', ()
'subtitleStyle.enableJlpt', 'subtitleStyle.enableJlpt',
'subtitleStyle.autoPauseVideoOnYomitanPopup', 'subtitleStyle.autoPauseVideoOnYomitanPopup',
'ankiConnect.enabled', 'ankiConnect.enabled',
'anilist.characterDictionary.enabled', 'subtitleStyle.nameMatchEnabled',
'anilist.characterDictionary.collapsibleSections.description', 'anilist.characterDictionary.collapsibleSections.description',
'mpv.executablePath', 'mpv.executablePath',
'mpv.launchMode', 'mpv.launchMode',
+25 -1
View File
@@ -83,6 +83,30 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.logging.level, defaultValue: defaultConfig.logging.level,
description: 'Minimum log level for runtime logging.', description: 'Minimum log level for runtime logging.',
}, },
{
path: 'logging.rotation',
kind: 'number',
defaultValue: defaultConfig.logging.rotation,
description: 'Number of days of app, launcher, and mpv logs to retain.',
},
{
path: 'logging.files.app',
kind: 'boolean',
defaultValue: defaultConfig.logging.files.app,
description: 'Write SubMiner app runtime logs.',
},
{
path: 'logging.files.launcher',
kind: 'boolean',
defaultValue: defaultConfig.logging.files.launcher,
description: 'Write launcher command logs.',
},
{
path: 'logging.files.mpv',
kind: 'boolean',
defaultValue: defaultConfig.logging.files.mpv,
description: 'Write mpv player logs. Enable temporarily when debugging mpv/plugin startup.',
},
{ {
path: 'youtube.primarySubLanguages', path: 'youtube.primarySubLanguages',
kind: 'string', kind: 'string',
@@ -164,7 +188,7 @@ export function buildCoreConfigOptionRegistry(
kind: 'object', kind: 'object',
defaultValue: defaultConfig.controller.buttonIndices, defaultValue: defaultConfig.controller.buttonIndices,
description: description:
'Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.', 'Semantic button-name reference mapping used for debug output. Updating it does not rewrite existing raw binding descriptors.',
}, },
{ {
path: 'controller.buttonIndices.select', path: 'controller.buttonIndices.select',
+2 -72
View File
@@ -304,7 +304,7 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'object', kind: 'object',
defaultValue: defaultConfig.ankiConnect.knownWords.decks, defaultValue: defaultConfig.ankiConnect.knownWords.decks,
description: description:
'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.', 'Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.',
}, },
{ {
path: 'ankiConnect.isKiku.fieldGrouping', path: 'ankiConnect.isKiku.fieldGrouping',
@@ -392,20 +392,6 @@ export function buildIntegrationConfigOptionRegistry(
description: description:
'Optional explicit AniList access token override; leave empty to use locally stored token from setup.', 'Optional explicit AniList access token override; leave empty to use locally stored token from setup.',
}, },
{
path: 'anilist.characterDictionary.enabled',
kind: 'boolean',
defaultValue: defaultConfig.anilist.characterDictionary.enabled,
description:
'Enable automatic Yomitan character dictionary sync for currently watched AniList media.',
},
{
path: 'anilist.characterDictionary.refreshTtlHours',
kind: 'number',
defaultValue: defaultConfig.anilist.characterDictionary.refreshTtlHours,
description:
'Legacy setting; merged character dictionary retention is now usage-based and this value is ignored.',
},
{ {
path: 'anilist.characterDictionary.maxLoaded', path: 'anilist.characterDictionary.maxLoaded',
kind: 'number', kind: 'number',
@@ -413,20 +399,12 @@ export function buildIntegrationConfigOptionRegistry(
description: description:
'Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary.', 'Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary.',
}, },
{
path: 'anilist.characterDictionary.evictionPolicy',
kind: 'enum',
enumValues: ['disable', 'delete'],
defaultValue: defaultConfig.anilist.characterDictionary.evictionPolicy,
description:
'Legacy setting; merged character dictionary eviction is usage-based and this value is ignored.',
},
{ {
path: 'anilist.characterDictionary.profileScope', path: 'anilist.characterDictionary.profileScope',
kind: 'enum', kind: 'enum',
enumValues: ['all', 'active'], enumValues: ['all', 'active'],
defaultValue: defaultConfig.anilist.characterDictionary.profileScope, defaultValue: defaultConfig.anilist.characterDictionary.profileScope,
description: 'Yomitan profile scope for dictionary enable/disable updates.', description: 'Yomitan profile scope for character dictionary settings updates.',
}, },
{ {
path: 'anilist.characterDictionary.collapsibleSections.description', path: 'anilist.characterDictionary.collapsibleSections.description',
@@ -672,53 +650,5 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ai.requestTimeoutMs, defaultValue: defaultConfig.ai.requestTimeoutMs,
description: 'Timeout in milliseconds for shared AI provider requests.', description: 'Timeout in milliseconds for shared AI provider requests.',
}, },
{
path: 'youtubeSubgen.whisperBin',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.whisperBin,
description:
'Legacy compatibility path kept for external subtitle fallback tools; not used by default.',
},
{
path: 'youtubeSubgen.whisperModel',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.whisperModel,
description:
'Legacy compatibility model path kept for external subtitle fallback tooling; not used by default.',
},
{
path: 'youtubeSubgen.whisperVadModel',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.whisperVadModel,
description:
'Legacy compatibility VAD path kept for external subtitle fallback tooling; not used by default.',
},
{
path: 'youtubeSubgen.whisperThreads',
kind: 'number',
defaultValue: defaultConfig.youtubeSubgen.whisperThreads,
description: 'Legacy thread tuning for subtitle fallback tooling; not used by default.',
},
{
path: 'youtubeSubgen.fixWithAi',
kind: 'boolean',
defaultValue: defaultConfig.youtubeSubgen.fixWithAi,
description:
'Legacy subtitle fallback post-processing switch kept for compatibility; use is currently disabled by default.',
},
{
path: 'youtubeSubgen.ai.model',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.ai.model,
description:
'Optional model override for legacy subtitle fallback post-processing; not used by default.',
},
{
path: 'youtubeSubgen.ai.systemPrompt',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.ai.systemPrompt,
description:
'Optional system prompt override for legacy subtitle fallback post-processing; not used by default.',
},
]; ];
} }
+1 -1
View File
@@ -74,7 +74,7 @@ export function buildSubtitleConfigOptionRegistry(
kind: 'boolean', kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.nameMatchEnabled, defaultValue: defaultConfig.subtitleStyle.nameMatchEnabled,
description: description:
'Enable subtitle token coloring for matches from the SubMiner character dictionary.', 'Enable character dictionary sync and subtitle token coloring for character-name matches.',
}, },
{ {
path: 'subtitleStyle.nameMatchImagesEnabled', path: 'subtitleStyle.nameMatchImagesEnabled',
+1 -1
View File
@@ -33,7 +33,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{ {
title: 'Logging', title: 'Logging',
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'], description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
notes: ['Hot-reload: logging.level applies live while SubMiner is running.'], notes: ['Hot-reload: logging.level and logging.files apply live while SubMiner is running.'],
key: 'logging', key: 'logging',
}, },
{ {
+3 -3
View File
@@ -84,7 +84,7 @@ test('accepts knownWords.addMinedWordsImmediately boolean override', () => {
); );
}); });
test('enables n+1 for existing configs with known-word highlighting enabled', () => { test('knownWords.highlightEnabled does not implicitly enable nPlusOne', () => {
const { context } = makeContext({ const { context } = makeContext({
knownWords: { highlightEnabled: true }, knownWords: { highlightEnabled: true },
}); });
@@ -92,10 +92,10 @@ test('enables n+1 for existing configs with known-word highlighting enabled', ()
applyAnkiConnectResolution(context); applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true); assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, true); assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled);
}); });
test('keeps explicit n+1 disabled when known-word highlighting is enabled', () => { test('explicit nPlusOne.enabled is respected regardless of highlightEnabled', () => {
const { context } = makeContext({ const { context } = makeContext({
knownWords: { highlightEnabled: true }, knownWords: { highlightEnabled: true },
nPlusOne: { enabled: false }, nPlusOne: { enabled: false },
-2
View File
@@ -758,8 +758,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
'Expected boolean.', 'Expected boolean.',
); );
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled; context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
} else if (context.resolved.ankiConnect.knownWords.highlightEnabled === true) {
context.resolved.ankiConnect.nPlusOne.enabled = true;
} else { } else {
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled; context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
} }
+30
View File
@@ -100,6 +100,36 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'Expected debug, info, warn, or error.', 'Expected debug, info, warn, or error.',
); );
} }
const logRotation = src.logging.rotation;
if (typeof logRotation === 'number' && Number.isInteger(logRotation) && logRotation > 0) {
resolved.logging.rotation = logRotation;
} else if (src.logging.rotation !== undefined) {
warn(
'logging.rotation',
src.logging.rotation,
resolved.logging.rotation,
'Expected a positive whole number of days.',
);
}
if (isObject(src.logging.files)) {
for (const key of ['app', 'launcher', 'mpv'] as const) {
const enabled = asBoolean(src.logging.files[key]);
if (enabled !== undefined) {
resolved.logging.files[key] = enabled;
} else if (src.logging.files[key] !== undefined) {
warn(
`logging.files.${key}`,
src.logging.files[key],
resolved.logging.files[key],
'Expected boolean.',
);
}
}
} else if (src.logging.files !== undefined) {
warn('logging.files', src.logging.files, resolved.logging.files, 'Expected object.');
}
} }
applyControllerConfig(context); applyControllerConfig(context);
-12
View File
@@ -81,18 +81,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
if (isObject(src.anilist.characterDictionary)) { if (isObject(src.anilist.characterDictionary)) {
const characterDictionary = src.anilist.characterDictionary; const characterDictionary = src.anilist.characterDictionary;
const dictionaryEnabled = asBoolean(characterDictionary.enabled);
if (dictionaryEnabled !== undefined) {
resolved.anilist.characterDictionary.enabled = dictionaryEnabled;
} else if (characterDictionary.enabled !== undefined) {
warn(
'anilist.characterDictionary.enabled',
characterDictionary.enabled,
resolved.anilist.characterDictionary.enabled,
'Expected boolean.',
);
}
const refreshTtlHours = asNumber(characterDictionary.refreshTtlHours); const refreshTtlHours = asNumber(characterDictionary.refreshTtlHours);
if (refreshTtlHours !== undefined) { if (refreshTtlHours !== undefined) {
const normalized = Math.min(24 * 365, Math.max(1, Math.floor(refreshTtlHours))); const normalized = Math.min(24 * 365, Math.max(1, Math.floor(refreshTtlHours)));
-2
View File
@@ -97,7 +97,6 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate
const { context, warnings } = createResolveContext({ const { context, warnings } = createResolveContext({
anilist: { anilist: {
characterDictionary: { characterDictionary: {
enabled: true,
refreshTtlHours: 0, refreshTtlHours: 0,
maxLoaded: 99, maxLoaded: 99,
evictionPolicy: 'purge' as never, evictionPolicy: 'purge' as never,
@@ -113,7 +112,6 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate
applyIntegrationConfig(context); applyIntegrationConfig(context);
assert.equal(context.resolved.anilist.characterDictionary.enabled, true);
assert.equal(context.resolved.anilist.characterDictionary.refreshTtlHours, 1); assert.equal(context.resolved.anilist.characterDictionary.refreshTtlHours, 1);
assert.equal(context.resolved.anilist.characterDictionary.maxLoaded, 20); assert.equal(context.resolved.anilist.characterDictionary.maxLoaded, 20);
assert.equal(context.resolved.anilist.characterDictionary.evictionPolicy, 'delete'); assert.equal(context.resolved.anilist.characterDictionary.evictionPolicy, 'delete');
+5 -6
View File
@@ -55,10 +55,6 @@ test('settings registry moves AniSkip button key into input shortcuts and hot re
}); });
test('settings registry exposes character dictionary panel shortcuts dynamically', () => { test('settings registry exposes character dictionary panel shortcuts dynamically', () => {
assert.equal(
fields.some((candidate) => candidate.configPath === 'shortcuts.openCharacterDictionary'),
false,
);
assert.equal( assert.equal(
field('shortcuts.openCharacterDictionaryManager').label, field('shortcuts.openCharacterDictionaryManager').label,
'Open Character Dictionary Manager', 'Open Character Dictionary Manager',
@@ -69,7 +65,6 @@ test('settings registry exposes character dictionary panel shortcuts dynamically
test('settings registry hides removed modal-only fields', () => { test('settings registry hides removed modal-only fields', () => {
for (const path of [ for (const path of [
'shortcuts.multiCopyTimeoutMs', 'shortcuts.multiCopyTimeoutMs',
'shortcuts.openCharacterDictionary',
'anilist.characterDictionary.profileScope', 'anilist.characterDictionary.profileScope',
'jellyfin.directPlayContainers', 'jellyfin.directPlayContainers',
]) { ]) {
@@ -265,7 +260,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
]) { ]) {
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`); assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
} }
assert.equal(field('anilist.characterDictionary.enabled').section, 'Character Dictionary'); assert.equal(paths.has('anilist.characterDictionary.enabled'), false);
}); });
test('settings registry marks safe live config paths as hot-reloadable', () => { test('settings registry marks safe live config paths as hot-reloadable', () => {
@@ -274,6 +269,10 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
'stats.toggleKey', 'stats.toggleKey',
'stats.markWatchedKey', 'stats.markWatchedKey',
'logging.level', 'logging.level',
'logging.rotation',
'logging.files.app',
'logging.files.launcher',
'logging.files.mpv',
'youtube.primarySubLanguages', 'youtube.primarySubLanguages',
'jimaku.apiBaseUrl', 'jimaku.apiBaseUrl',
'jimaku.languagePreference', 'jimaku.languagePreference',
+8
View File
@@ -63,6 +63,7 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'controller.preferredGamepadLabel', 'controller.preferredGamepadLabel',
'controller.profiles', 'controller.profiles',
'youtubeSubgen.primarySubLanguages', 'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.enabled',
'anilist.characterDictionary.refreshTtlHours', 'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy', 'anilist.characterDictionary.evictionPolicy',
'anilist.characterDictionary.profileScope', 'anilist.characterDictionary.profileScope',
@@ -184,6 +185,11 @@ const PATH_ORDER = new Map<string, number>(
'mpv.launchMode', 'mpv.launchMode',
'mpv.executablePath', 'mpv.executablePath',
'mpv.aniskipButtonKey', 'mpv.aniskipButtonKey',
'logging.level',
'logging.rotation',
'logging.files.app',
'logging.files.launcher',
'logging.files.mpv',
].map((path, index) => [path, index]), ].map((path, index) => [path, index]),
); );
@@ -667,6 +673,8 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
path === 'stats.toggleKey' || path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey' || path === 'stats.markWatchedKey' ||
path === 'logging.level' || path === 'logging.level' ||
path === 'logging.rotation' ||
pathStartsWith(path, 'logging.files') ||
path === 'youtube.primarySubLanguages' || path === 'youtube.primarySubLanguages' ||
pathStartsWith(path, 'jimaku') || pathStartsWith(path, 'jimaku') ||
pathStartsWith(path, 'subsync') pathStartsWith(path, 'subsync')
+14
View File
@@ -18,6 +18,17 @@ const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map(
CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']), CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']),
); );
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar']; const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
const HIDDEN_TEMPLATE_PATHS = [
'anilist.characterDictionary.evictionPolicy',
'anilist.characterDictionary.refreshTtlHours',
'youtubeSubgen.ai.model',
'youtubeSubgen.ai.systemPrompt',
'youtubeSubgen.fixWithAi',
'youtubeSubgen.whisperBin',
'youtubeSubgen.whisperModel',
'youtubeSubgen.whisperThreads',
'youtubeSubgen.whisperVadModel',
];
function normalizeCommentText(value: string): string { function normalizeCommentText(value: string): string {
return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim(); return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim();
@@ -172,6 +183,9 @@ function renderSection(
function createTemplateConfig(config: ResolvedConfig): ResolvedConfig { function createTemplateConfig(config: ResolvedConfig): ResolvedConfig {
const templateConfig = deepCloneConfig(config); const templateConfig = deepCloneConfig(config);
foldSubtitleCssManagedDefaults(templateConfig); foldSubtitleCssManagedDefaults(templateConfig);
for (const hiddenPath of HIDDEN_TEMPLATE_PATHS) {
deleteValueAtPath(templateConfig, hiddenPath);
}
if (templateConfig.keybindings.length === 0) { if (templateConfig.keybindings.length === 0) {
templateConfig.keybindings = DEFAULT_KEYBINDINGS.map((binding) => ({ templateConfig.keybindings = DEFAULT_KEYBINDINGS.map((binding) => ({
key: binding.key, key: binding.key,
@@ -71,7 +71,7 @@ test('anilist update queue applies retry backoff and dead-letter', () => {
assert.equal((pendingPayload.pending[0]?.nextAttemptAt ?? now) - now, 30_000); assert.equal((pendingPayload.pending[0]?.nextAttemptAt ?? now) - now, 30_000);
for (let attempt = 2; attempt <= 8; attempt += 1) { for (let attempt = 2; attempt <= 8; attempt += 1) {
queue.markFailure('k2', `fail-${attempt}`, now); queue.markFailure('k2', `fail-${attempt}`, now + attempt * 6 * 60 * 60 * 1000);
} }
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER); const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
@@ -83,6 +83,52 @@ test('anilist update queue applies retry backoff and dead-letter', () => {
); );
}); });
test('anilist update queue ignores duplicate failures while retry is cooling down', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
const now = 1_700_000 * 1_000_000;
queue.enqueue('k2', 'Backoff Demo', 2);
queue.markFailure('k2', 'fail-1', now);
for (let attempt = 2; attempt <= 12; attempt += 1) {
queue.markFailure('k2', `duplicate-${attempt}`, now + attempt);
}
const payload = JSON.parse(fs.readFileSync(queueFile, 'utf-8')) as {
pending: Array<{ attemptCount: number; lastError: string | null }>;
deadLetter: Array<unknown>;
};
assert.equal(payload.pending[0]?.attemptCount, 1);
assert.equal(payload.pending[0]?.lastError, 'fail-1');
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
pending: 1,
ready: 1,
deadLetter: 0,
});
});
test('anilist update queue does not re-enqueue dead-lettered keys', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
const now = 1_700_000 * 1_000_000;
queue.enqueue('k4', 'Dead Letter Demo', 4);
for (let attempt = 1; attempt <= 8; attempt += 1) {
queue.markFailure('k4', `fail-${attempt}`, now + attempt * 6 * 60 * 60 * 1000);
}
queue.enqueue('k4', 'Dead Letter Demo', 4);
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
pending: 0,
ready: 0,
deadLetter: 1,
});
});
test('anilist update queue persists and reloads from disk', () => { test('anilist update queue persists and reloads from disk', () => {
const queueFile = createTempQueueFile(); const queueFile = createTempQueueFile();
const loggerState = createLogger(); const loggerState = createLogger();
@@ -108,7 +108,8 @@ export function createAnilistUpdateQueue(
return { return {
enqueue(key: string, title: string, episode: number, season: number | null = null): void { enqueue(key: string, title: string, episode: number, season: number | null = null): void {
const existing = pending.find((item) => item.key === key); const existing =
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
if (existing) { if (existing) {
return; return;
} }
@@ -147,6 +148,9 @@ export function createAnilistUpdateQueue(
if (!item) { if (!item) {
return; return;
} }
if (item.attemptCount > 0 && item.nextAttemptAt > nowMs) {
return;
}
item.attemptCount += 1; item.attemptCount += 1;
item.lastError = reason; item.lastError = reason;
if (item.attemptCount >= MAX_ATTEMPTS) { if (item.attemptCount >= MAX_ATTEMPTS) {
-1
View File
@@ -41,7 +41,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openJimaku: false, openJimaku: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,
openCharacterDictionary: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false, shiftSubDelayPrevLine: false,
+24 -17
View File
@@ -41,7 +41,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
refreshKnownWords: false, refreshKnownWords: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openSessionHelp: false, openSessionHelp: false,
openCharacterDictionary: false,
openControllerSelect: false, openControllerSelect: false,
openControllerDebug: false, openControllerDebug: false,
openJimaku: false, openJimaku: false,
@@ -785,6 +784,30 @@ test('handleCliCommand dispatches cycle-runtime-option session action', async ()
}); });
}); });
test('handleCliCommand dispatches generic session action payloads', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(
makeArgs({
sessionAction: {
actionId: 'openCharacterDictionaryManager',
},
}),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'openCharacterDictionaryManager',
});
});
test('handleCliCommand dispatches mark-watched session action', async () => { test('handleCliCommand dispatches mark-watched session action', async () => {
let request: unknown = null; let request: unknown = null;
const { deps } = createDeps({ const { deps } = createDeps({
@@ -801,22 +824,6 @@ test('handleCliCommand dispatches mark-watched session action', async () => {
}); });
}); });
test('handleCliCommand opens character dictionary manager from CLI flag', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(makeArgs({ openCharacterDictionary: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'openCharacterDictionaryManager',
});
});
test('handleCliCommand logs AniList status details', () => { test('handleCliCommand logs AniList status details', () => {
const { deps, calls } = createDeps(); const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps); handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
+7 -7
View File
@@ -384,7 +384,13 @@ export function handleCliCommand(
deps.log(`Starting MPV IPC connection on socket: ${socketPath}`); deps.log(`Starting MPV IPC connection on socket: ${socketPath}`);
} }
if (args.toggle || args.toggleVisibleOverlay) { if (args.sessionAction) {
dispatchCliSessionAction(
args.sessionAction,
`sessionAction:${args.sessionAction.actionId}`,
'Session action failed',
);
} else if (args.toggle || args.toggleVisibleOverlay) {
deps.toggleVisibleOverlay(); deps.toggleVisibleOverlay();
} else if (args.togglePrimarySubtitleBar) { } else if (args.togglePrimarySubtitleBar) {
deps.togglePrimarySubtitleBar(); deps.togglePrimarySubtitleBar();
@@ -490,12 +496,6 @@ export function handleCliCommand(
'openSessionHelp', 'openSessionHelp',
'Open session help failed', 'Open session help failed',
); );
} else if (args.openCharacterDictionary) {
dispatchCliSessionAction(
{ actionId: 'openCharacterDictionaryManager' },
'openCharacterDictionaryManager',
'Open character dictionary failed',
);
} else if (args.openControllerSelect) { } else if (args.openControllerSelect) {
dispatchCliSessionAction( dispatchCliSessionAction(
{ actionId: 'openControllerSelect' }, { actionId: 'openControllerSelect' },
@@ -25,6 +25,8 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
next.stats.toggleKey = 'F8'; next.stats.toggleKey = 'F8';
next.stats.markWatchedKey = 'F9'; next.stats.markWatchedKey = 'F9';
next.logging.level = 'debug'; next.logging.level = 'debug';
next.logging.rotation = 14;
next.logging.files.mpv = true;
next.youtube.primarySubLanguages = ['ja', 'en']; next.youtube.primarySubLanguages = ['ja', 'en'];
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1; next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
next.subsync.replace = !prev.subsync.replace; next.subsync.replace = !prev.subsync.replace;
@@ -56,6 +58,8 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
'mpv.aniskipButtonKey', 'mpv.aniskipButtonKey',
'stats.markWatchedKey', 'stats.markWatchedKey',
'logging.level', 'logging.level',
'logging.rotation',
'logging.files',
'youtube.primarySubLanguages', 'youtube.primarySubLanguages',
'jimaku.maxEntryResults', 'jimaku.maxEntryResults',
'subsync.replace', 'subsync.replace',
+2
View File
@@ -61,6 +61,8 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'stats.toggleKey', 'stats.toggleKey',
'stats.markWatchedKey', 'stats.markWatchedKey',
'logging.level', 'logging.level',
'logging.rotation',
'logging.files',
'youtube.primarySubLanguages', 'youtube.primarySubLanguages',
'jimaku', 'jimaku',
'subsync', 'subsync',
+76
View File
@@ -23,6 +23,37 @@ function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClie
}; };
} }
function captureWarnLogs(run: () => void): string[] {
const originalWarn = console.warn;
const originalLogLevel = process.env.SUBMINER_LOG_LEVEL;
const originalAppLog = process.env.SUBMINER_APP_LOG;
const messages: string[] = [];
console.warn = (...args: unknown[]) => {
messages.push(args.map(String).join(' '));
};
process.env.SUBMINER_LOG_LEVEL = 'warn';
process.env.SUBMINER_APP_LOG = process.platform === 'win32' ? 'NUL' : '/dev/null';
try {
run();
} finally {
console.warn = originalWarn;
if (originalLogLevel === undefined) {
delete process.env.SUBMINER_LOG_LEVEL;
} else {
process.env.SUBMINER_LOG_LEVEL = originalLogLevel;
}
if (originalAppLog === undefined) {
delete process.env.SUBMINER_APP_LOG;
} else {
process.env.SUBMINER_APP_LOG = originalAppLog;
}
}
return messages;
}
function invokeHandleMessage(client: MpvIpcClient, msg: unknown): Promise<void> { function invokeHandleMessage(client: MpvIpcClient, msg: unknown): Promise<void> {
return (client as unknown as { handleMessage: (msg: unknown) => Promise<void> }).handleMessage( return (client as unknown as { handleMessage: (msg: unknown) => Promise<void> }).handleMessage(
msg, msg,
@@ -401,6 +432,51 @@ test('MpvIpcClient onClose requests app quit for managed playback', () => {
assert.equal(quitRequests, 1); assert.equal(quitRequests, 1);
}); });
test('MpvIpcClient only warns once for repeated post-disconnect socket failures', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).send = () => true;
(client as any).scheduleReconnect = () => {};
const callbacks = (client as any).transport.callbacks;
callbacks.onConnect();
const messages = captureWarnLogs(() => {
callbacks.onClose();
for (let index = 0; index < 3; index += 1) {
const error = Object.assign(new Error('connect ENOENT /tmp/mpv.sock'), {
code: 'ENOENT',
});
callbacks.onError(error);
callbacks.onClose();
}
});
assert.equal(messages.filter((message) => message.includes('MPV IPC socket closed')).length, 1);
assert.equal(messages.filter((message) => message.includes('MPV IPC socket error')).length, 1);
});
test('MpvIpcClient warns again after MPV reconnects and disconnects later', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).send = () => true;
(client as any).scheduleReconnect = () => {};
const callbacks = (client as any).transport.callbacks;
callbacks.onConnect();
const messages = captureWarnLogs(() => {
callbacks.onClose();
callbacks.onError(Object.assign(new Error('connect ENOENT /tmp/mpv.sock'), { code: 'ENOENT' }));
callbacks.onClose();
callbacks.onConnect();
callbacks.onClose();
callbacks.onError(Object.assign(new Error('connect ENOENT /tmp/mpv.sock'), { code: 'ENOENT' }));
callbacks.onClose();
});
assert.equal(messages.filter((message) => message.includes('MPV IPC socket closed')).length, 2);
assert.equal(messages.filter((message) => message.includes('MPV IPC socket error')).length, 2);
});
test('MpvIpcClient reconnect replays property subscriptions and initial state requests', () => { test('MpvIpcClient reconnect replays property subscriptions and initial state requests', () => {
const commands: unknown[] = []; const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
+69 -6
View File
@@ -136,6 +136,7 @@ type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
export class MpvIpcClient implements MpvClient { export class MpvIpcClient implements MpvClient {
private deps: MpvIpcClientProtocolDeps; private deps: MpvIpcClientProtocolDeps;
private transport: MpvSocketTransport; private transport: MpvSocketTransport;
private socketPath: string;
public socket: ReturnType<MpvSocketTransport['getSocket']> = null; public socket: ReturnType<MpvSocketTransport['getSocket']> = null;
private eventBus = new EventEmitter(); private eventBus = new EventEmitter();
private buffer = ''; private buffer = '';
@@ -144,6 +145,7 @@ export class MpvIpcClient implements MpvClient {
private reconnectAttempt = 0; private reconnectAttempt = 0;
private firstConnection = true; private firstConnection = true;
private hasConnectedOnce = false; private hasConnectedOnce = false;
private socketErrorWarnedForDisconnect = false;
public currentVideoPath = ''; public currentVideoPath = '';
public currentMediaTitle: string | null = null; public currentMediaTitle: string | null = null;
public currentTimePos = 0; public currentTimePos = 0;
@@ -180,23 +182,30 @@ export class MpvIpcClient implements MpvClient {
constructor(socketPath: string, deps: MpvIpcClientDeps) { constructor(socketPath: string, deps: MpvIpcClientDeps) {
this.deps = deps; this.deps = deps;
this.socketPath = socketPath;
this.transport = new MpvSocketTransport({ this.transport = new MpvSocketTransport({
socketPath, socketPath,
onConnect: () => { onConnect: () => {
logger.debug('Connected to MPV socket');
this.connected = true; this.connected = true;
this.connecting = false; this.connecting = false;
this.socket = this.transport.getSocket(); this.socket = this.transport.getSocket();
this.reconnectAttempt = 0; this.reconnectAttempt = 0;
this.hasConnectedOnce = true; this.hasConnectedOnce = true;
this.socketErrorWarnedForDisconnect = false;
const resolvedConfig = this.deps.getResolvedConfig();
logger.info('MPV IPC socket connected', {
socketPath: this.socketPath,
autoStartOverlay: this.deps.autoStartOverlay,
configAutoStartOverlay: resolvedConfig.auto_start_overlay === true,
});
this.setSecondarySubVisibility(false); this.setSecondarySubVisibility(false);
subscribeToMpvProperties(this.send.bind(this)); subscribeToMpvProperties(this.send.bind(this));
requestMpvInitialState(this.send.bind(this)); requestMpvInitialState(this.send.bind(this));
this.emit('connection-change', { connected: true }); this.emit('connection-change', { connected: true });
const shouldAutoStart = const shouldAutoStart =
this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true; this.deps.autoStartOverlay || resolvedConfig.auto_start_overlay === true;
if (this.firstConnection && shouldAutoStart) { if (this.firstConnection && shouldAutoStart) {
logger.debug('Auto-starting overlay, hiding mpv subtitles'); logger.debug('Auto-starting overlay, hiding mpv subtitles');
setTimeout(() => { setTimeout(() => {
@@ -211,18 +220,30 @@ export class MpvIpcClient implements MpvClient {
this.processBuffer(); this.processBuffer();
}, },
onError: (err: Error) => { onError: (err: Error) => {
logger.debug('MPV socket error:', err.message); this.logSocketError(err);
this.failPendingRequests(); this.failPendingRequests();
}, },
onClose: () => { onClose: () => {
logger.debug('MPV socket closed'); const wasConnected = this.connected;
const shouldQuitOnMpvShutdown = this.deps.shouldQuitOnMpvShutdown?.() === true;
if (wasConnected) {
logger.warn('MPV IPC socket closed', {
socketPath: this.socketPath,
shouldQuitOnMpvShutdown,
});
} else {
logger.debug('MPV IPC socket closed before first connection', {
socketPath: this.socketPath,
reconnectAttempt: this.reconnectAttempt,
});
}
this.connected = false; this.connected = false;
this.connecting = false; this.connecting = false;
this.socket = null; this.socket = null;
this.playbackPaused = null; this.playbackPaused = null;
this.emit('connection-change', { connected: false }); this.emit('connection-change', { connected: false });
this.failPendingRequests(); this.failPendingRequests();
if (this.deps.shouldQuitOnMpvShutdown?.() === true) { if (shouldQuitOnMpvShutdown) {
this.deps.requestAppQuit?.(); this.deps.requestAppQuit?.();
return; return;
} }
@@ -261,6 +282,13 @@ export class MpvIpcClient implements MpvClient {
} }
setSocketPath(socketPath: string): void { setSocketPath(socketPath: string): void {
if (socketPath !== this.socketPath) {
logger.info('MPV IPC socket path updated', {
previousSocketPath: this.socketPath,
socketPath,
});
}
this.socketPath = socketPath;
this.transport.setSocketPath(socketPath); this.transport.setSocketPath(socketPath);
} }
@@ -299,7 +327,9 @@ export class MpvIpcClient implements MpvClient {
getReconnectTimer: () => this.deps.getReconnectTimer(), getReconnectTimer: () => this.deps.getReconnectTimer(),
setReconnectTimer: (timer) => this.deps.setReconnectTimer(timer), setReconnectTimer: (timer) => this.deps.setReconnectTimer(timer),
onReconnectAttempt: (attempt, delay) => { onReconnectAttempt: (attempt, delay) => {
logger.debug(`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`); logger.debug(`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`, {
socketPath: this.socketPath,
});
}, },
connect: () => { connect: () => {
this.connect(); this.connect();
@@ -307,6 +337,39 @@ export class MpvIpcClient implements MpvClient {
}); });
} }
private shouldLogPreConnectionFailure(): boolean {
const nextAttempt = this.reconnectAttempt + 1;
return nextAttempt <= 3 || nextAttempt % 10 === 0;
}
private logSocketError(err: Error): void {
const errorWithCode = err as Error & { code?: unknown };
const details = {
socketPath: this.socketPath,
reconnectAttempt: this.reconnectAttempt,
hasConnectedOnce: this.hasConnectedOnce,
message: err.message,
code: typeof errorWithCode.code === 'string' ? errorWithCode.code : undefined,
};
if (!this.hasConnectedOnce) {
if (this.shouldLogPreConnectionFailure()) {
logger.warn('MPV IPC socket error', details);
return;
}
logger.debug('MPV IPC socket error', details);
return;
}
if (!this.socketErrorWarnedForDisconnect) {
this.socketErrorWarnedForDisconnect = true;
logger.warn('MPV IPC socket error', details);
return;
}
logger.debug('MPV IPC socket error', details);
}
private processBuffer(): void { private processBuffer(): void {
const parsed = splitMpvMessagesFromBuffer( const parsed = splitMpvMessagesFromBuffer(
this.buffer, this.buffer,
@@ -46,9 +46,6 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
openRuntimeOptions: () => { openRuntimeOptions: () => {
calls.push('openRuntimeOptions'); calls.push('openRuntimeOptions');
}, },
openCharacterDictionary: () => {
calls.push('openCharacterDictionary');
},
openCharacterDictionaryManager: () => { openCharacterDictionaryManager: () => {
calls.push('openCharacterDictionaryManager'); calls.push('openCharacterDictionaryManager');
}, },
@@ -163,7 +160,6 @@ test('runOverlayShortcutLocalFallback dispatches matching single-step actions',
}, },
{ {
openRuntimeOptions: () => handled.push('openRuntimeOptions'), openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'), openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'), openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'), markAudioCard: () => handled.push('markAudioCard'),
@@ -197,7 +193,6 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
(_input, accelerator) => accelerator === 'Ctrl+M', (_input, accelerator) => accelerator === 'Ctrl+M',
{ {
openRuntimeOptions: () => handled.push('openRuntimeOptions'), openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'), openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'), openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'), markAudioCard: () => handled.push('markAudioCard'),
@@ -218,7 +213,6 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
(_input, accelerator) => accelerator === 'Ctrl+N', (_input, accelerator) => accelerator === 'Ctrl+N',
{ {
openRuntimeOptions: () => handled.push('openRuntimeOptions'), openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'), openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'), openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'), markAudioCard: () => handled.push('markAudioCard'),
@@ -256,7 +250,6 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
}, },
{ {
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openJimaku: () => {}, openJimaku: () => {},
markAudioCard: () => {}, markAudioCard: () => {},
@@ -293,7 +286,6 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
}, },
{ {
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openJimaku: () => {}, openJimaku: () => {},
markAudioCard: () => {}, markAudioCard: () => {},
@@ -322,9 +314,6 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
openRuntimeOptions: () => { openRuntimeOptions: () => {
called = true; called = true;
}, },
openCharacterDictionary: () => {
called = true;
},
openCharacterDictionaryManager: () => { openCharacterDictionaryManager: () => {
called = true; called = true;
}, },
@@ -410,7 +399,6 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
mineSentenceMultiple: () => {}, mineSentenceMultiple: () => {},
toggleSecondarySub: () => {}, toggleSecondarySub: () => {},
markAudioCard: () => {}, markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
@@ -438,7 +426,6 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
mineSentenceMultiple: () => {}, mineSentenceMultiple: () => {},
toggleSecondarySub: () => {}, toggleSecondarySub: () => {},
markAudioCard: () => {}, markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
@@ -6,7 +6,6 @@ const logger = createLogger('main:overlay-shortcut-handler');
export interface OverlayShortcutFallbackHandlers { export interface OverlayShortcutFallbackHandlers {
openRuntimeOptions: () => void; openRuntimeOptions: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void; openCharacterDictionaryManager: () => void;
openJimaku: () => void; openJimaku: () => void;
markAudioCard: () => void; markAudioCard: () => void;
@@ -23,7 +22,6 @@ export interface OverlayShortcutFallbackHandlers {
export interface OverlayShortcutRuntimeDeps { export interface OverlayShortcutRuntimeDeps {
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
openRuntimeOptions: () => void; openRuntimeOptions: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void; openCharacterDictionaryManager: () => void;
openJimaku: () => void; openJimaku: () => void;
markAudioCard: () => Promise<void>; markAudioCard: () => Promise<void>;
@@ -99,9 +97,6 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
openRuntimeOptions: () => { openRuntimeOptions: () => {
deps.openRuntimeOptions(); deps.openRuntimeOptions();
}, },
openCharacterDictionary: () => {
deps.openCharacterDictionary();
},
openCharacterDictionaryManager: () => { openCharacterDictionaryManager: () => {
deps.openCharacterDictionaryManager(); deps.openCharacterDictionaryManager();
}, },
@@ -112,7 +107,6 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
const fallbackHandlers: OverlayShortcutFallbackHandlers = { const fallbackHandlers: OverlayShortcutFallbackHandlers = {
openRuntimeOptions: overlayHandlers.openRuntimeOptions, openRuntimeOptions: overlayHandlers.openRuntimeOptions,
openCharacterDictionary: overlayHandlers.openCharacterDictionary,
openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager, openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager,
openJimaku: overlayHandlers.openJimaku, openJimaku: overlayHandlers.openJimaku,
markAudioCard: overlayHandlers.markAudioCard, markAudioCard: overlayHandlers.markAudioCard,
@@ -43,7 +43,6 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured'
mineSentenceMultiple: () => {}, mineSentenceMultiple: () => {},
toggleSecondarySub: () => {}, toggleSecondarySub: () => {},
markAudioCard: () => {}, markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
@@ -64,7 +63,6 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
mineSentenceMultiple: () => {}, mineSentenceMultiple: () => {},
toggleSecondarySub: () => {}, toggleSecondarySub: () => {},
markAudioCard: () => {}, markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
@@ -87,7 +85,6 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active
mineSentenceMultiple: () => {}, mineSentenceMultiple: () => {},
toggleSecondarySub: () => {}, toggleSecondarySub: () => {},
markAudioCard: () => {}, markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
-1
View File
@@ -10,7 +10,6 @@ export interface OverlayShortcutHandlers {
mineSentenceMultiple: (timeoutMs: number) => void; mineSentenceMultiple: (timeoutMs: number) => void;
toggleSecondarySub: () => void; toggleSecondarySub: () => void;
markAudioCard: () => void; markAudioCard: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void; openCharacterDictionaryManager: () => void;
openRuntimeOptions: () => void; openRuntimeOptions: () => void;
openJimaku: () => void; openJimaku: () => void;
@@ -34,7 +34,6 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
}, },
openRuntimeOptionsPalette: () => calls.push('runtime-options'), openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openSessionHelp: () => calls.push('session-help'), openSessionHelp: () => calls.push('session-help'),
openCharacterDictionary: () => calls.push('character-dictionary'),
openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'), openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'),
openControllerSelect: () => calls.push('controller-select'), openControllerSelect: () => calls.push('controller-select'),
openControllerDebug: () => calls.push('controller-debug'), openControllerDebug: () => calls.push('controller-debug'),
-4
View File
@@ -18,7 +18,6 @@ export interface SessionActionExecutorDeps {
markActiveVideoWatched: () => Promise<boolean>; markActiveVideoWatched: () => Promise<boolean>;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openSessionHelp: () => void; openSessionHelp: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void; openCharacterDictionaryManager: () => void;
openControllerSelect: () => void; openControllerSelect: () => void;
openControllerDebug: () => void; openControllerDebug: () => void;
@@ -97,9 +96,6 @@ export async function dispatchSessionAction(
case 'openSessionHelp': case 'openSessionHelp':
deps.openSessionHelp(); deps.openSessionHelp();
return; return;
case 'openCharacterDictionary':
deps.openCharacterDictionaryManager();
return;
case 'openCharacterDictionaryManager': case 'openCharacterDictionaryManager':
deps.openCharacterDictionaryManager(); deps.openCharacterDictionaryManager();
return; return;
+50 -5
View File
@@ -4,7 +4,7 @@ import type { Keybinding } from '../../types';
import type { ConfiguredShortcuts } from '../utils/shortcut-config'; import type { ConfiguredShortcuts } from '../utils/shortcut-config';
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions'; import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions';
import { resolveConfiguredShortcuts } from '../utils/shortcut-config'; import { resolveConfiguredShortcuts } from '../utils/shortcut-config';
import { compileSessionBindings } from './session-bindings'; import { buildPluginSessionBindingsArtifact, compileSessionBindings } from './session-bindings';
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts { function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return { return {
@@ -220,10 +220,7 @@ test('compileSessionBindings keeps only the character dictionary manager bound b
const characterDictionaryBindings = result.bindings.flatMap((binding) => { const characterDictionaryBindings = result.bindings.flatMap((binding) => {
if (binding.actionType !== 'session-action') return []; if (binding.actionType !== 'session-action') return [];
if ( if (binding.actionId !== 'openCharacterDictionaryManager') {
binding.actionId !== 'openCharacterDictionary' &&
binding.actionId !== 'openCharacterDictionaryManager'
) {
return []; return [];
} }
return [ return [
@@ -471,3 +468,51 @@ test('compileSessionBindings wires every configured shortcut key into the shared
shortcutKeys.map((key) => `shortcuts.${key}`).sort(), shortcutKeys.map((key) => `shortcuts.${key}`).sort(),
); );
}); });
test('buildPluginSessionBindingsArtifact emits CLI args for plugin-bound session actions', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
openCharacterDictionaryManager: 'Ctrl+D',
}),
keybindings: [
createKeybinding('Ctrl+Alt+KeyR', [
`${SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX}anki.autoUpdateNewCards:prev`,
]),
],
platform: 'linux',
});
const artifact = buildPluginSessionBindingsArtifact({
bindings: result.bindings,
warnings: result.warnings,
numericSelectionTimeoutMs: 2500,
now: new Date('2026-05-26T00:00:00.000Z'),
});
const byActionId = new Map(
artifact.bindings.flatMap((binding) =>
binding.actionType === 'session-action' ? [[binding.actionId, binding]] : [],
),
);
const compiledManagerBinding = result.bindings.find(
(binding) =>
binding.actionType === 'session-action' &&
binding.actionId === 'openCharacterDictionaryManager',
);
assert.equal(compiledManagerBinding && 'cliArgs' in compiledManagerBinding, false);
const managerCliArgs = byActionId.get('openCharacterDictionaryManager')?.cliArgs;
const cycleCliArgs = byActionId.get('cycleRuntimeOption')?.cliArgs;
assert.equal(managerCliArgs?.[0], '--session-action');
assert.deepEqual(JSON.parse(managerCliArgs?.[1] ?? ''), {
actionId: 'openCharacterDictionaryManager',
});
assert.equal(cycleCliArgs?.[0], '--session-action');
assert.deepEqual(JSON.parse(cycleCliArgs?.[1] ?? ''), {
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId: 'anki.autoUpdateNewCards',
direction: -1,
},
});
});
+18 -1
View File
@@ -4,6 +4,7 @@ import type {
CompiledMpvCommandBinding, CompiledMpvCommandBinding,
CompiledSessionActionBinding, CompiledSessionActionBinding,
CompiledSessionBinding, CompiledSessionBinding,
PluginSessionBinding,
PluginSessionBindingsArtifact, PluginSessionBindingsArtifact,
SessionActionId, SessionActionId,
SessionBindingWarning, SessionBindingWarning,
@@ -344,6 +345,22 @@ function getBindingFingerprint(binding: CompiledSessionBinding): string {
return `session:${binding.actionId}:${JSON.stringify(binding.payload ?? null)}`; return `session:${binding.actionId}:${JSON.stringify(binding.payload ?? null)}`;
} }
function buildSessionActionCliArgs(binding: CompiledSessionActionBinding): string[] {
const request =
binding.payload === undefined
? { actionId: binding.actionId }
: { actionId: binding.actionId, payload: binding.payload };
return ['--session-action', JSON.stringify(request)];
}
function toPluginSessionBinding(binding: CompiledSessionBinding): PluginSessionBinding {
if (binding.actionType !== 'session-action') {
return binding;
}
return { ...binding, cliArgs: buildSessionActionCliArgs(binding) };
}
export function compileSessionBindings(input: CompileSessionBindingsInput): { export function compileSessionBindings(input: CompileSessionBindingsInput): {
bindings: CompiledSessionBinding[]; bindings: CompiledSessionBinding[];
warnings: SessionBindingWarning[]; warnings: SessionBindingWarning[];
@@ -516,7 +533,7 @@ export function buildPluginSessionBindingsArtifact(input: {
version: 1, version: 1,
generatedAt: (input.now ?? new Date()).toISOString(), generatedAt: (input.now ?? new Date()).toISOString(),
numericSelectionTimeoutMs: input.numericSelectionTimeoutMs, numericSelectionTimeoutMs: input.numericSelectionTimeoutMs,
bindings: input.bindings, bindings: input.bindings.map(toPluginSessionBinding),
warnings: input.warnings, warnings: input.warnings,
}; };
} }
@@ -41,7 +41,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openJimaku: false, openJimaku: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,
openCharacterDictionary: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false, shiftSubDelayPrevLine: false,
+13
View File
@@ -29,6 +29,7 @@ export interface StartupBootstrapRuntimeDeps {
argv: string[]; argv: string[];
parseArgs: (argv: string[]) => CliArgs; parseArgs: (argv: string[]) => CliArgs;
setLogLevel: (level: string, source: LogLevelSource) => void; setLogLevel: (level: string, source: LogLevelSource) => void;
setLogRotation?: (rotation: number) => void;
forceX11Backend: (args: CliArgs) => void; forceX11Backend: (args: CliArgs) => void;
enforceUnsupportedWaylandMode: (args: CliArgs) => void; enforceUnsupportedWaylandMode: (args: CliArgs) => void;
getDefaultSocketPath: () => string; getDefaultSocketPath: () => string;
@@ -95,6 +96,12 @@ interface AppReadyConfigLike {
}; };
logging?: { logging?: {
level?: 'debug' | 'info' | 'warn' | 'error'; level?: 'debug' | 'info' | 'warn' | 'error';
rotation?: number;
files?: {
app?: boolean;
launcher?: boolean;
mpv?: boolean;
};
}; };
} }
@@ -115,6 +122,10 @@ export interface AppReadyRuntimeDeps {
getConfigWarnings: () => ConfigValidationWarning[]; getConfigWarnings: () => ConfigValidationWarning[];
logConfigWarning: (warning: ConfigValidationWarning) => void; logConfigWarning: (warning: ConfigValidationWarning) => void;
setLogLevel: (level: string, source: LogLevelSource) => void; setLogLevel: (level: string, source: LogLevelSource) => void;
setLogRotation?: (rotation: number) => void;
setLogFileToggles?: (
files: { app?: boolean; launcher?: boolean; mpv?: boolean } | undefined,
) => void;
initRuntimeOptionsManager: () => void; initRuntimeOptionsManager: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void; setSecondarySubMode: (mode: SecondarySubMode) => void;
defaultSecondarySubMode: SecondarySubMode; defaultSecondarySubMode: SecondarySubMode;
@@ -263,6 +274,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
} }
deps.setLogLevel(config.logging?.level ?? 'info', 'config'); deps.setLogLevel(config.logging?.level ?? 'info', 'config');
deps.setLogRotation?.(config.logging?.rotation ?? 7);
deps.setLogFileToggles?.(config.logging?.files);
for (const warning of deps.getConfigWarnings()) { for (const warning of deps.getConfigWarnings()) {
deps.logConfigWarning(warning); deps.logConfigWarning(warning);
} }
+3
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { PartOfSpeech } from '../../types'; import { PartOfSpeech } from '../../types';
import { setLogLevel } from '../../logger';
import { createTokenizerDepsRuntime, TokenizerServiceDeps, tokenizeSubtitle } from './tokenizer'; import { createTokenizerDepsRuntime, TokenizerServiceDeps, tokenizeSubtitle } from './tokenizer';
function makeDeps(overrides: Partial<TokenizerServiceDeps> = {}): TokenizerServiceDeps { function makeDeps(overrides: Partial<TokenizerServiceDeps> = {}): TokenizerServiceDeps {
@@ -1865,6 +1866,7 @@ test('tokenizeSubtitle uses Yomitan parser result when available and drops no-he
test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled', async () => { test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled', async () => {
const infoLogs: string[] = []; const infoLogs: string[] = [];
const originalInfo = console.info; const originalInfo = console.info;
setLogLevel('info');
console.info = (...args: unknown[]) => { console.info = (...args: unknown[]) => {
infoLogs.push(args.map((value) => String(value)).join(' ')); infoLogs.push(args.map((value) => String(value)).join(' '));
}; };
@@ -1912,6 +1914,7 @@ test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled
); );
} finally { } finally {
console.info = originalInfo; console.info = originalInfo;
setLogLevel(undefined);
} }
assert.ok(infoLogs.some((line) => line.includes('Selected Yomitan token groups'))); assert.ok(infoLogs.some((line) => line.includes('Selected Yomitan token groups')));
@@ -601,6 +601,47 @@ test('requestYomitanScanTokens prefers parseText tokenization over termsFind fra
assert.ok(scripts.some((script) => script.includes('termsFind'))); assert.ok(scripts.some((script) => script.includes('termsFind')));
}); });
test('requestYomitanScanTokens warns when active Yomitan profile has no dictionaries', async () => {
const warnings: Array<{ message: string; details: unknown }> = [];
const deps = createDeps(async (script) => {
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [],
},
},
],
};
}
if (script.includes('parseText')) {
return [];
}
if (script.includes('termsFind')) {
return [];
}
return null;
});
await requestYomitanScanTokens('字幕', deps, {
error: () => undefined,
warn: (message, details) => warnings.push({ message, details }),
});
assert.equal(warnings.length, 1);
assert.match(warnings[0]!.message, /no enabled dictionaries/);
assert.deepEqual(warnings[0]!.details, {
profileIndex: 0,
scanLength: 40,
dictionaryCount: 0,
dictionaries: [],
omittedDictionaryCount: 0,
});
});
test('requestYomitanScanTokens keeps scanner metadata when parse spans agree', async () => { test('requestYomitanScanTokens keeps scanner metadata when parse spans agree', async () => {
const deps = createDeps(async (script) => { const deps = createDeps(async (script) => {
if (script.includes('optionsGetFull')) { if (script.includes('optionsGetFull')) {
@@ -7,6 +7,7 @@ import { selectYomitanParseTokens } from './parser-selection-stage';
interface LoggerLike { interface LoggerLike {
error: (message: string, ...args: unknown[]) => void; error: (message: string, ...args: unknown[]) => void;
info?: (message: string, ...args: unknown[]) => void; info?: (message: string, ...args: unknown[]) => void;
warn?: (message: string, ...args: unknown[]) => void;
} }
interface YomitanParserRuntimeDeps { interface YomitanParserRuntimeDeps {
@@ -72,6 +73,7 @@ export interface YomitanAddNoteResult {
const DEFAULT_YOMITAN_SCAN_LENGTH = 40; const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>(); const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>();
const yomitanProfileDiagnosticsLoggedByWindow = new WeakSet<BrowserWindow>();
const yomitanFrequencyCacheByWindow = new WeakMap< const yomitanFrequencyCacheByWindow = new WeakMap<
BrowserWindow, BrowserWindow,
Map<string, YomitanTermFrequency[]> Map<string, YomitanTermFrequency[]>
@@ -532,6 +534,7 @@ async function requestYomitanProfileMetadata(
return null; return null;
} }
yomitanProfileMetadataByWindow.set(parserWindow, metadata); yomitanProfileMetadataByWindow.set(parserWindow, metadata);
logYomitanProfileDiagnostics(parserWindow, metadata, logger);
return metadata; return metadata;
} catch (err) { } catch (err) {
logger.error('Yomitan parser metadata request failed:', (err as Error).message); logger.error('Yomitan parser metadata request failed:', (err as Error).message);
@@ -539,6 +542,37 @@ async function requestYomitanProfileMetadata(
} }
} }
function logYomitanProfileDiagnostics(
parserWindow: BrowserWindow,
metadata: YomitanProfileMetadata,
logger: LoggerLike,
): void {
if (yomitanProfileDiagnosticsLoggedByWindow.has(parserWindow)) {
return;
}
yomitanProfileDiagnosticsLoggedByWindow.add(parserWindow);
const visibleDictionaries = metadata.dictionaries.slice(0, 8);
const details = {
profileIndex: metadata.profileIndex,
scanLength: metadata.scanLength,
dictionaryCount: metadata.dictionaries.length,
dictionaries: visibleDictionaries,
omittedDictionaryCount: Math.max(0, metadata.dictionaries.length - visibleDictionaries.length),
};
if (metadata.dictionaries.length === 0) {
const logWarning = logger.warn ?? logger.info;
logWarning?.(
'Yomitan active profile has no enabled dictionaries; lookup popups may not show definitions.',
details,
);
return;
}
logger.info?.('Yomitan active profile dictionaries loaded.', details);
}
async function ensureYomitanParserWindow( async function ensureYomitanParserWindow(
deps: YomitanParserRuntimeDeps, deps: YomitanParserRuntimeDeps,
logger: LoggerLike, logger: LoggerLike,
+12 -3
View File
@@ -14,6 +14,10 @@ type ExtensionCopyResult = {
copied: boolean; copied: boolean;
}; };
type ExtensionCopyOptions = {
platform?: NodeJS.Platform;
};
const asyncExtensionCopyInFlight = new Map<string, Promise<ExtensionCopyResult>>(); const asyncExtensionCopyInFlight = new Map<string, Promise<ExtensionCopyResult>>();
function readManifestVersion(manifestPath: string): string | null { function readManifestVersion(manifestPath: string): string | null {
@@ -142,8 +146,12 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string)
return sourceHash === null || targetHash === null || sourceHash !== targetHash; return sourceHash === null || targetHash === null || sourceHash !== targetHash;
} }
export function ensureExtensionCopy(sourceDir: string, userDataPath: string): ExtensionCopyResult { export function ensureExtensionCopy(
if (process.platform === 'win32') { sourceDir: string,
userDataPath: string,
options?: ExtensionCopyOptions,
): ExtensionCopyResult {
if ((options?.platform ?? process.platform) === 'win32') {
return { targetDir: sourceDir, copied: false }; return { targetDir: sourceDir, copied: false };
} }
@@ -167,8 +175,9 @@ export function ensureExtensionCopy(sourceDir: string, userDataPath: string): Ex
export async function ensureExtensionCopyAsync( export async function ensureExtensionCopyAsync(
sourceDir: string, sourceDir: string,
userDataPath: string, userDataPath: string,
options?: ExtensionCopyOptions,
): Promise<ExtensionCopyResult> { ): Promise<ExtensionCopyResult> {
if (process.platform === 'win32') { if ((options?.platform ?? process.platform) === 'win32') {
return { targetDir: sourceDir, copied: false }; return { targetDir: sourceDir, copied: false };
} }

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