mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
81b941fe8c
|
|||
|
80d05aef27
|
|||
|
d1998797e9
|
|||
|
8de2613e4b
|
|||
|
e8831bfbb8
|
|||
|
add09213bf
|
|||
|
2f2dfa3e91
|
|||
|
85d838ac96
|
|||
|
d373de7a92
|
@@ -34,13 +34,11 @@ Rules:
|
||||
How fragments turn into a release:
|
||||
|
||||
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
|
||||
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
|
||||
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
||||
|
||||
Prerelease notes:
|
||||
|
||||
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
|
||||
- existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn
|
||||
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
|
||||
- the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Made sentence-audio padding opt-in by default, and kept animated AVIF motion aligned when padding is configured by freezing the first frame during leading audio padding.
|
||||
- Kept multi-line sentence mining aligned when repeated subtitle text appears in the selected history range.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: release
|
||||
|
||||
- Release-note polishing now treats pending fragments and reviewed prerelease notes as a cumulative final outcome, so prerelease-only fixes or breakages collapse into the final user-facing change.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed animated AVIF word-audio sync so the frozen lead-in matches the word audio duration without adding sentence audio padding a second time.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Fixed Jellyfin discovery resume playback when a remote play command sends `StartPositionTicks: 0` despite saved progress on the item.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Improved Jellyfin subtitle timing behavior by preferring default embedded subtitle streams over external sidecars, stripping Jellyfin's server-selected subtitle stream from mpv playback URLs, suppressing mpv's subtitle auto-selection and plugin overlay auto-start while SubMiner stages managed tracks, automatically correcting clear Japanese-vs-English cue timeline offsets, and restoring saved per-stream subtitle delay shifts.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Preserved Jellyfin-visible resume progress when mpv resets its position during playback stop by reusing SubMiner's last known playback position for final progress and stopped reports.
|
||||
- Kept Jellyfin remote Play and Resume distinct so normal Play starts from the beginning, while Resume starts at Jellyfin's requested position without an early mpv seek race.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: shortcuts
|
||||
|
||||
- Focus the visible overlay when entering multi-line copy/mine selection so number keys choose the line count on macOS and Windows.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed manual clipboard card updates from YouTube playback so generated audio and images use mpv's resolved stream URLs instead of the YouTube page URL.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: youtube
|
||||
|
||||
- Downloaded selected YouTube primary subtitles to temporary local files so the primary bar and sidebar read the same subtitle source, with temp-file cleanup on reload and quit. Suppressed stale failure notifications by re-checking live mpv subtitle state before reporting primary subtitle load failures.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Added `mpv.profile` config and settings support for passing an mpv profile to SubMiner-managed mpv launches.
|
||||
@@ -1,7 +0,0 @@
|
||||
type: fixed
|
||||
area: linux
|
||||
|
||||
- Suppressed false YouTube primary subtitle failure notifications after SubMiner confirms the selected primary track loaded successfully.
|
||||
- Ensured launcher-managed playback commands create the tray icon even when they attach to an already-running SubMiner process.
|
||||
- Prevented app-owned YouTube playback from letting the mpv plugin start a second SubMiner process after the launcher already started one.
|
||||
- Logged Linux tray registration failures with a StatusNotifier/AppIndicator hint and documented the Hyprland tray-host requirement.
|
||||
@@ -488,7 +488,6 @@
|
||||
"tags": [
|
||||
"SubMiner"
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
@@ -508,14 +507,11 @@
|
||||
"imageType": "static", // Image capture type: "static" for a single still frame, "avif" for an animated AVIF. Values: static | avif
|
||||
"imageFormat": "jpg", // Encoding format used when imageType is "static". Values: jpg | png | webp
|
||||
"imageQuality": 92, // Quality (0-100) used for lossy static image encoders.
|
||||
"imageMaxWidth": 0, // Maximum width for static images, in pixels. Set to 0 to preserve the source resolution.
|
||||
"imageMaxHeight": 0, // Maximum height for static images, in pixels. Set to 0 to preserve the source resolution.
|
||||
"animatedFps": 10, // Target frame rate for animated AVIF captures.
|
||||
"animatedMaxWidth": 640, // Maximum width applied to animated AVIF captures.
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio.
|
||||
"audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
}, // Media setting.
|
||||
@@ -559,8 +555,6 @@
|
||||
// ==========================================
|
||||
"jimaku": {
|
||||
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
|
||||
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
|
||||
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||
}, // Jimaku API configuration and defaults.
|
||||
@@ -617,13 +611,11 @@
|
||||
// Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.
|
||||
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
|
||||
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
||||
// Set mpv.profile to pass an mpv profile to managed mpv launches; leave it blank to pass none.
|
||||
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||
// ==========================================
|
||||
"mpv": {
|
||||
"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
|
||||
"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.
|
||||
"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
|
||||
|
||||
@@ -4,8 +4,6 @@ SubMiner can sync your watch progress to [AniList](https://anilist.co) automatic
|
||||
|
||||
AniList data also powers two additional features: [cover art](#cover-art) for the stats dashboard and the [Character Dictionary](/character-dictionary) for in-overlay name lookup.
|
||||
|
||||
[AniList](https://anilist.co) is a free website for tracking which anime you have watched. An **access token** is a private key SubMiner stores so it can update your list on your behalf — you approve it once during setup, and you never paste a password into SubMiner.
|
||||
|
||||
## Setup
|
||||
|
||||
AniList integration is opt-in. To enable it:
|
||||
|
||||
@@ -3,13 +3,6 @@
|
||||
SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on to create and update Anki cards with sentence context, audio, and screenshots.
|
||||
This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
|
||||
|
||||
::: tip New to these terms?
|
||||
- **Anki** is the flashcard app where your study cards live.
|
||||
- **AnkiConnect** is a free add-on that lets other programs (like SubMiner) talk to Anki over a local connection. SubMiner needs it installed to add or edit cards.
|
||||
- A **note type** (also called a "model") is the template that defines what a card looks like — for example the Kiku or Lapis templates many Japanese learners use.
|
||||
- A **field** is one labeled slot in that template, such as `Sentence`, `Expression`, or `Picture`. SubMiner fills these fields when it mines a card.
|
||||
:::
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install [Anki](https://apps.ankiweb.net/).
|
||||
@@ -22,9 +15,9 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po
|
||||
|
||||
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, image, and translation fields automatically. Two detection methods are available:
|
||||
|
||||
**Proxy mode** (default) — SubMiner runs a local *proxy*: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant.
|
||||
**Proxy mode** — SubMiner runs a local AnkiConnect-compatible proxy and intercepts card creation instantly. Recommended when possible.
|
||||
|
||||
**Polling mode** (fallback, when the proxy is disabled) — SubMiner asks AnkiConnect every few seconds whether any new cards were added, then enriches them. Simpler setup, but with a short delay (~3 seconds).
|
||||
**Polling mode** (default) — SubMiner polls AnkiConnect every few seconds for newly added cards. Simpler setup, but with a short delay (~3 seconds).
|
||||
|
||||
Use proxy mode if you want immediate enrichment. Use polling mode if your Yomitan instance is external (browser-based) or you prefer minimal configuration.
|
||||
|
||||
@@ -154,13 +147,13 @@ SubMiner uses FFmpeg to generate audio and image media from the video. FFmpeg mu
|
||||
|
||||
### Audio
|
||||
|
||||
Audio is extracted from the video file using the subtitle's start and end timestamps. Padding is opt-in; keep it at `0` when you want sentence audio to start exactly at the mined sentence.
|
||||
Audio is extracted from the video file using the subtitle's start and end timestamps, with configurable padding added before and after.
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"media": {
|
||||
"generateAudio": true,
|
||||
"audioPadding": 0, // optional seconds before and after subtitle timing
|
||||
"audioPadding": 0.5, // seconds before and after subtitle timing
|
||||
"maxMediaDuration": 30 // cap total duration in seconds
|
||||
}
|
||||
}
|
||||
@@ -329,7 +322,6 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
||||
"upstreamUrl": "http://127.0.0.1:8765",
|
||||
},
|
||||
"fields": {
|
||||
"word": "Expression",
|
||||
"audio": "ExpressionAudio",
|
||||
"image": "Picture",
|
||||
"sentence": "Sentence",
|
||||
@@ -342,7 +334,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
||||
"imageType": "static",
|
||||
"imageFormat": "jpg",
|
||||
"imageQuality": 92,
|
||||
"audioPadding": 0,
|
||||
"audioPadding": 0.5,
|
||||
"maxMediaDuration": 30,
|
||||
},
|
||||
"behavior": {
|
||||
|
||||
@@ -273,7 +273,7 @@ For domains migrated to reducer-style transitions (for example AniList token/que
|
||||
|
||||
- **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.
|
||||
- **Startup:** If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks.
|
||||
- **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to mpv subtitle/playback properties via `observe_property`), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`.
|
||||
- **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to 26 properties), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`.
|
||||
- **Overlay runtime:** `initializeOverlayRuntime()` creates the primary overlay window (interactive Yomitan lookups and subtitle rendering), registers global shortcuts, and sets up bounds tracking via the active window tracker. mpv subtitle suppression is handled by a dedicated `overlay-mpv-sub-visibility` service.
|
||||
- **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check (with async worker thread), Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, AniList token refresh, and optional AnkiConnect proxy server. Warmup coverage is configurable through `startupWarmups` (including low-power mode that defers all but Yomitan).
|
||||
- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through `SubtitlePipeline` (normalize → tokenize → merge), and results are sent to the main overlay renderer and modal surfaces.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# Character Dictionary
|
||||
|
||||
SubMiner can build a Yomitan-compatible character dictionary from [AniList](https://anilist.co) metadata so that character names in subtitles are recognized, highlighted, and enrichable with context — portraits, roles, voice actors, and biographical detail — without leaving the overlay. (AniList is an online anime/manga database; SubMiner pulls each show's character list from it.)
|
||||
|
||||
This is helpful because proper names rarely appear in normal dictionaries, so character names would otherwise be flagged as "unknown" words and clutter your mining. Recognizing them keeps your N+1 highlighting focused on real vocabulary.
|
||||
SubMiner can build a Yomitan-compatible character dictionary from AniList metadata so that character names in subtitles are recognized, highlighted, and enrichable with context — portraits, roles, voice actors, and biographical detail — without leaving the overlay.
|
||||
|
||||
The dictionary is generated per-media, merged across your recently-watched titles, and auto-imported into Yomitan. When a character name appears in a subtitle line, it gets highlighted and becomes available for hover-driven Yomitan profile lookup.
|
||||
|
||||
|
||||
+28
-27
@@ -8,8 +8,6 @@ outline: [2, 3]
|
||||
import { withBase } from 'vitepress';
|
||||
</script>
|
||||
|
||||
SubMiner is configured through a single file (`config.jsonc`). Most settings are also editable from the in-app **Settings** window — you rarely need to edit the file by hand. This page is the full reference: it explains the Settings window, where the config file lives, and documents every option grouped by topic. New to SubMiner? The Quick Start below plus the [Settings window](#settings) cover everything most users need.
|
||||
|
||||
## Quick Start
|
||||
|
||||
For most users, start with this minimal configuration:
|
||||
@@ -180,7 +178,7 @@ The configuration file includes several main sections:
|
||||
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
|
||||
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
|
||||
- [**Stats Dashboard**](#stats-dashboard) - Local dashboard and overlay for immersion progress
|
||||
- [**MPV Launcher**](#mpv-launcher) - mpv executable path, profile, and window launch mode
|
||||
- [**MPV Launcher**](#mpv-launcher) - mpv executable path and window launch mode
|
||||
- [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading
|
||||
- [**Updates**](#updates) - Automatic update checks, notifications, and prerelease testing
|
||||
|
||||
@@ -230,15 +228,22 @@ Control whether the overlay automatically becomes visible when it connects to mp
|
||||
|
||||
```json
|
||||
{
|
||||
"auto_start_overlay": true
|
||||
"auto_start_overlay": false
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------------- | --------------- | ----------------------------------------------------- |
|
||||
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `true`) |
|
||||
| -------------------- | --------------- | ------------------------------------------------------ |
|
||||
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) |
|
||||
|
||||
When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime — there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
|
||||
The mpv plugin controls startup overlay visibility via `auto_start_visible_overlay` in `subminer.conf`.
|
||||
For wrapper-driven playback, `subminer.conf` can also enable startup pause gating with
|
||||
`auto_start_pause_until_ready` (requires `auto_start=yes` + `auto_start_visible_overlay=yes`).
|
||||
Current plugin defaults in `subminer.conf` are:
|
||||
|
||||
- `auto_start=yes`
|
||||
- `auto_start_visible_overlay=yes`
|
||||
- `auto_start_pause_until_ready=yes`
|
||||
|
||||
On Windows, packaged plugin installs also rewrite the plugin socket path to `\\.\pipe\subminer-socket`.
|
||||
|
||||
@@ -362,7 +367,7 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
"fontColor": "#cad3f5",
|
||||
"backgroundColor": "transparent",
|
||||
"css": {
|
||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
||||
"font-family": "Inter, Noto Sans, Helvetica Neue, sans-serif",
|
||||
"font-size": "24px",
|
||||
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"
|
||||
}
|
||||
@@ -423,7 +428,7 @@ Character-name highlighting is separate from N+1 and frequency highlighting:
|
||||
- `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.
|
||||
|
||||
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 defaults: `fontFamily: "Inter, Noto Sans, Helvetica Neue, sans-serif"`, `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.
|
||||
|
||||
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
|
||||
|
||||
@@ -440,7 +445,7 @@ Configure the parsed-subtitle sidebar modal.
|
||||
"toggleKey": "Backslash",
|
||||
"pauseVideoOnHover": true,
|
||||
"autoScroll": true,
|
||||
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
|
||||
"fontSize": 16
|
||||
}
|
||||
}
|
||||
@@ -478,7 +483,7 @@ For full details on layout modes, behavior, and the keyboard shortcut, see the [
|
||||
| `N1` | `#ed8796` | JLPT N1 underline color |
|
||||
| `N2` | `#f5a97f` | JLPT N2 underline color |
|
||||
| `N3` | `#f9e2af` | JLPT N3 underline color |
|
||||
| `N4` | `#8bd5ca` | JLPT N4 underline color |
|
||||
| `N4` | `#a6e3a1` | JLPT N4 underline color |
|
||||
| `N5` | `#8aadf4` | JLPT N5 underline color |
|
||||
|
||||
**Image Quality Notes:**
|
||||
@@ -850,7 +855,8 @@ Palette controls:
|
||||
|
||||
### Shared AI Provider
|
||||
|
||||
This is the single, shared connection to an OpenAI-compatible LLM endpoint. Configure it **once** here at the top level, and SubMiner reuses it wherever AI is needed (today: Anki translation/enrichment). Per-feature toggles and prompt/model tweaks live in their own sections (for example `ankiConnect.ai`) and inherit this transport.
|
||||
Shared OpenAI-compatible transport settings live at the top level under `ai`.
|
||||
Anki reads this provider directly. Legacy subtitle fallback keeps the same provider shape for compatibility, then applies feature-local overrides where supported.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -858,7 +864,6 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf
|
||||
"enabled": false,
|
||||
"apiKey": "",
|
||||
"apiKeyCommand": "",
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"baseUrl": "https://openrouter.ai/api",
|
||||
"requestTimeoutMs": 15000
|
||||
}
|
||||
@@ -866,13 +871,13 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------ | -------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) |
|
||||
| ------------------ | -------------------- | ------------------------------------------------------------- |
|
||||
| `enabled` | `true`, `false` | Enable shared AI provider features |
|
||||
| `apiKey` | string | Static API key for the shared provider |
|
||||
| `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`) |
|
||||
| `baseUrl` | string (URL) | OpenAI-compatible base URL (default: `https://openrouter.ai/api`) |
|
||||
| `systemPrompt` | string | Default system prompt sent with requests (default: a translation-engine prompt) |
|
||||
| `apiKeyCommand` | string | Shell command used to resolve the API key |
|
||||
| `baseUrl` | string (URL) | OpenAI-compatible base URL |
|
||||
| `model` | string | Optional model override for shared provider workflows |
|
||||
| `systemPrompt` | string | Optional system prompt override for shared provider workflows |
|
||||
| `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) |
|
||||
|
||||
SubMiner uses the shared provider for:
|
||||
@@ -890,7 +895,7 @@ Enable automatic Anki card creation and updates with media generation:
|
||||
"url": "http://127.0.0.1:8765",
|
||||
"pollingRate": 3000,
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"host": "127.0.0.1",
|
||||
"port": 8766,
|
||||
"upstreamUrl": "http://127.0.0.1:8765"
|
||||
@@ -922,7 +927,7 @@ Enable automatic Anki card creation and updates with media generation:
|
||||
"animatedMaxWidth": 640,
|
||||
"animatedMaxHeight": 360,
|
||||
"animatedCrf": 35,
|
||||
"audioPadding": 0,
|
||||
"audioPadding": 0.5,
|
||||
"fallbackDuration": 3,
|
||||
"maxMediaDuration": 30
|
||||
},
|
||||
@@ -984,7 +989,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||
| `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) | Padding around audio clip timing (default: `0.5`) |
|
||||
| `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) |
|
||||
| `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`) |
|
||||
@@ -1448,13 +1453,12 @@ Usage notes:
|
||||
|
||||
### MPV Launcher
|
||||
|
||||
Configure the mpv executable, profile, and window state for SubMiner-managed mpv launches (launcher playback, Windows `--launch-mpv`, and Jellyfin idle mpv startup):
|
||||
Configure the mpv executable and window state for SubMiner-managed mpv launches (launcher playback, Windows `--launch-mpv`, and Jellyfin idle mpv startup):
|
||||
|
||||
```json
|
||||
{
|
||||
"mpv": {
|
||||
"executablePath": "",
|
||||
"profile": "",
|
||||
"launchMode": "normal"
|
||||
}
|
||||
}
|
||||
@@ -1463,11 +1467,8 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
||||
| 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 `""`) |
|
||||
| `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"`) |
|
||||
|
||||
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
|
||||
|
||||
Launch mode behavior:
|
||||
|
||||
- **`normal`** — mpv opens at its default window size with no extra flags.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Feature Demos
|
||||
|
||||
Short recordings of SubMiner's key features and integrations from real playback sessions. A few terms you'll see below: _Yomitan_ is the pop-up dictionary used for word lookups, _Jimaku_ is a community subtitle database, _alass_ and _ffsubsync_ are tools that retime subtitles to match the audio, _Jellyfin_ is a self-hosted media server, and a _texthooker_ is a web page that mirrors the current subtitle as selectable text for browser-based tools.
|
||||
Short recordings of SubMiner's key features and integrations from real playback sessions.
|
||||
|
||||
<script setup>
|
||||
import { withBase } from 'vitepress';
|
||||
|
||||
@@ -68,15 +68,10 @@ make dev-watch-macos # same as dev-watch, forcing --bac
|
||||
```
|
||||
|
||||
For mpv-plugin-driven testing without exporting `SUBMINER_BINARY_PATH` each run, set a one-time
|
||||
dev binary path with `mpv.subminerBinaryPath` in your SubMiner config. The launcher injects it into
|
||||
the mpv plugin at runtime:
|
||||
dev binary path in `~/.config/mpv/script-opts/subminer.conf`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mpv": {
|
||||
"subminerBinaryPath": "/absolute/path/to/SubMiner/scripts/subminer-dev.sh"
|
||||
}
|
||||
}
|
||||
```ini
|
||||
binary_path=/absolute/path/to/SubMiner/scripts/subminer-dev.sh
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -2,14 +2,8 @@
|
||||
|
||||
SubMiner can log your watching and mining activity to a local SQLite database, then surface it in the built-in stats dashboard. Tracking is enabled by default and can be turned off if you do not want local analytics.
|
||||
|
||||
"Immersion" here means time spent watching and reading native Japanese content. **All data stays on your computer** — nothing is uploaded anywhere. (SQLite is just a single-file database; you do not need to install or manage anything.)
|
||||
|
||||
When enabled, SubMiner records per-session statistics (watch time, subtitle lines seen, words encountered, cards mined) and maintains exact lifetime summary tables plus daily/monthly rollups. You can view that data in SubMiner's stats UI or query the database directly with any SQLite tool.
|
||||
|
||||
::: tip For most users
|
||||
Just leave tracking on and use the built-in [Stats Dashboard](#stats-dashboard). The retention, performance, SQL, and schema sections further down are reference material for advanced users who want to inspect or tune the database — you can safely skip them.
|
||||
:::
|
||||
|
||||
Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_RATIO` (`85%`) value from `src/shared/watch-threshold.ts`.
|
||||
|
||||
## Enabling
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# Installation
|
||||
|
||||
SubMiner is a desktop app that draws an interactive layer — an **overlay** — on top of the [mpv](https://mpv.io) video player. As you watch native Japanese media, you can click or hover any word in the subtitles to look it up, then turn it into an Anki flashcard without pausing to switch apps. Building flashcards from real content you're watching is called **sentence mining**, and it's what SubMiner is built for. It bundles its own copy of **Yomitan** (a pop-up dictionary) and talks to **AnkiConnect** (an add-on that lets other programs add cards to Anki) so cards get filled in automatically.
|
||||
|
||||
Three steps to get started:
|
||||
|
||||
1. **Install requirements** — mpv and a few optional extras
|
||||
@@ -94,7 +92,7 @@ pip install ffsubsync
|
||||
|
||||
### macOS
|
||||
|
||||
macOS 11 (Big Sur) or later. Accessibility permission — the macOS setting that lets one app observe and position another app's windows — is required so the overlay can follow the mpv window (see [step 2](#macos-dmg)).
|
||||
macOS 10.13 or later. Accessibility permission is required for window tracking (see [step 2](#macos-dmg)).
|
||||
|
||||
```bash
|
||||
brew install mpv ffmpeg
|
||||
|
||||
@@ -1,118 +1,186 @@
|
||||
# Jellyfin Integration
|
||||
|
||||
[Jellyfin](https://jellyfin.org) is a free, self-hosted media server — think of it as your own private streaming service for video you own. If you keep your anime on a Jellyfin server, SubMiner can play episodes through mpv with the full mining overlay.
|
||||
SubMiner includes an optional Jellyfin CLI integration for:
|
||||
|
||||
::: tip Who needs this?
|
||||
This page is only relevant if you already run (or have access to) a Jellyfin server. If you watch local files or YouTube, you can skip it. The in-app setup window (`subminer jellyfin`) is the easiest starting point.
|
||||
:::
|
||||
|
||||
SubMiner can act as a **cast-to-device target** for Jellyfin (similar to jellyfin-mpv-shim). Sign in once, turn on discovery, and SubMiner shows up in the "Play on…" / cast menu of any Jellyfin app — web, phone, or TV. Pick an episode, cast it to SubMiner, and it plays in SubMiner's mpv window with the full overlay and Yomitan click-to-lookup.
|
||||
|
||||
This is the recommended way to use Jellyfin with SubMiner. A terminal-only option is covered in [Launcher playback](#launcher-playback) at the end.
|
||||
- authenticating against a server
|
||||
- listing libraries and media items
|
||||
- launching item playback in the connected mpv instance
|
||||
- receiving Jellyfin remote cast-to-device playback events in-app
|
||||
- opening an in-app setup window for server URL and authentication
|
||||
- toggling Jellyfin cast discovery from the tray once configured
|
||||
|
||||
## Requirements
|
||||
|
||||
- A Jellyfin server plus your username and password
|
||||
- SubMiner installed and running (see [Installation](/installation))
|
||||
- On Linux, the session token is stored with `gnome-libsecret` by default
|
||||
- Jellyfin server URL and user credentials
|
||||
- For `--jellyfin-play`: connected mpv IPC socket (`--start` or existing mpv plugin workflow)
|
||||
- On Linux, token encryption defaults to `gnome-libsecret`; pass `--password-store=<backend>` to override.
|
||||
|
||||
## Quick start
|
||||
## Setup
|
||||
|
||||
### 1. Start SubMiner
|
||||
|
||||
Launch SubMiner so it's running in the system tray.
|
||||
|
||||
### 2. Sign in to your server
|
||||
|
||||
Open the tray menu and click **Configure Jellyfin**. In the window that opens, enter your **Server URL** (for example `http://127.0.0.1:8096`), **Username**, and **Password**, then click **Login**.
|
||||
|
||||
On success, SubMiner:
|
||||
|
||||
- saves an encrypted session token — your password is never stored,
|
||||
- turns the Jellyfin integration on, and
|
||||
- remembers the server and username for next time.
|
||||
|
||||
Reopen this window any time to switch servers or **Logout**.
|
||||
|
||||
### 3. Turn on discovery
|
||||
|
||||
Discovery is what makes SubMiner appear as a cast target. Two ways to enable it:
|
||||
|
||||
- **For the current session** — open the tray menu and tick **Jellyfin Discovery**. (This item appears once you've signed in.)
|
||||
- **Automatically on every launch** — already on by default. After your first sign-in, SubMiner auto-connects to Jellyfin at startup, so the cast target is ready without touching the tray. You can change this under [Settings](#settings).
|
||||
|
||||
### 4. Cast from any Jellyfin app
|
||||
|
||||
In the Jellyfin web UI or mobile app, start playing something, open the **cast / "Play on"** menu, and pick your device — SubMiner appears there named after your computer's hostname. Playback opens in SubMiner.
|
||||
|
||||
From then on, pause / resume / seek / stop and audio or subtitle track changes you make in the Jellyfin app are mirrored in SubMiner, and your watch progress syncs back to Jellyfin (now-playing and resume position).
|
||||
|
||||
## What happens during playback
|
||||
|
||||
- **mpv launches automatically.** If mpv isn't already running when you cast, SubMiner starts it with SubMiner defaults and the bundled mpv plugin, so keybindings work right away.
|
||||
- **The overlay is managed by SubMiner,** so your configured `subtitleStyle` controls how subtitles look. Use the [overlay-toggle shortcut](/shortcuts) to hide it for a session.
|
||||
- **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load.
|
||||
- **Direct play first.** When the source allows it and the container is in your direct-play allowlist, SubMiner streams the original file; otherwise it requests a transcoded stream from Jellyfin.
|
||||
- **Japanese subtitles are auto-selected,** preferring Jellyfin's default and embedded tracks over external sidecar files when several match.
|
||||
- **Subtitle timing is corrected when possible.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages downloaded subtitle tracks without letting mpv auto-switch between tracks, then selects the Japanese track once after applying any saved or inferred timing delay. When Jellyfin provides both Japanese and English subtitle files, SubMiner compares their cue timelines and applies a global delay if one track is clearly offset. Manual delay shifts you make with SubMiner's adjacent-cue controls are saved per item and subtitle track, then restored the next time you select that track.
|
||||
|
||||
## Settings
|
||||
|
||||
All Jellyfin options live under **Settings → Integrations → Jellyfin** (open settings from the tray's **Open SubMiner Settings**). The ones that matter for casting:
|
||||
|
||||
| Setting | Default | What it does |
|
||||
| ------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Enabled** | Off | Turns the Jellyfin integration on. Switched on for you when you sign in. |
|
||||
| **Server Url** | — | Your Jellyfin server. Filled in when you sign in. |
|
||||
| **Remote Control Enabled** | On | Lets SubMiner act as a cast target. |
|
||||
| **Remote Control Auto Connect** | On | Connects to Jellyfin at startup so discovery is automatic. Turn off if you'd rather start it from the tray each time. |
|
||||
| **Auto Announce** | Off | Re-broadcasts visibility on connect. Enable if your device is slow to appear in the cast menu. |
|
||||
|
||||
Prefer editing the config file? The same keys live under `jellyfin` in `config.jsonc`:
|
||||
1. Set base config values (`config.jsonc`):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"jellyfin": {
|
||||
"enabled": true,
|
||||
"serverUrl": "http://127.0.0.1:8096",
|
||||
"recentServers": ["http://127.0.0.1:8096"],
|
||||
"username": "your-user",
|
||||
"remoteControlEnabled": true,
|
||||
"remoteControlAutoConnect": true,
|
||||
"autoAnnounce": false,
|
||||
"defaultLibraryId": "",
|
||||
"pullPictures": false,
|
||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons",
|
||||
"directPlayPreferred": true,
|
||||
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
|
||||
"transcodeVideoCodec": "h264",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
See [Configuration](/configuration) for the full list (transcode codec, direct-play containers, default library, and more).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**SubMiner doesn't appear in the cast menu**
|
||||
|
||||
- Make sure SubMiner is running.
|
||||
- Make sure you're signed in — reopen **Configure Jellyfin** and log in again if your token expired.
|
||||
- Make sure discovery is on (tray **Jellyfin Discovery**, or **Remote Control Auto Connect** in settings).
|
||||
- Make sure SubMiner and the Jellyfin client point at the same server.
|
||||
|
||||
**Casting starts but nothing plays**
|
||||
|
||||
- Confirm the item plays normally in another Jellyfin client.
|
||||
- If mpv was closed, give it a moment — SubMiner launches it on demand and retries.
|
||||
|
||||
**SubMiner keeps disconnecting**
|
||||
|
||||
- Check server/network stability and whether the session token has expired.
|
||||
|
||||
## Security notes
|
||||
|
||||
- The Jellyfin session (access token + user ID) is kept in SubMiner's local encrypted token storage. Your password is used only to log in and is never saved.
|
||||
- Treat the token storage and your `config.jsonc` as secrets — don't commit them.
|
||||
- Advanced/headless: the `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID` environment variables can supply a session without the sign-in window.
|
||||
|
||||
## Launcher playback
|
||||
|
||||
If you'd rather stay in the terminal, the `subminer` launcher can browse and play Jellyfin media directly, without casting from a Jellyfin app:
|
||||
2. Authenticate:
|
||||
|
||||
```bash
|
||||
subminer jellyfin -p # alias: subminer jf -p
|
||||
subminer jellyfin
|
||||
subminer jellyfin -l \
|
||||
--server http://127.0.0.1:8096 \
|
||||
--username your-user \
|
||||
--password 'your-password'
|
||||
```
|
||||
|
||||
This opens an fzf picker (add `-R` for rofi) to browse your libraries and episodes, then plays the selected item in SubMiner's mpv with the same overlay, resume, and subtitle behavior described above. Sign in first (step 2) so the launcher can reach your server. See [Launcher Script](/launcher-script) for the rest of the launcher's features.
|
||||
`subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username, and refreshes recent servers. Passwords are never stored.
|
||||
|
||||
3. List libraries:
|
||||
|
||||
```bash
|
||||
SubMiner.AppImage --jellyfin-libraries
|
||||
```
|
||||
|
||||
Launcher wrapper equivalent for interactive playback flow:
|
||||
|
||||
```bash
|
||||
subminer jellyfin -p
|
||||
```
|
||||
|
||||
Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
|
||||
|
||||
```bash
|
||||
subminer jellyfin -d
|
||||
```
|
||||
|
||||
After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. By default, Jellyfin sees the cast target as the OS hostname (`uname -n` on Linux). If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart.
|
||||
|
||||
Stop discovery session/app:
|
||||
|
||||
```bash
|
||||
subminer app --stop
|
||||
```
|
||||
|
||||
`subminer jf ...` is an alias for `subminer jellyfin ...`.
|
||||
|
||||
To clear saved session credentials:
|
||||
|
||||
```bash
|
||||
subminer jellyfin --logout
|
||||
```
|
||||
|
||||
4. List items in a library:
|
||||
|
||||
```bash
|
||||
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search term
|
||||
```
|
||||
|
||||
Optional listing controls:
|
||||
|
||||
- `--jellyfin-recursive=true|false` (default: true)
|
||||
- `--jellyfin-include-item-types=Series,Season,Folder,CollectionFolder,Movie,...`
|
||||
|
||||
These are used by the launcher picker flow to:
|
||||
|
||||
- keep root search focused on shows/folders/movies (exclude episode rows)
|
||||
- browse selected anime/show directories as folder-or-file lists
|
||||
- recurse for playable files only after selecting a folder
|
||||
|
||||
5. Start playback:
|
||||
|
||||
```bash
|
||||
SubMiner.AppImage --start
|
||||
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID
|
||||
```
|
||||
|
||||
Optional stream overrides:
|
||||
|
||||
- `--jellyfin-audio-stream-index N`
|
||||
- `--jellyfin-subtitle-stream-index N`
|
||||
|
||||
## Playback Behavior
|
||||
|
||||
- Direct play is attempted first when:
|
||||
- `jellyfin.directPlayPreferred=true`
|
||||
- media source supports direct stream
|
||||
- source container matches `jellyfin.directPlayContainers`
|
||||
- If direct play is not selected/available, SubMiner requests a Jellyfin transcoded stream (`master.m3u8`) using `jellyfin.transcodeVideoCodec`.
|
||||
- Resume position (`PlaybackPositionTicks`) is applied via mpv seek.
|
||||
- Media title is set in mpv as `[Jellyfin/<mode>] <title>`.
|
||||
- When SubMiner auto-launches mpv for Jellyfin playback, it injects the bundled mpv plugin unless an installed SubMiner mpv plugin is already present. This keeps mpv-side keybindings available without clicking the overlay first.
|
||||
- Jellyfin playback shows the SubMiner visible overlay before selecting subtitle tracks, so `subtitleStyle` controls the rendered subtitle appearance. Use the overlay toggle shortcut if you want to hide it for a session.
|
||||
|
||||
## Cast To Device Mode (jellyfin-mpv-shim style)
|
||||
|
||||
When SubMiner is running with a valid Jellyfin session, it can appear as a
|
||||
remote playback target in Jellyfin's cast-to-device menu.
|
||||
|
||||
### Requirements
|
||||
|
||||
- `jellyfin.enabled=true`
|
||||
- valid `jellyfin.serverUrl` and Jellyfin auth session (env override or stored login session)
|
||||
- `jellyfin.remoteControlEnabled=true` (default)
|
||||
- `jellyfin.remoteControlAutoConnect=true` (default) for startup auto-connect
|
||||
- `jellyfin.autoAnnounce=false` by default (`true` enables auto announce/visibility check logs on connect)
|
||||
|
||||
### Behavior
|
||||
|
||||
- SubMiner connects to Jellyfin remote websocket and posts playback capabilities.
|
||||
- Startup auto-connect still requires `remoteControlAutoConnect=true`; the tray `Jellyfin Discovery` checkbox can start discovery later even when startup auto-connect is disabled.
|
||||
- `Play` events open media in mpv with the same defaults used by `--jellyfin-play`.
|
||||
- If mpv IPC is not connected at cast time, SubMiner auto-launches mpv in idle mode with SubMiner defaults and retries playback.
|
||||
- `Playstate` events map to mpv pause/resume/seek/stop controls.
|
||||
- Stream selection commands (`SetAudioStreamIndex`, `SetSubtitleStreamIndex`) are mapped to mpv track selection.
|
||||
- SubMiner reports start/progress/stop timeline updates back to Jellyfin so now-playing and resume state stay synchronized.
|
||||
- `--jellyfin-remote-announce` forces an immediate capability re-broadcast and logs whether server sessions can see the device.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- Device not visible in Jellyfin cast menu:
|
||||
- ensure SubMiner is running
|
||||
- ensure session token is valid (`--jellyfin-login` again if needed)
|
||||
- ensure `remoteControlEnabled` is true
|
||||
- use tray `Jellyfin Discovery` or `subminer jellyfin -d` to start discovery
|
||||
- Cast command received but playback does not start:
|
||||
- verify mpv IPC can connect (`--start` flow)
|
||||
- verify item is playable from normal `--jellyfin-play --jellyfin-item-id ...`
|
||||
- Frequent reconnects:
|
||||
- check Jellyfin server/network stability and token expiration
|
||||
|
||||
## Failure Handling
|
||||
|
||||
User-visible errors are shown through CLI logs and mpv OSD for:
|
||||
|
||||
- invalid credentials
|
||||
- expired/invalid token
|
||||
- server/network errors
|
||||
- missing library/item identifiers
|
||||
- no playable source
|
||||
- mpv not connected for playback
|
||||
|
||||
## Security Notes and Limitations
|
||||
|
||||
- Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted token storage after login/setup.
|
||||
- Launcher wrappers support `--password-store=<backend>` and forward it through to the app process.
|
||||
- Optional environment overrides are supported: `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID`.
|
||||
- Treat both token storage and config files as secrets and avoid committing them.
|
||||
- Password is used only for login and is not stored.
|
||||
- Optional setup UI is available via `--jellyfin`; all actions are also available via CLI flags.
|
||||
- `subminer` wrapper uses Jellyfin subcommands (`subminer jellyfin ...`, alias `subminer jf ...`). Use `SubMiner.AppImage` for direct `--jellyfin-libraries` and `--jellyfin-items`.
|
||||
- For direct app CLI usage (`SubMiner.AppImage ...`), `--jellyfin-server` can override server URL for login/play flows without editing config.
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# Jimaku Integration
|
||||
|
||||
[Jimaku](https://jimaku.cc) is a community-driven subtitle repository for anime — a shared online library of subtitle files contributed by other learners. SubMiner integrates with the Jimaku API so you can search, browse, and download Japanese subtitle files directly from the overlay — no alt-tabbing or manual file management required. Downloaded subtitles are loaded into mpv immediately.
|
||||
|
||||
::: tip Prerequisite: a free API key
|
||||
You need a Jimaku account and an API key (a personal access string) before this feature works. Create an account at [jimaku.cc](https://jimaku.cc), copy your key, and add it to your config as shown under [Configuration](#configuration) below. Without a key, the search modal will report "Jimaku API key not set."
|
||||
:::
|
||||
[Jimaku](https://jimaku.cc) is a community-driven subtitle repository for anime. SubMiner integrates with the Jimaku API so you can search, browse, and download Japanese subtitle files directly from the overlay — no alt-tabbing or manual file management required. Downloaded subtitles are loaded into mpv immediately.
|
||||
|
||||
## How It Works
|
||||
|
||||
|
||||
+112
-79
@@ -4,15 +4,74 @@ This guide walks through the sentence mining loop — from watching a video to c
|
||||
|
||||
## Overview
|
||||
|
||||
*Sentence mining* means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop.
|
||||
SubMiner runs as a transparent overlay on top of mpv. As subtitles play, the overlay displays them as interactive text. You hover a word, trigger Yomitan lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, audio clip, and screenshot.
|
||||
|
||||
SubMiner runs as a transparent overlay on top of mpv (the video player). As subtitles play, the overlay displays them as interactive text. You hover a word, trigger a Yomitan dictionary lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, an audio clip, and a screenshot to that card — no manual copy-pasting or screen capturing.
|
||||
## Subtitle Delivery Path (Startup + Runtime)
|
||||
|
||||
> **Yomitan** is the popup dictionary that shows definitions when you hover or scan a word. **AnkiConnect** is the add-on that lets SubMiner talk to Anki. Both are set up during installation — see [Anki Integration](/anki-integration) if you have not configured them yet.
|
||||
SubMiner prioritizes subtitle responsiveness over heavy initialization:
|
||||
|
||||
1. The first subtitle render is **plain text first** (no tokenization wait).
|
||||
2. Tokenized enrichment (word spans, known-word flags, JLPT/frequency metadata) is applied right after parsing completes.
|
||||
3. Under rapid subtitle churn, SubMiner uses a **latest-only tokenization queue** so stale lines are dropped instead of building lag.
|
||||
4. MeCab, Yomitan extension load, and dictionary prewarm run as background warmups after overlay initialization (configurable via `startupWarmups`, including low-power mode).
|
||||
|
||||
This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes.
|
||||
|
||||
## Overlay Model
|
||||
|
||||
SubMiner uses one overlay window with modal surfaces.
|
||||
|
||||
### Primary Subtitle Layer
|
||||
|
||||
The visible overlay renders subtitles as tokenized hoverable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports:
|
||||
|
||||
- Word-level hover targets for Yomitan lookup
|
||||
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
|
||||
- Auto pause/resume while the Yomitan popup is open (enabled by default via `subtitleStyle.autoPauseVideoOnYomitanPopup`)
|
||||
- Right-click to pause/resume
|
||||
- Right-click + drag to reposition subtitles
|
||||
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
||||
- **Reading annotations** — known words, N+1 targets, character-name matches, JLPT levels, and frequency hits can all be visually highlighted
|
||||
|
||||
Toggle visibility with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
|
||||
|
||||
### Secondary Subtitle Bar
|
||||
|
||||
The secondary subtitle bar is a compact top-strip region in the same overlay window for translation/context visibility while keeping primary reading flow below. It mirrors your configured secondary subtitle preference and can be independently shown or hidden.
|
||||
|
||||
It is controlled by `secondarySub` configuration and shares lifecycle with the main overlay window.
|
||||
|
||||
### Modal Surfaces
|
||||
|
||||
Jimaku search, field-grouping, runtime options, and manual subsync open as modal surfaces on top of the same overlay window.
|
||||
|
||||
## Looking Up Words
|
||||
|
||||
1. Hover over the subtitle area — the overlay activates pointer events.
|
||||
2. Hover the word you want. SubMiner keeps per-token boundaries so Yomitan can target that token cleanly.
|
||||
3. Trigger Yomitan lookup with your configured lookup key/modifier (for example `Shift` if that is how your Yomitan profile is set up).
|
||||
4. Yomitan opens its lookup popup for the hovered token.
|
||||
5. From the popup, add the word to Anki.
|
||||
|
||||
### Controller Workflow
|
||||
|
||||
With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard:
|
||||
|
||||
1. **Navigate** — push the left stick left/right to move the token highlight across subtitle words.
|
||||
2. **Look up** — press `A` to trigger Yomitan lookup on the highlighted word.
|
||||
3. **Browse the popup** — push the left stick up/down to smooth-scroll through the Yomitan popup, or use the right stick for larger jumps.
|
||||
4. **Cycle audio** — press `R1` to move to the next dictionary audio entry, `L1` to play the current one.
|
||||
5. **Mine** — press `X` to create an Anki card for the current sentence (same as `Ctrl+S`).
|
||||
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
|
||||
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
|
||||
|
||||
After controller support is enabled, the controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
||||
|
||||
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
||||
|
||||
## Creating Anki Cards
|
||||
|
||||
There are four ways to create or enrich cards, depending on your workflow.
|
||||
There are three ways to create cards, depending on your workflow.
|
||||
|
||||
### 1. Auto-Update from Yomitan
|
||||
|
||||
@@ -21,11 +80,11 @@ This is the most common flow. Yomitan creates a card in Anki, and SubMiner enric
|
||||
1. Hover a word, then trigger Yomitan lookup → Yomitan popup appears.
|
||||
2. Click the Anki icon in Yomitan to add the word.
|
||||
3. SubMiner receives or detects the new card:
|
||||
- **Proxy mode** (default, `ankiConnect.proxy.enabled: true`): immediate enrich after a successful `addNote` / `addNotes` is pushed through the local proxy.
|
||||
- **Polling mode** (fallback, when the proxy is disabled): detects new cards via AnkiConnect polling (`ankiConnect.pollingRate`, default 3 seconds).
|
||||
- **Proxy mode** (`ankiConnect.proxy.enabled: true`): immediate enrich after successful `addNote` / `addNotes`.
|
||||
- **Polling mode** (default): detects via AnkiConnect polling (`ankiConnect.pollingRate`, default 3 seconds).
|
||||
4. SubMiner updates the card with:
|
||||
- **Sentence**: The current subtitle line.
|
||||
- **Audio**: Extracted from the video using the subtitle's start/end timing (plus optional configured padding).
|
||||
- **Audio**: Extracted from the video using the subtitle's start/end timing (plus configurable padding).
|
||||
- **Image**: A screenshot or animated clip from the current playback position.
|
||||
- **Translation**: From the secondary subtitle track, or generated via AI if configured.
|
||||
- **MiscInfo**: Metadata like filename and timestamp.
|
||||
@@ -72,88 +131,55 @@ After adding a word via Yomitan, press the audio card shortcut to overwrite the
|
||||
Audio card marking requires a [Lapis](https://github.com/donkuri/lapis) or [Kiku](https://github.com/youyoumu/kiku) compatible note type and `ankiConnect.isLapis.enabled: true` in your config. See [Anki Integration — Sentence Cards](/anki-integration#sentence-cards-lapis) for setup.
|
||||
:::
|
||||
|
||||
### Field Grouping (Kiku)
|
||||
## Secondary Subtitles
|
||||
|
||||
If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This feature is designed for use with [Kiku](https://github.com/youyoumu/kiku) and similar note types that support grouped fields.
|
||||
|
||||
1. You add a word via Yomitan.
|
||||
2. SubMiner detects the new card and checks if a card with the same expression already exists.
|
||||
3. If a duplicate is found (this requires `ankiConnect.isKiku.fieldGrouping` to be set to `"auto"` or `"manual"`; it defaults to `"disabled"`):
|
||||
- **Auto mode** (`ankiConnect.isKiku.fieldGrouping: "auto"`): Merges automatically. Both sentences, audio clips, and images are combined into the existing card. The duplicate is optionally deleted.
|
||||
- **Manual mode** (`ankiConnect.isKiku.fieldGrouping: "manual"`): A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming.
|
||||
|
||||
See [Anki Integration — Field Grouping](/anki-integration#field-grouping-kiku) for configuration options, merge behavior, and modal keyboard shortcuts.
|
||||
|
||||
## Overlay Model
|
||||
|
||||
SubMiner uses one overlay window with modal surfaces. It carries two subtitle bars — a primary reading bar and a secondary translation/context bar — plus modal dialogs that open on top.
|
||||
|
||||
Toggle the entire overlay window with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
|
||||
|
||||
### Primary Subtitle Layer
|
||||
|
||||
The primary bar renders subtitles as tokenized hoverable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports:
|
||||
|
||||
- Word-level hover targets for Yomitan lookup
|
||||
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
|
||||
- Auto pause/resume while the Yomitan popup is open (enabled by default via `subtitleStyle.autoPauseVideoOnYomitanPopup`)
|
||||
- Right-click to pause/resume
|
||||
- Right-click + drag to reposition subtitles
|
||||
- **Reading annotations** — known words, N+1 targets, character-name matches, JLPT levels, and frequency hits can all be visually highlighted
|
||||
|
||||
### Secondary Subtitle Bar
|
||||
|
||||
The secondary bar is a compact top-strip region in the same overlay window. It shows a secondary subtitle track (typically English) for translation/context while keeping the primary reading flow below. It is useful for:
|
||||
SubMiner can display a secondary subtitle track (typically English) alongside the primary Japanese subtitles. This is useful for:
|
||||
|
||||
- Quick comprehension checks without leaving the mining flow.
|
||||
- Auto-populating the translation field on mined cards — when a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it).
|
||||
|
||||
It is controlled by `secondarySub` configuration and shares its lifecycle with the main overlay window. Cycle which track feeds it with `Shift+J`.
|
||||
- Auto-populating the translation field on mined cards.
|
||||
|
||||
### Display Modes
|
||||
|
||||
Both the primary and secondary subtitle bars share the same three visibility modes, and each can be changed independently at runtime:
|
||||
Cycle through modes with the configured shortcut:
|
||||
|
||||
- **Hidden** — the bar is not shown.
|
||||
- **Visible** — the bar is always shown.
|
||||
- **Hover** — the bar is revealed only while you hover over the overlay.
|
||||
- **Hidden**: Secondary subtitle not shown.
|
||||
- **Visible**: Always displayed below the primary subtitle.
|
||||
- **Hover**: Only shown when you hover over the primary subtitle.
|
||||
|
||||
By default the **primary** bar is `visible` (`subtitleStyle.primaryDefaultMode`) and the **secondary** bar is `hover` (`secondarySub.defaultMode`).
|
||||
When a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it).
|
||||
|
||||
Cycle each bar's mode at runtime with its own shortcut:
|
||||
## Field Grouping (Kiku)
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| -------------------- | -------------------------------------------------------- | ------------------------------ |
|
||||
| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local |
|
||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||
If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This feature is designed for use with [Kiku](https://github.com/youyoumu/kiku) and similar note types that support grouped fields.
|
||||
|
||||
### Modal Surfaces
|
||||
### How It Works
|
||||
|
||||
Jimaku search, field-grouping, runtime options, and manual subsync open as modal surfaces on top of the same overlay window.
|
||||
1. You add a word via Yomitan.
|
||||
2. SubMiner detects the new card and checks if a card with the same expression already exists.
|
||||
3. If a duplicate is found:
|
||||
- **Auto mode** (`fieldGrouping: "auto"`): Merges automatically. Both sentences, audio clips, and images are combined into the existing card. The duplicate is optionally deleted.
|
||||
- **Manual mode** (`fieldGrouping: "manual"`): A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming.
|
||||
|
||||
## Looking Up Words
|
||||
See [Anki Integration — Field Grouping](/anki-integration#field-grouping-kiku) for configuration options, merge behavior, and modal keyboard shortcuts.
|
||||
|
||||
1. Hover over the subtitle area — the overlay activates pointer events.
|
||||
2. Hover the word you want. SubMiner keeps per-token boundaries so Yomitan can target that token cleanly.
|
||||
3. Trigger Yomitan lookup with your configured lookup key/modifier (for example `Shift` if that is how your Yomitan profile is set up).
|
||||
4. Yomitan opens its lookup popup for the hovered token.
|
||||
5. From the popup, add the word to Anki.
|
||||
## Jimaku Subtitle Search
|
||||
|
||||
### Controller Workflow
|
||||
SubMiner integrates with [Jimaku](https://jimaku.cc) to search and download subtitle files for anime directly from the overlay.
|
||||
|
||||
With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard:
|
||||
1. Open the Jimaku modal via the configured shortcut (`Ctrl+Shift+J` by default).
|
||||
2. SubMiner auto-fills the search from the current video filename (title, season, episode).
|
||||
3. Browse matching entries and select a subtitle file to download.
|
||||
4. The subtitle is loaded into mpv as a new track.
|
||||
|
||||
1. **Navigate** — push the left stick left/right to move the token highlight across subtitle words.
|
||||
2. **Look up** — press `A` to trigger Yomitan lookup on the highlighted word.
|
||||
3. **Browse the popup** — push the left stick up/down to smooth-scroll through the Yomitan popup, or use the right stick for larger jumps.
|
||||
4. **Cycle audio** — press `R1` to move to the next dictionary audio entry, `L1` to play the current one.
|
||||
5. **Mine** — press `X` to create an Anki card for the current sentence (same as `Ctrl+S`).
|
||||
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
|
||||
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
|
||||
Requires an internet connection. An API key (`jimaku.apiKey`) is optional but recommended for higher rate limits.
|
||||
|
||||
After controller support is enabled, the controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
||||
## Texthooker
|
||||
|
||||
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
||||
SubMiner runs a local HTTP server at `http://127.0.0.1:5174` (configurable port) that serves a texthooker UI. This allows external tools — such as a browser-based Yomitan instance — to receive subtitle text in real time.
|
||||
|
||||
The texthooker page displays the current subtitle and updates as new lines arrive. This is useful if you prefer to do lookups in a browser rather than through the overlay's built-in Yomitan.
|
||||
|
||||
If you want to build your own browser client, websocket consumer, or automation relay, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api).
|
||||
|
||||
## Subtitle Sync (Subsync)
|
||||
|
||||
@@ -168,20 +194,27 @@ For remote streams, including Jellyfin playback, the modal only offers alass. Je
|
||||
|
||||
Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
|
||||
|
||||
## Texthooker
|
||||
## N+1 Word Highlighting
|
||||
|
||||
SubMiner runs a local HTTP server at `http://127.0.0.1:5174` (configurable port) that serves a texthooker UI. This allows external tools — such as a browser-based Yomitan instance — to receive subtitle text in real time.
|
||||
When enabled, SubMiner cross-references your Anki decks to highlight known words in the overlay, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
|
||||
|
||||
The texthooker page displays the current subtitle and updates as new lines arrive. This is useful if you prefer to do lookups in a browser rather than through the overlay's built-in Yomitan.
|
||||
See [Subtitle Annotations — N+1](/subtitle-annotations#n1-word-highlighting) for configuration options and color settings.
|
||||
|
||||
If you want to build your own browser client, websocket consumer, or automation relay, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api).
|
||||
## Immersion Tracking
|
||||
|
||||
## Related Features
|
||||
SubMiner can log your watching and mining activity to a local SQLite database and expose it in the built-in stats dashboard — session times, words seen, cards mined, and daily/monthly rollups.
|
||||
|
||||
These features support the mining loop but have their own dedicated pages:
|
||||
Enable it in your config:
|
||||
|
||||
- **[Jimaku subtitle search](/jimaku-integration)** — search and download anime subtitle files directly from the overlay (`Ctrl+Shift+J` by default), then load them into mpv.
|
||||
- **[N+1 word highlighting](/subtitle-annotations#n1-word-highlighting)** — cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
|
||||
- **[Immersion tracking](/immersion-tracking)** — log watching and mining activity to a local database and view session times, words seen, and cards mined in the built-in stats dashboard.
|
||||
```jsonc
|
||||
"immersionTracking": {
|
||||
"enabled": true,
|
||||
"dbPath": "" // leave empty to use the default location
|
||||
}
|
||||
```
|
||||
|
||||
Open the dashboard in the overlay with `stats.toggleKey` (default: `` ` ``), launch it in a browser with `subminer stats`, keep a dedicated background server alive with `subminer stats -b`, stop that background server with `subminer stats -s`, or visit `http://127.0.0.1:6969` directly if the local stats server is already running. The dashboard covers overview totals, anime progress, session detail, and vocabulary drill-down from the same local immersion database.
|
||||
|
||||
See [Immersion Tracking](/immersion-tracking) for dashboard details, schema, and retention settings.
|
||||
|
||||
Next: [Anki Integration](/anki-integration) — field mapping, media generation, and card enrichment configuration.
|
||||
|
||||
+93
-33
@@ -1,10 +1,6 @@
|
||||
# MPV Plugin
|
||||
|
||||
**What this is:** mpv is the video player SubMiner overlays subtitles on. The SubMiner mpv plugin is a small Lua script that runs *inside* mpv and gives you in-player keybindings to control the SubMiner overlay (start/stop/toggle, skip intro, etc.) without leaving the player window.
|
||||
|
||||
**Who needs this page:** Most users never touch the plugin directly — SubMiner-managed launches (the app, the `subminer` launcher, or the Windows shortcut) inject the bundled plugin automatically for that session, so there is nothing to install into mpv's global `scripts` directory. Read on if you launch mpv from another tool and want SubMiner's in-player controls, or you want to script mpv against SubMiner.
|
||||
|
||||
The plugin ships as a modular Lua package under `plugin/subminer/` (entry point `init.lua`, which loads `main.lua` and sibling modules). Earlier releases shipped a single global `main.lua`; runtime loading replaces it.
|
||||
The SubMiner mpv plugin (`subminer/main.lua`) provides in-player keybindings to control the overlay without leaving mpv. SubMiner-managed launches inject the bundled runtime plugin, so users do not need to install it into mpv's global `scripts` directory.
|
||||
|
||||
## Runtime Loading
|
||||
|
||||
@@ -27,45 +23,22 @@ input-ipc-server=\\.\pipe\subminer-socket
|
||||
|
||||
## Keybindings
|
||||
|
||||
Most plugin actions use a `y` chord prefix — press `y`, then the second key (a "chord"):
|
||||
All keybindings use a `y` chord prefix — press `y`, then the second key:
|
||||
|
||||
| Chord | Action |
|
||||
| ---------------- | -------------------------------------- |
|
||||
| ----- | ---------------------- |
|
||||
| `y-y` | Open menu |
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `v` | Toggle primary subtitle bar visibility |
|
||||
| `y-o` | Open settings window |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check status |
|
||||
| `y-h` | Open session help / keybinding modal |
|
||||
| `v` | Toggle primary subtitle bar visibility |
|
||||
| `TAB` (default) | Skip intro (AniSkip) |
|
||||
|
||||
The AniSkip key is **not** a `y` chord. It defaults to `TAB` and is configurable via `mpv.aniskipButtonKey`. The legacy `y-k` chord still works as a fallback unless you remap the AniSkip key onto it.
|
||||
| `y-k` | Skip intro (AniSkip) |
|
||||
|
||||
The bare `v` binding is a forced mpv binding. It overrides mpv's default primary subtitle visibility toggle and routes the action to SubMiner's primary subtitle bar instead.
|
||||
|
||||
## Shared Shortcuts (Session Bindings)
|
||||
|
||||
The `y-*` chords above are built into the plugin. Everything else you configure under [`shortcuts.*`](/shortcuts) — plus any custom [`keybindings`](/configuration) and the stats toggle/mark-watched keys — is **injected into mpv at runtime**, so the same shortcut works both inside mpv and in the SubMiner overlay. You do not edit any mpv config to enable them.
|
||||
|
||||
How it works:
|
||||
|
||||
1. The SubMiner app compiles your configured shortcuts, custom keybindings, and stats keys into a normalized list and writes it to `session-bindings.json` in the SubMiner config directory.
|
||||
2. On load, the plugin reads that file and registers each entry as a forced mpv key binding, translating each accelerator into the matching mpv key name.
|
||||
3. When a binding fires, the plugin either runs a SubMiner action (by invoking the SubMiner binary with the corresponding CLI flag, e.g. `--mine-sentence`) or runs a raw mpv command, depending on what the shortcut maps to.
|
||||
|
||||
Because the bindings come from the same configuration the overlay uses, you maintain one set of shortcuts for both surfaces.
|
||||
|
||||
Live updates: changing a shortcut in the app rewrites `session-bindings.json` and sends the plugin a `subminer-reload-session-bindings` script message, so mpv re-registers the bindings immediately — no mpv restart required.
|
||||
|
||||
Notes:
|
||||
|
||||
- Accelerators are normalized per platform — `CommandOrControl` resolves to `Cmd` on macOS and `Ctrl` elsewhere.
|
||||
- Multi-line actions (`copySubtitleMultiple`, `mineSentenceMultiple`) register temporary `1`–`9` digit follow-up bindings after the trigger key, with `Esc` to cancel.
|
||||
- If two shortcuts compile to the same key, or an accelerator can't be mapped to an mpv key, the app logs a warning and skips that binding instead of registering a broken one.
|
||||
|
||||
## Menu
|
||||
|
||||
Press `y-y` to open an interactive menu in mpv's OSD:
|
||||
@@ -82,6 +55,93 @@ SubMiner:
|
||||
|
||||
Select an item by pressing its number.
|
||||
|
||||
## Configuration
|
||||
|
||||
For advanced/manual runtime use, create or edit `~/.config/mpv/script-opts/subminer.conf`:
|
||||
|
||||
```ini
|
||||
# Path to SubMiner binary. Leave empty for auto-detection.
|
||||
binary_path=
|
||||
|
||||
# MPV IPC socket path. Must match input-ipc-server in mpv.conf.
|
||||
socket_path=/tmp/subminer-socket
|
||||
|
||||
# Enable the texthooker WebSocket server.
|
||||
texthooker_enabled=yes
|
||||
|
||||
# Port for the texthooker server.
|
||||
texthooker_port=5174
|
||||
|
||||
# Window manager backend: auto, hyprland, sway, x11, macos, windows.
|
||||
backend=auto
|
||||
|
||||
# Start the overlay automatically when a file is loaded.
|
||||
# Runs only when mpv input-ipc-server matches socket_path.
|
||||
auto_start=yes
|
||||
|
||||
# Show the visible overlay on auto-start.
|
||||
# Runs only when mpv input-ipc-server matches socket_path.
|
||||
auto_start_visible_overlay=yes
|
||||
|
||||
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
|
||||
# Requires auto_start=yes and auto_start_visible_overlay=yes.
|
||||
auto_start_pause_until_ready=yes
|
||||
|
||||
# Show OSD messages for overlay status changes.
|
||||
osd_messages=yes
|
||||
|
||||
# Logging level: debug, info, warn, error.
|
||||
log_level=info
|
||||
|
||||
# Enable AniSkip intro detection/markers.
|
||||
aniskip_enabled=yes
|
||||
|
||||
# Optional title override (launcher fills from guessit when available).
|
||||
aniskip_title=
|
||||
|
||||
# Optional season override (launcher fills from guessit when available).
|
||||
aniskip_season=
|
||||
|
||||
# Optional MAL ID override. Leave blank to resolve from media title.
|
||||
aniskip_mal_id=
|
||||
|
||||
# Optional episode override. Leave blank to detect from filename/title.
|
||||
aniskip_episode=
|
||||
|
||||
# Show OSD skip button while inside intro range.
|
||||
aniskip_show_button=yes
|
||||
|
||||
# OSD label + keybinding for intro skip action.
|
||||
aniskip_button_text=You can skip by pressing %s
|
||||
aniskip_button_key=y-k
|
||||
aniskip_button_duration=3
|
||||
```
|
||||
|
||||
### Option Reference
|
||||
|
||||
| Option | Default | Values | Description |
|
||||
| ------------------------------ | ----------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
|
||||
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
|
||||
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
|
||||
| `texthooker_port` | `5174` | 1–65535 | Texthooker server port |
|
||||
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
|
||||
| `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
|
||||
| `auto_start_visible_overlay` | `yes` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
|
||||
| `auto_start_pause_until_ready` | `yes` | `yes` / `no` | Pause mpv on visible auto-start; resume when SubMiner signals tokenization-ready |
|
||||
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
||||
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
||||
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
|
||||
| `aniskip_title` | `""` | string | Override title used for lookup |
|
||||
| `aniskip_season` | `""` | numeric season | Optional season hint |
|
||||
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
|
||||
| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed |
|
||||
| `aniskip_payload` | `""` | JSON / base64-encoded JSON | Optional pre-fetched AniSkip payload for this media. When set, plugin skips network lookup |
|
||||
| `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt |
|
||||
| `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) |
|
||||
| `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) |
|
||||
| `aniskip_button_duration` | `3` | float seconds | OSD hint duration |
|
||||
|
||||
## Binary Auto-Detection
|
||||
|
||||
When `binary_path` is empty, the plugin searches platform-specific locations:
|
||||
@@ -158,7 +218,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
||||
- If the payload is absent or invalid, lookup falls back to title/MAL-based async fetch.
|
||||
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
||||
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
||||
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing TAB` by default; the key reflects `mpv.aniskipButtonKey`).
|
||||
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing y-k` by default).
|
||||
- Use `script-message subminer-aniskip-refresh` after changing media metadata/options to retry lookup.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
@@ -488,7 +488,6 @@
|
||||
"tags": [
|
||||
"SubMiner"
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
@@ -508,14 +507,11 @@
|
||||
"imageType": "static", // Image capture type: "static" for a single still frame, "avif" for an animated AVIF. Values: static | avif
|
||||
"imageFormat": "jpg", // Encoding format used when imageType is "static". Values: jpg | png | webp
|
||||
"imageQuality": 92, // Quality (0-100) used for lossy static image encoders.
|
||||
"imageMaxWidth": 0, // Maximum width for static images, in pixels. Set to 0 to preserve the source resolution.
|
||||
"imageMaxHeight": 0, // Maximum height for static images, in pixels. Set to 0 to preserve the source resolution.
|
||||
"animatedFps": 10, // Target frame rate for animated AVIF captures.
|
||||
"animatedMaxWidth": 640, // Maximum width applied to animated AVIF captures.
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio.
|
||||
"audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
}, // Media setting.
|
||||
@@ -559,8 +555,6 @@
|
||||
// ==========================================
|
||||
"jimaku": {
|
||||
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
|
||||
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
|
||||
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||
}, // Jimaku API configuration and defaults.
|
||||
@@ -617,13 +611,11 @@
|
||||
// Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.
|
||||
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
|
||||
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
||||
// Set mpv.profile to pass an mpv profile to managed mpv launches; leave it blank to pass none.
|
||||
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||
// ==========================================
|
||||
"mpv": {
|
||||
"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
|
||||
"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.
|
||||
"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
|
||||
|
||||
+4
-12
@@ -1,13 +1,5 @@
|
||||
# Keyboard Shortcuts
|
||||
|
||||
This page is the complete reference for every keystroke SubMiner responds to. If you are just getting started, focus on the **Mining Shortcuts** and **Overlay Controls** sections — those cover the day-to-day mining loop. The rest can wait until you need them.
|
||||
|
||||
A few terms used throughout:
|
||||
|
||||
- **Overlay** — the transparent SubMiner window that sits on top of mpv and shows the interactive subtitles. Most shortcuts only work while this window has focus (click the video once if a shortcut seems to do nothing).
|
||||
- **`Ctrl/Cmd`** — use `Ctrl` on Windows/Linux and `Cmd` (⌘) on macOS. In the config file this is written as `CommandOrControl`.
|
||||
- **Accelerator** — Electron's name for a shortcut string like `Alt+Shift+O`.
|
||||
|
||||
All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindings`. Set any shortcut to `null` to disable it.
|
||||
|
||||
## Global Shortcuts
|
||||
@@ -37,7 +29,7 @@ These work when the overlay window has focus.
|
||||
| `Ctrl/Cmd+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` |
|
||||
| `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` |
|
||||
|
||||
The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select how many recent subtitle lines to combine. When the shortcut starts from mpv, SubMiner focuses the visible overlay for that selector instead of reserving the number keys in the mpv plugin.
|
||||
The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select how many recent subtitle lines to combine.
|
||||
|
||||
## Overlay Controls
|
||||
|
||||
@@ -47,7 +39,7 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| -------------------- | --------------------------------------------------- |
|
||||
| `Space` | Toggle mpv pause |
|
||||
| `F` | Toggle fullscreen |
|
||||
| `V` | Cycle primary subtitle bar mode (hidden → visible → hover) |
|
||||
| `V` | Toggle primary subtitle bar visibility |
|
||||
| `J` | Cycle primary subtitle track |
|
||||
| `Shift+J` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
||||
@@ -112,13 +104,13 @@ When the mpv plugin is installed, all commands use a `y` chord prefix — press
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `v` | Cycle primary subtitle bar mode (hidden → visible → hover) |
|
||||
| `v` | Toggle primary subtitle bar visibility |
|
||||
| `y-o` | Open Yomitan settings |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check overlay status |
|
||||
| `y-h` | Open session help |
|
||||
|
||||
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so it cycles the SubMiner primary subtitle bar (hidden → visible → hover) instead.
|
||||
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so the SubMiner primary subtitle bar is hidden or restored instead.
|
||||
|
||||
When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper).
|
||||
|
||||
|
||||
@@ -73,11 +73,9 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
|
||||
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
|
||||
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
|
||||
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
|
||||
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for single mode |
|
||||
| `subtitleStyle.frequencyDictionary.bandedColors` | 5 colors[^1] | Array of five hex colors for banded mode |
|
||||
| `subtitleStyle.frequencyDictionary.sourcePath` | `""` | Custom path to frequency dictionary root (empty = auto-discover) |
|
||||
|
||||
[^1]: Default banded palette (most common → least common): `#ed8796`, `#f5a97f`, `#f9e2af`, `#8bd5ca`, `#8aadf4`.
|
||||
| `subtitleStyle.frequencyDictionary.singleColor` | — | Color for single mode |
|
||||
| `subtitleStyle.frequencyDictionary.bandedColors` | — | Array of five hex colors for banded mode |
|
||||
| `subtitleStyle.frequencyDictionary.sourcePath` | — | Custom path to frequency dictionary root |
|
||||
|
||||
When `sourcePath` is omitted, SubMiner searches default install/runtime locations for `frequency-dictionary` directories automatically.
|
||||
|
||||
@@ -104,7 +102,7 @@ SubMiner loads offline `term_meta_bank_*.json` files from `vendor/yomitan-jlpt-v
|
||||
| N1 | `#ed8796` | Red |
|
||||
| N2 | `#f5a97f` | Peach |
|
||||
| N3 | `#f9e2af` | Yellow |
|
||||
| N4 | `#8bd5ca` | Teal |
|
||||
| N4 | `#a6e3a1` | Green |
|
||||
| N5 | `#8aadf4` | Blue |
|
||||
|
||||
All colors are customizable via the `subtitleStyle.jlptColors` object.
|
||||
|
||||
@@ -51,14 +51,14 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
|
||||
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
|
||||
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels |
|
||||
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` |
|
||||
| `backgroundColor` | string | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color |
|
||||
| `textColor` | string | `#cad3f5` | Default cue text color |
|
||||
| `fontFamily` | string | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | CSS `font-family` applied to cue text |
|
||||
| `backgroundColor` | string | — | Sidebar shell background color |
|
||||
| `textColor` | string | — | Default cue text color |
|
||||
| `fontFamily` | string | — | CSS `font-family` applied to cue text |
|
||||
| `fontSize` | number | `16` | Base cue font size in CSS pixels |
|
||||
| `timestampColor` | string | `#a5adcb` | Cue timestamp color |
|
||||
| `activeLineColor` | string | `#f5bde6` | Active cue text color |
|
||||
| `activeLineBackgroundColor` | string | `rgba(138, 173, 244, 0.22)` | Active cue background color |
|
||||
| `hoverLineBackgroundColor` | string | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
|
||||
| `timestampColor` | string | — | Cue timestamp color |
|
||||
| `activeLineColor` | string | — | Active cue text color |
|
||||
| `activeLineBackgroundColor` | string | — | Active cue background color |
|
||||
| `hoverLineBackgroundColor` | string | — | Hovered cue background color |
|
||||
|
||||
## Keyboard Shortcut
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Troubleshooting
|
||||
|
||||
Common issues and how to resolve them. Most problems fall into one of a few buckets — the overlay shows but subtitles don't (see [MPV Connection](#mpv-connection)), cards aren't being created or come out empty (see [AnkiConnect](#ankiconnect)), or word lookups don't appear (see [Yomitan](#yomitan)). If an error message popped up on screen, search this page for the exact text — most headings below are quoted error strings.
|
||||
Common issues and how to resolve them.
|
||||
|
||||
## MPV Connection
|
||||
|
||||
@@ -9,7 +9,7 @@ Common issues and how to resolve them. Most problems fall into one of a few buck
|
||||
SubMiner connects to mpv via a Unix socket (or named pipe on Windows). If the socket does not exist or the path does not match, the overlay will appear but subtitles will never arrive.
|
||||
|
||||
- Ensure mpv is running with `--input-ipc-server=/tmp/subminer-socket`.
|
||||
- If you use a custom socket path, set it in both your mpv config and SubMiner config (`mpv.socketPath`).
|
||||
- If you use a custom socket path, set it in both your mpv config and SubMiner config (`mpvSocketPath`).
|
||||
- The `subminer` wrapper script sets the socket automatically when it launches mpv. If you launch mpv yourself, the `--input-ipc-server` flag is required.
|
||||
|
||||
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.
|
||||
@@ -315,7 +315,6 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati
|
||||
|
||||
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors are not supported — both mpv and SubMiner must run under X11 or Xwayland instead.
|
||||
- **X11 / Xwayland**: Requires `xdotool` and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway — both mpv and SubMiner must be running under X11/Xwayland for window tracking to work.
|
||||
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
|
||||
- **Mouse passthrough**: On Linux, Electron's mouse passthrough is unreliable. SubMiner keeps pointer events enabled, meaning you may need to toggle the overlay off to interact with mpv controls underneath.
|
||||
|
||||
### Hyprland
|
||||
|
||||
+8
-9
@@ -38,13 +38,13 @@ Field names must match your Anki note type exactly (case-sensitive). See [Anki I
|
||||
|
||||
## How It Works
|
||||
|
||||
When you launch SubMiner, it wires up mpv and the overlay for you:
|
||||
|
||||
1. SubMiner starts the overlay app in the background
|
||||
2. mpv runs with an **IPC socket** at `/tmp/subminer-socket` — a small local channel two programs use to talk to each other, so the overlay can ask mpv what subtitle is on screen right now
|
||||
2. mpv runs with an IPC socket at `/tmp/subminer-socket`
|
||||
3. The overlay connects and subscribes to subtitle changes
|
||||
|
||||
From there, subtitles render as interactive, hoverable word spans and you mine cards directly from the overlay. For the overlay anatomy and the full mining loop — word lookup, card creation, annotations — see [Mining Workflow](/mining-workflow).
|
||||
4. Subtitles are tokenized with Yomitan's internal parser
|
||||
5. Words are displayed as interactive spans in the overlay
|
||||
6. Hover a word, then trigger Yomitan lookup with your configured lookup key/modifier to open the Yomitan popup
|
||||
7. Optional [subtitle annotations](/subtitle-annotations) (N+1, character-name, frequency, JLPT) highlight useful cues in real time
|
||||
|
||||
### Ways to Launch
|
||||
|
||||
@@ -156,7 +156,6 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
|
||||
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
||||
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
|
||||
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
||||
- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host.
|
||||
- On Linux, the app now defaults `safeStorage` to `gnome-libsecret` for encrypted token persistence.
|
||||
Launcher pass-through commands also support `--password-store=<backend>` and forward it to the app when present.
|
||||
Override with e.g. `--password-store=basic_text`.
|
||||
@@ -191,7 +190,7 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
|
||||
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
||||
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
|
||||
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
|
||||
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`).
|
||||
- `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage.
|
||||
- Subcommand help pages are available (for example `subminer jellyfin -h`).
|
||||
|
||||
@@ -242,7 +241,7 @@ Top-level launcher flags like `--jellyfin-*` are intentionally rejected.
|
||||
|
||||
You can append additional MPV arguments with launcher `-a/--args`, for example `--args "--ao=alsa --volume=80"`.
|
||||
|
||||
You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. The Windows `SubMiner.exe --launch-mpv` shortcut path uses equivalent args directly, but skips the extra current-directory subtitle scan to avoid duplicate sidecar detection when you drag a video onto the shortcut; the optional profile remains useful for manual mpv launches. The `subminer` wrapper passes no mpv profile by default; set one with `subminer -p <profile> ...` or with `mpv.profile` in your config (for example `"profile": "subminer"` to use the `[subminer]` profile below):
|
||||
You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. The Windows `SubMiner.exe --launch-mpv` shortcut path uses equivalent args directly, but skips the extra current-directory subtitle scan to avoid duplicate sidecar detection when you drag a video onto the shortcut; the optional profile remains useful for manual mpv launches and the `subminer` wrapper defaults to `--profile=subminer` (or override with `subminer -p <profile> ...`):
|
||||
|
||||
```ini
|
||||
[subminer]
|
||||
@@ -284,7 +283,7 @@ Notes:
|
||||
- For YouTube URLs, `subminer` probes available YouTube subtitle tracks, reuses existing authoritative tracks when available, and downloads only missing sides.
|
||||
- Native mpv secondary subtitle rendering stays hidden so the overlay remains the visible secondary subtitle surface.
|
||||
- Primary subtitle target languages come from `youtube.primarySubLanguages` (defaults to `["ja","jpn"]`).
|
||||
- Secondary target languages come from `secondarySub.secondarySubLanguages` (empty by default; when empty, no language-based secondary track is auto-selected, though mpv's `--slang` list above still prefers English variants).
|
||||
- Secondary target languages come from `secondarySub.secondarySubLanguages` (defaults to English if unset).
|
||||
- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtube` and `secondarySub`.
|
||||
|
||||
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# WebSocket / Texthooker API & Integration
|
||||
|
||||
**Who this page is for:** developers and tinkerers who want to consume SubMiner's live subtitle stream from their own tools — a browser tab, an automation script, or another mpv plugin. If you just want subtitles in a browser tab for Yomitan, skip to [Texthooker Integration Guide](#texthooker-integration-guide); the rest is reference for building custom clients.
|
||||
|
||||
A *texthooker* is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to.
|
||||
|
||||
SubMiner exposes a small set of local integration surfaces for browser tools, automation helpers, and mpv-driven workflows:
|
||||
|
||||
- **Subtitle WebSocket** at `ws://127.0.0.1:6677` by default for plain subtitle pushes.
|
||||
@@ -50,7 +46,7 @@ SubMiner's integration ports are configured in `config.jsonc`.
|
||||
- `texthooker.launchAtStartup` starts the local HTTP UI automatically.
|
||||
- `texthooker.openBrowser` controls whether SubMiner opens the texthooker page in your browser when it starts.
|
||||
|
||||
If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only helper process. The launcher derives the plugin's texthooker setting from your SubMiner config (`texthooker.launchAtStartup`) and injects it at runtime — there is no plugin config file to edit.
|
||||
If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only helper process and override the texthooker port in `subminer.conf`.
|
||||
|
||||
## Developer API Documentation
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ SubMiner auto-loads Japanese subtitles when you play a YouTube URL, giving you t
|
||||
|
||||
## Requirements
|
||||
|
||||
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** must be installed and on your `PATH`. yt-dlp is a free command-line tool that reads YouTube video and subtitle info; SubMiner calls it behind the scenes. (`PATH` is the list of folders your system searches for programs — most installers add yt-dlp to it automatically. If yours did not, set `SUBMINER_YTDLP_BIN` to the full path of the yt-dlp binary.)
|
||||
- mpv with `--input-ipc-server` configured (handled automatically when you launch playback through the `subminer` launcher — no manual setup needed).
|
||||
- **yt-dlp** must be installed and on `PATH` (or set `SUBMINER_YTDLP_BIN` to its path)
|
||||
- mpv with `--input-ipc-server` configured (handled automatically by the `subminer` launcher)
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -111,8 +111,8 @@ Secondary track selection uses the shared `secondarySub` config:
|
||||
```jsonc
|
||||
{
|
||||
"secondarySub": {
|
||||
"secondarySubLanguages": [],
|
||||
"autoLoadSecondarySub": false,
|
||||
"secondarySubLanguages": ["eng", "en"],
|
||||
"autoLoadSecondarySub": true,
|
||||
"defaultMode": "hover"
|
||||
}
|
||||
}
|
||||
@@ -120,8 +120,8 @@ Secondary track selection uses the shared `secondarySub` config:
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
|
||||
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
|
||||
| `secondarySubLanguages` | `string[]` | Language codes for secondary subtitle auto-loading (default: English) |
|
||||
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load matching secondary track |
|
||||
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
|
||||
|
||||
Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@
|
||||
# SubMiner Internal Docs
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: you need internal architecture, workflow, verification, or release guidance
|
||||
|
||||
@@ -15,6 +15,7 @@ Read when: you need internal architecture, workflow, verification, or release gu
|
||||
- [Workflow](./workflow/README.md) - planning, execution, verification expectations
|
||||
- [Knowledge Base](./knowledge-base/README.md) - how docs are structured, maintained, and audited
|
||||
- [Release Guide](./RELEASING.md) - tagged release checklist
|
||||
- [Plans](./plans/) - active design and implementation artifacts
|
||||
|
||||
## Fast Paths
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Architecture Map
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-03-26
|
||||
Owner: Kyle Yasuda
|
||||
Read when: runtime ownership, composition boundaries, or layering questions
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Domain Ownership
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-03-26
|
||||
Owner: Kyle Yasuda
|
||||
Read when: you need to find the owner module for a behavior or test surface
|
||||
|
||||
@@ -19,7 +19,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
- Config system: `src/config/`
|
||||
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
|
||||
- MPV runtime and protocol: `src/core/services/mpv*.ts`
|
||||
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
|
||||
- Subtitle/token pipeline: `src/core/services/tokenizer*`, `src/subtitle/`, `src/tokenizers/`
|
||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||
- Immersion tracking: `src/core/services/immersion-tracker/`
|
||||
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
|
||||
@@ -37,8 +37,6 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
- Anki-specific contracts: `src/types/anki.ts`
|
||||
- External integration contracts: `src/types/integrations.ts`
|
||||
- Runtime-option contracts: `src/types/runtime-options.ts`
|
||||
- Settings UI contracts: `src/types/settings.ts`
|
||||
- Session-binding contracts: `src/types/session-bindings.ts`
|
||||
- Compatibility-only barrel: `src/types.ts`
|
||||
|
||||
## Ownership Heuristics
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Layering Rules
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: deciding whether a dependency direction is acceptable
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Knowledge Base Rules
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: maintaining the internal doc system itself
|
||||
|
||||
|
||||
@@ -3,24 +3,24 @@
|
||||
# Documentation Catalog
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: finding internal docs or checking verification status
|
||||
|
||||
| Area | Path | Status | Last verified | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint |
|
||||
| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
|
||||
| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership |
|
||||
| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
|
||||
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
|
||||
| KB home | `docs/README.md` | active | 2026-03-13 | internal entrypoint |
|
||||
| Architecture index | `docs/architecture/README.md` | active | 2026-03-13 | top-level runtime map |
|
||||
| Domain ownership | `docs/architecture/domains.md` | active | 2026-03-13 | runtime and feature ownership |
|
||||
| Layering rules | `docs/architecture/layering.md` | active | 2026-03-13 | dependency direction and smells |
|
||||
| KB rules | `docs/knowledge-base/README.md` | active | 2026-03-13 | maintenance policy |
|
||||
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
|
||||
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
|
||||
| Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map |
|
||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
||||
| Agent plugins | `docs/workflow/agent-plugins.md` | active | 2026-05-23 | repo-local agent workflow plugin ownership |
|
||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes |
|
||||
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
||||
| Workflow index | `docs/workflow/README.md` | active | 2026-03-13 | execution map |
|
||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-03-13 | lightweight vs execution plans |
|
||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-03-13 | maintained verification lanes |
|
||||
| Release guide | `docs/RELEASING.md` | active | 2026-03-13 | release checklist |
|
||||
| Active plans | `docs/plans/` | active | 2026-03-13 | task-scoped design and implementation artifacts |
|
||||
|
||||
## Update Rules
|
||||
|
||||
|
||||
@@ -37,3 +37,4 @@ Grades are directional, not ceremonial. The point is to keep gaps visible.
|
||||
|
||||
- Some deep architecture detail still lives in `docs-site/architecture.md` and may merit later migration.
|
||||
- Quality grading is manual and should be refreshed when major refactors land.
|
||||
- Active plans can accumulate without lifecycle cleanup if humans do not prune them.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Config Settings Window
|
||||
|
||||
read_when: changing config UI, config save behavior, or config docs
|
||||
|
||||
## Intent
|
||||
|
||||
Add a dedicated Electron settings window for editing canonical config values without exposing the historical layout mistakes in `config.jsonc`.
|
||||
|
||||
The UI groups options by workflow:
|
||||
|
||||
- Viewing
|
||||
- Mining & Anki
|
||||
- Playback & Sources
|
||||
- Input
|
||||
- Integrations
|
||||
- Tracking & App
|
||||
- Advanced
|
||||
|
||||
Each field maps back to its current raw config path. The presentation layer must stay separate from generated config-template sections.
|
||||
|
||||
## Sources
|
||||
|
||||
- Canonical defaults: `DEFAULT_CONFIG`
|
||||
- Existing option descriptions/enums: `CONFIG_OPTION_REGISTRY`
|
||||
- UI registry: `src/config/settings/registry.ts`
|
||||
- JSONC save path: `src/config/settings/jsonc-edit.ts`
|
||||
- Window runtime: `src/main/runtime/config-settings-window.ts`
|
||||
|
||||
## Save Contract
|
||||
|
||||
Settings writes use `jsonc-parser.modify`, not `JSON.stringify`.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Preserve comments, trailing commas, unrelated keys, and hidden legacy keys.
|
||||
- Reset removes the explicit path so defaults resolve normally.
|
||||
- Validate the candidate config before writing.
|
||||
- Reject warnings caused by modified fields.
|
||||
- Preserve unrelated existing warnings and return them in the snapshot.
|
||||
- Write atomically, reload `ConfigService`, classify with existing hot-reload logic, and apply live changes where supported.
|
||||
- Never return secret values to the renderer; snapshots only expose configured/not-configured state.
|
||||
|
||||
## Hidden Compatibility Keys
|
||||
|
||||
Do not expose these as first-class controls:
|
||||
|
||||
- `ankiConnect.deck`
|
||||
- Legacy top-level Anki migration fields such as `wordField`, `audioField`, media-generation aliases, and behavior aliases
|
||||
- Legacy `ankiConnect.nPlusOne.*` aliases except canonical `nPlusOne.nPlusOne` and `nPlusOne.minSentenceWords`
|
||||
- Deprecated Lapis sentence-card fields
|
||||
- `youtubeSubgen.primarySubLanguages`
|
||||
- `anilist.characterDictionary.refreshTtlHours`
|
||||
- `anilist.characterDictionary.evictionPolicy`
|
||||
- `jellyfin.accessToken`
|
||||
- `jellyfin.userId`
|
||||
- `controller.buttonIndices` as a normal editable field
|
||||
|
||||
## Verification
|
||||
|
||||
Minimum targeted checks:
|
||||
|
||||
- `bun test src/config/settings/registry.test.ts src/config/settings/jsonc-edit.test.ts src/settings/settings-model.test.ts src/main/runtime/config-settings-window.test.ts`
|
||||
- `bun run test:config`
|
||||
- `bun run typecheck`
|
||||
- `bun run build`
|
||||
|
||||
If docs changed:
|
||||
|
||||
- `bun run docs:test`
|
||||
- `bun run docs:build`
|
||||
@@ -3,7 +3,7 @@
|
||||
# Workflow
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: planning or executing nontrivial work in this repo
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Agent Plugins
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-03-26
|
||||
Owner: Kyle Yasuda
|
||||
Read when: packaging or migrating repo-local agent workflow skills into plugins
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Planning
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: the task spans multiple files, subsystems, or verification lanes
|
||||
|
||||
@@ -28,9 +28,9 @@ Read when: the task spans multiple files, subsystems, or verification lanes
|
||||
|
||||
## Plan Location
|
||||
|
||||
- plans are task-scoped scratch artifacts; keep them with the work (worktree, branch, or PR description), not committed under `docs/`
|
||||
- if a plan must be shared, keep names date-prefixed and task-specific
|
||||
- delete plans once the work lands; do not leave mystery artifacts behind
|
||||
- active design and implementation docs live in `docs/plans/`
|
||||
- keep names date-prefixed and task-specific
|
||||
- remove or archive old plans deliberately; do not leave mystery artifacts
|
||||
|
||||
## Plan Contents
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Verification
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: selecting the right verification lane for a change
|
||||
|
||||
|
||||
@@ -148,50 +148,6 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
||||
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||
});
|
||||
|
||||
test('youtube app-owned playback disables mpv plugin auto-start', async () => {
|
||||
const context = createContext();
|
||||
context.pluginRuntimeConfig = {
|
||||
...context.pluginRuntimeConfig,
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
};
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async (
|
||||
_target,
|
||||
_targetKind,
|
||||
_args,
|
||||
_socketPath,
|
||||
_appPath,
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
if (options) {
|
||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async () => {},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
});
|
||||
|
||||
const runtimeConfig = receivedStartMpvOptions[0]?.runtimePluginConfig as
|
||||
| { autoStart?: boolean; autoStartVisibleOverlay?: boolean; autoStartPauseUntilReady?: boolean }
|
||||
| undefined;
|
||||
assert.equal(runtimeConfig?.autoStart, false);
|
||||
assert.equal(runtimeConfig?.autoStartVisibleOverlay, false);
|
||||
assert.equal(runtimeConfig?.autoStartPauseUntilReady, false);
|
||||
});
|
||||
|
||||
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
|
||||
@@ -258,13 +258,6 @@ export async function runPlaybackCommandWithDeps(
|
||||
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
runtimePluginConfig: {
|
||||
...effectivePluginRuntimeConfig,
|
||||
...(isAppOwnedYoutubeFlow
|
||||
? {
|
||||
autoStart: false,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
}
|
||||
: {}),
|
||||
backend: args.backend,
|
||||
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
|
||||
},
|
||||
|
||||
@@ -229,29 +229,6 @@ test('getDefaultSocketPath returns Windows named pipe default', () => {
|
||||
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
|
||||
});
|
||||
|
||||
test('parseLauncherMpvConfig reads configured mpv profile', () => {
|
||||
assert.deepEqual(
|
||||
parseLauncherMpvConfig({
|
||||
mpv: {
|
||||
profile: ' anime ',
|
||||
},
|
||||
}),
|
||||
{
|
||||
launchMode: undefined,
|
||||
socketPath: undefined,
|
||||
backend: undefined,
|
||||
autoStartSubMiner: undefined,
|
||||
pauseUntilOverlayReady: undefined,
|
||||
subminerBinaryPath: undefined,
|
||||
profile: 'anime',
|
||||
aniskipEnabled: undefined,
|
||||
aniskipButtonKey: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(parseLauncherMpvConfig({ mpv: { profile: ' ' } }).profile, undefined);
|
||||
});
|
||||
|
||||
test('readExternalYomitanProfilePath detects configured external profile paths', () => {
|
||||
assert.equal(
|
||||
readExternalYomitanProfilePath({
|
||||
|
||||
@@ -45,20 +45,6 @@ test('createDefaultArgs normalizes configured language codes and env thread over
|
||||
}
|
||||
});
|
||||
|
||||
test('createDefaultArgs seeds mpv profile from launcher config', () => {
|
||||
const parsed = createDefaultArgs({}, { profile: 'anime' });
|
||||
|
||||
assert.equal(parsed.profile, 'anime');
|
||||
});
|
||||
|
||||
test('applyRootOptionsToArgs appends CLI mpv profile to configured profile', () => {
|
||||
const parsed = createDefaultArgs({}, { profile: 'anime' });
|
||||
|
||||
applyRootOptionsToArgs(parsed, { profile: 'hdr' }, undefined);
|
||||
|
||||
assert.equal(parsed.profile, 'anime,hdr');
|
||||
});
|
||||
|
||||
test('applyRootOptionsToArgs maps file, directory, and url targets', () => {
|
||||
withTempDir((dir) => {
|
||||
const filePath = path.join(dir, 'movie.mkv');
|
||||
|
||||
@@ -68,12 +68,6 @@ function parseBackend(value: string): Backend {
|
||||
fail(`Invalid backend: ${value} (must be auto, hyprland, sway, x11, macos, or windows)`);
|
||||
}
|
||||
|
||||
function appendMpvProfile(current: string, next: string): string {
|
||||
const trimmed = next.trim();
|
||||
if (!trimmed) return current;
|
||||
return current ? `${current},${trimmed}` : trimmed;
|
||||
}
|
||||
|
||||
function parseDictionaryTarget(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
@@ -127,7 +121,7 @@ export function createDefaultArgs(
|
||||
backend: mpvConfig.backend ?? 'auto',
|
||||
directory: '.',
|
||||
recursive: false,
|
||||
profile: mpvConfig.profile ?? '',
|
||||
profile: '',
|
||||
startOverlay: false,
|
||||
whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || '',
|
||||
whisperModel: process.env.SUBMINER_WHISPER_MODEL || launcherConfig.whisperModel || '',
|
||||
@@ -221,8 +215,7 @@ export function applyRootOptionsToArgs(
|
||||
if (typeof options.backend === 'string') parsed.backend = parseBackend(options.backend);
|
||||
if (typeof options.directory === 'string') parsed.directory = options.directory;
|
||||
if (options.recursive === true) parsed.recursive = true;
|
||||
if (typeof options.profile === 'string')
|
||||
parsed.profile = appendMpvProfile(parsed.profile, options.profile);
|
||||
if (typeof options.profile === 'string') parsed.profile = options.profile;
|
||||
if (options.start === true) parsed.startOverlay = true;
|
||||
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
||||
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
||||
|
||||
@@ -31,7 +31,6 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
|
||||
|
||||
return {
|
||||
launchMode: parseMpvLaunchMode(mpv.launchMode),
|
||||
profile: parseNonEmptyString(mpv.profile),
|
||||
socketPath: parseNonEmptyString(mpv.socketPath),
|
||||
backend: parseBackend(mpv.backend),
|
||||
autoStartSubMiner:
|
||||
|
||||
@@ -268,18 +268,6 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
|
||||
});
|
||||
});
|
||||
|
||||
test('buildConfiguredMpvDefaultArgs passes configured mpv profile before SubMiner defaults', () => {
|
||||
withPlatform('linux', () => {
|
||||
assert.deepEqual(
|
||||
buildConfiguredMpvDefaultArgs(makeArgs({ profile: 'anime,hdr' }), {
|
||||
DISPLAY: ':1',
|
||||
XDG_SESSION_TYPE: 'x11',
|
||||
}).slice(0, 2),
|
||||
['--profile=anime,hdr', '--sub-auto=fuzzy'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('buildConfiguredMpvDefaultArgs disables macOS menu shortcuts so SubMiner bindings reach mpv', () => {
|
||||
withPlatform('darwin', () => {
|
||||
assert.equal(
|
||||
|
||||
@@ -30,12 +30,6 @@ test('parseArgs captures mpv args string', () => {
|
||||
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
|
||||
});
|
||||
|
||||
test('parseArgs appends CLI mpv profile to configured mpv profile', () => {
|
||||
const parsed = parseArgs(['--profile', 'hdr'], 'subminer', {}, { profile: 'anime' });
|
||||
|
||||
assert.equal(parsed.profile, 'anime,hdr');
|
||||
});
|
||||
|
||||
test('parseArgs maps root settings window option', () => {
|
||||
const parsed = parseArgs(['--settings'], 'subminer', {});
|
||||
|
||||
|
||||
@@ -175,7 +175,6 @@ export interface LauncherJellyfinConfig {
|
||||
|
||||
export interface LauncherMpvConfig {
|
||||
launchMode?: MpvLaunchMode;
|
||||
profile?: string;
|
||||
socketPath?: string;
|
||||
backend?: MpvBackend;
|
||||
autoStartSubMiner?: boolean;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "subminer",
|
||||
"version": "0.15.0-beta.5",
|
||||
"version": "0.15.0-beta.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "subminer",
|
||||
"version": "0.15.0-beta.5",
|
||||
"version": "0.15.0-beta.3",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.15.0-beta.5",
|
||||
"version": "0.15.0-beta.4",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -50,8 +50,8 @@
|
||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||
"test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/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/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
|
||||
"test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/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:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
|
||||
"test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
|
||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
||||
|
||||
@@ -85,10 +85,6 @@ function M.create(ctx)
|
||||
if not has_matching_subminer_socket() then
|
||||
return false
|
||||
end
|
||||
if state.skip_managed_subtitle_rearm_once then
|
||||
state.skip_managed_subtitle_rearm_once = false
|
||||
return true
|
||||
end
|
||||
mp.set_property_native("sub-auto", "fuzzy")
|
||||
mp.set_property_native("sid", "auto")
|
||||
mp.set_property_native("secondary-sid", "auto")
|
||||
@@ -183,21 +179,12 @@ function M.create(ctx)
|
||||
state.pending_reload_reason = nil
|
||||
state.current_media_identity = media_identity
|
||||
state.current_media_title = media_title
|
||||
if state.app_managed_playback_pending then
|
||||
state.app_managed_playback_pending = false
|
||||
state.app_managed_playback_active = true
|
||||
elseif new_media_loaded then
|
||||
state.app_managed_playback_active = false
|
||||
end
|
||||
if new_media_loaded then
|
||||
state.suppress_ready_overlay_restore = false
|
||||
end
|
||||
|
||||
if same_media_reload then
|
||||
subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload")
|
||||
if state.app_managed_playback_active then
|
||||
return
|
||||
end
|
||||
if
|
||||
state.overlay_running
|
||||
and not state.suppress_ready_overlay_restore
|
||||
@@ -221,11 +208,6 @@ function M.create(ctx)
|
||||
process.disarm_auto_play_ready_gate()
|
||||
end
|
||||
|
||||
if state.app_managed_playback_active then
|
||||
subminer_log("debug", "lifecycle", "Skipping plugin auto-start for app-managed subtitle preload")
|
||||
return
|
||||
end
|
||||
|
||||
if should_auto_start then
|
||||
start_overlay_when_socket_ready(retry_generation, media_identity, same_media_loaded, 1)
|
||||
return
|
||||
@@ -245,8 +227,6 @@ function M.create(ctx)
|
||||
state.pending_reload_media_identity = nil
|
||||
state.pending_reload_media_title = nil
|
||||
state.pending_reload_reason = nil
|
||||
state.app_managed_playback_pending = false
|
||||
state.app_managed_playback_active = false
|
||||
end
|
||||
|
||||
local function register_lifecycle_hooks()
|
||||
@@ -272,8 +252,6 @@ function M.create(ctx)
|
||||
state.pending_reload_media_identity = nil
|
||||
state.pending_reload_media_title = nil
|
||||
state.pending_reload_reason = nil
|
||||
state.app_managed_playback_pending = false
|
||||
state.app_managed_playback_active = false
|
||||
if state.overlay_running and reason ~= "quit" then
|
||||
process.hide_visible_overlay()
|
||||
end
|
||||
|
||||
@@ -6,7 +6,6 @@ function M.create(ctx)
|
||||
local aniskip = ctx.aniskip
|
||||
local hover = ctx.hover
|
||||
local ui = ctx.ui
|
||||
local state = ctx.state
|
||||
|
||||
local function register_script_messages()
|
||||
mp.register_script_message("subminer-start", function(...)
|
||||
@@ -24,10 +23,6 @@ function M.create(ctx)
|
||||
mp.register_script_message("subminer-visible-overlay-shown", function()
|
||||
process.record_visible_overlay_visibility(true)
|
||||
end)
|
||||
mp.register_script_message("subminer-managed-subtitles-loading", function()
|
||||
state.skip_managed_subtitle_rearm_once = true
|
||||
state.app_managed_playback_pending = true
|
||||
end)
|
||||
mp.register_script_message("subminer-menu", function()
|
||||
ui.show_menu()
|
||||
end)
|
||||
|
||||
@@ -102,46 +102,6 @@ function M.create(ctx)
|
||||
state.suppress_ready_overlay_restore = true
|
||||
end
|
||||
|
||||
local function record_start_visibility_args(args)
|
||||
for _, arg in ipairs(args) do
|
||||
if arg == "--show-visible-overlay" then
|
||||
record_visible_overlay_action("show-visible-overlay")
|
||||
return
|
||||
end
|
||||
if arg == "--hide-visible-overlay" then
|
||||
record_visible_overlay_action("hide-visible-overlay")
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function should_run_visibility_action(action)
|
||||
if action == "show-visible-overlay" and state.visible_overlay_requested == true then
|
||||
return false
|
||||
end
|
||||
if action == "hide-visible-overlay" and state.visible_overlay_requested == false then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function run_visibility_action_if_needed(action, overrides, callback)
|
||||
if action == nil then
|
||||
if callback then
|
||||
callback(true)
|
||||
end
|
||||
return
|
||||
end
|
||||
if not should_run_visibility_action(action) then
|
||||
subminer_log("debug", "process", "Skipping duplicate visible overlay action: " .. tostring(action))
|
||||
if callback then
|
||||
callback(true)
|
||||
end
|
||||
return
|
||||
end
|
||||
run_control_command_async(action, overrides, callback)
|
||||
end
|
||||
|
||||
local function should_ignore_duplicate_visible_overlay_toggle()
|
||||
if type(mp.get_time) ~= "function" then
|
||||
return false
|
||||
@@ -287,7 +247,7 @@ function M.create(ctx)
|
||||
state.suppress_ready_overlay_restore = false
|
||||
end
|
||||
if state.overlay_running and (force_ready_overlay_restore or resolve_visible_overlay_startup()) then
|
||||
run_visibility_action_if_needed("show-visible-overlay", {
|
||||
run_control_command_async("show-visible-overlay", {
|
||||
socket_path = opts.socket_path,
|
||||
})
|
||||
end
|
||||
@@ -401,6 +361,7 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
run_control_command_async = function(action, overrides, callback)
|
||||
record_visible_overlay_action(action)
|
||||
local args = build_command_args(action, overrides)
|
||||
local command = build_subprocess_command(args)
|
||||
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
|
||||
@@ -413,9 +374,6 @@ function M.create(ctx)
|
||||
capture_stderr = true,
|
||||
}, function(success, result, error)
|
||||
local ok = success and (result == nil or result.status == 0)
|
||||
if ok then
|
||||
record_visible_overlay_action(action)
|
||||
end
|
||||
if callback then
|
||||
callback(ok, result, error)
|
||||
end
|
||||
@@ -523,10 +481,12 @@ function M.create(ctx)
|
||||
disarm_auto_play_ready_gate()
|
||||
end
|
||||
local visibility_action = resolve_auto_start_visibility_action()
|
||||
run_visibility_action_if_needed(visibility_action, {
|
||||
if visibility_action ~= nil then
|
||||
run_control_command_async(visibility_action, {
|
||||
socket_path = socket_path,
|
||||
log_level = overrides.log_level,
|
||||
})
|
||||
end
|
||||
return
|
||||
end
|
||||
subminer_log("info", "process", "Overlay already running")
|
||||
@@ -566,7 +526,6 @@ function M.create(ctx)
|
||||
state.overlay_running = true
|
||||
|
||||
local command = build_subprocess_command(args)
|
||||
record_start_visibility_args(args)
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
args = command.args,
|
||||
@@ -593,11 +552,13 @@ function M.create(ctx)
|
||||
|
||||
if overrides.auto_start_trigger == true then
|
||||
local visibility_action = resolve_auto_start_visibility_action()
|
||||
run_visibility_action_if_needed(visibility_action, {
|
||||
if visibility_action ~= nil then
|
||||
run_control_command_async(visibility_action, {
|
||||
socket_path = socket_path,
|
||||
log_level = overrides.log_level,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -134,10 +134,7 @@ function M.create(ctx)
|
||||
elseif action_id == "copySubtitle" then
|
||||
return { "--copy-subtitle" }
|
||||
elseif action_id == "copySubtitleMultiple" then
|
||||
if payload and payload.count then
|
||||
return { "--copy-subtitle-count", tostring(payload.count) }
|
||||
end
|
||||
return { "--copy-subtitle-multiple" }
|
||||
return { "--copy-subtitle-count", tostring(payload and payload.count or 1) }
|
||||
elseif action_id == "updateLastCardFromClipboard" then
|
||||
return { "--update-last-card-from-clipboard" }
|
||||
elseif action_id == "triggerFieldGrouping" then
|
||||
@@ -147,10 +144,7 @@ function M.create(ctx)
|
||||
elseif action_id == "mineSentence" then
|
||||
return { "--mine-sentence" }
|
||||
elseif action_id == "mineSentenceMultiple" then
|
||||
if payload and payload.count then
|
||||
return { "--mine-sentence-count", tostring(payload.count) }
|
||||
end
|
||||
return { "--mine-sentence-multiple" }
|
||||
return { "--mine-sentence-count", tostring(payload and payload.count or 1) }
|
||||
elseif action_id == "toggleSecondarySub" then
|
||||
return { "--toggle-secondary-sub" }
|
||||
elseif action_id == "toggleSubtitleSidebar" then
|
||||
@@ -238,6 +232,73 @@ function M.create(ctx)
|
||||
end)
|
||||
end
|
||||
|
||||
local function clear_numeric_selection(show_cancelled)
|
||||
if state.session_numeric_selection and state.session_numeric_selection.timeout then
|
||||
state.session_numeric_selection.timeout:kill()
|
||||
end
|
||||
state.session_numeric_selection = nil
|
||||
remove_binding_names(state.session_numeric_binding_names)
|
||||
if show_cancelled then
|
||||
show_osd("Cancelled")
|
||||
end
|
||||
end
|
||||
|
||||
local function build_modifier_prefixes(modifiers)
|
||||
local prefixes = { "" }
|
||||
if type(modifiers) ~= "table" then
|
||||
return prefixes
|
||||
end
|
||||
|
||||
for _, modifier in ipairs(modifiers) do
|
||||
local mapped = MODIFIER_MAP[modifier]
|
||||
if mapped then
|
||||
local existing_count = #prefixes
|
||||
for index = 1, existing_count do
|
||||
prefixes[#prefixes + 1] = prefixes[index] .. mapped .. "+"
|
||||
end
|
||||
end
|
||||
end
|
||||
return prefixes
|
||||
end
|
||||
|
||||
local function start_numeric_selection(action_id, timeout_ms, starter_modifiers)
|
||||
clear_numeric_selection(false)
|
||||
local modifier_prefixes = build_modifier_prefixes(starter_modifiers)
|
||||
for digit = 1, 9 do
|
||||
local digit_string = tostring(digit)
|
||||
for _, prefix in ipairs(modifier_prefixes) do
|
||||
local key_name = prefix .. digit_string
|
||||
local modifier_name = prefix:gsub("[^%w]", "-")
|
||||
local name = "subminer-session-digit-" .. modifier_name .. digit_string
|
||||
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = name
|
||||
mp.add_forced_key_binding(key_name, name, function()
|
||||
clear_numeric_selection(false)
|
||||
invoke_cli_action(action_id, { count = digit })
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] =
|
||||
"subminer-session-digit-cancel"
|
||||
mp.add_forced_key_binding("ESC", "subminer-session-digit-cancel", function()
|
||||
clear_numeric_selection(true)
|
||||
end)
|
||||
|
||||
state.session_numeric_selection = {
|
||||
action_id = action_id,
|
||||
timeout = mp.add_timeout((timeout_ms or 3000) / 1000, function()
|
||||
clear_numeric_selection(false)
|
||||
show_osd(action_id == "copySubtitleMultiple" and "Copy timeout" or "Mine timeout")
|
||||
end),
|
||||
}
|
||||
|
||||
show_osd(
|
||||
action_id == "copySubtitleMultiple"
|
||||
and "Copy how many lines? Press 1-9 (Esc to cancel)"
|
||||
or "Mine how many lines? Press 1-9 (Esc to cancel)"
|
||||
)
|
||||
end
|
||||
|
||||
local function execute_mpv_command(command)
|
||||
if type(command) ~= "table" or command[1] == nil then
|
||||
return
|
||||
@@ -245,7 +306,7 @@ function M.create(ctx)
|
||||
mp.commandv(unpack_fn(command))
|
||||
end
|
||||
|
||||
local function handle_binding(binding)
|
||||
local function handle_binding(binding, numeric_selection_timeout_ms)
|
||||
if binding.actionType == "mpv-command" then
|
||||
execute_mpv_command(binding.command)
|
||||
return
|
||||
@@ -256,6 +317,11 @@ function M.create(ctx)
|
||||
return
|
||||
end
|
||||
|
||||
if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then
|
||||
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms, binding.key.modifiers)
|
||||
return
|
||||
end
|
||||
|
||||
invoke_cli_action(binding.actionId, binding.payload)
|
||||
end
|
||||
|
||||
@@ -278,6 +344,7 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
local function clear_bindings()
|
||||
clear_numeric_selection(false)
|
||||
remove_binding_names(state.session_binding_names)
|
||||
end
|
||||
|
||||
@@ -288,18 +355,21 @@ function M.create(ctx)
|
||||
return false
|
||||
end
|
||||
|
||||
clear_numeric_selection(false)
|
||||
|
||||
local previous_binding_names = state.session_binding_names
|
||||
local next_binding_names = {}
|
||||
state.session_binding_generation = (state.session_binding_generation or 0) + 1
|
||||
local generation = state.session_binding_generation
|
||||
|
||||
local timeout_ms = tonumber(artifact.numericSelectionTimeoutMs) or 3000
|
||||
for index, binding in ipairs(artifact.bindings) do
|
||||
local key_name = key_spec_to_mpv_binding(binding.key)
|
||||
if key_name then
|
||||
local name = "subminer-session-binding-" .. tostring(generation) .. "-" .. tostring(index)
|
||||
next_binding_names[#next_binding_names + 1] = name
|
||||
mp.add_forced_key_binding(key_name, name, function()
|
||||
handle_binding(binding)
|
||||
handle_binding(binding, timeout_ms)
|
||||
end)
|
||||
else
|
||||
subminer_log(
|
||||
|
||||
@@ -42,8 +42,6 @@ function M.new()
|
||||
pending_reload_media_identity = nil,
|
||||
pending_reload_media_title = nil,
|
||||
pending_reload_reason = nil,
|
||||
app_managed_playback_pending = false,
|
||||
app_managed_playback_active = false,
|
||||
auto_start_retry_generation = 0,
|
||||
session_binding_generation = 0,
|
||||
session_binding_names = {},
|
||||
|
||||
+22
-46
@@ -3,25 +3,23 @@
|
||||
## Highlights
|
||||
### Added
|
||||
|
||||
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections. Includes click-to-learn keybinding controls, AnkiConnect-backed deck/field/note-type pickers, and live reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, and Anki field mappings. AI and translation settings remain config-file only.
|
||||
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`. Options are organized into Appearance, Behavior, Anki, Input, and Integration sections with learned keybinding controls, AnkiConnect-backed deck/field/note-type pickers, and live reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, and Anki field mappings. AI and translation fields remain supported in config files only.
|
||||
|
||||
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`, with checksum verification, configurable notifications, and an opt-in prerelease channel. The `subminer` launcher and Linux rofi theme update automatically.
|
||||
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`. Checks include checksum verification, configurable notifications, and an opt-in channel for prerelease builds. The `subminer` launcher and Linux rofi theme are also updated automatically.
|
||||
|
||||
- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH. First-run setup includes an Open SubMiner Settings button.
|
||||
- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH. First-run setup also includes an Open SubMiner Settings button.
|
||||
|
||||
- **Launcher:** `subminer --version` / `subminer -v` now prints the installed app version. The new `mpv.profile` config option passes an mpv profile to SubMiner-managed mpv launches. Bundled mpv plugin startup options are now configurable from SubMiner config.
|
||||
- **Launcher:** `subminer --version` / `subminer -v` now prints the installed SubMiner app version.
|
||||
|
||||
### 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`. Existing configs are migrated automatically. Sidebar appearance is now configured via `subtitleSidebar.css`; the default subtitle font is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`.
|
||||
|
||||
- **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys 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 are still accepted with deprecation warnings. Existing configs that had known-word highlighting enabled retain N+1 highlighting; new configs leave N+1 disabled unless `ankiConnect.nPlusOne.enabled` is explicitly set.
|
||||
|
||||
- **Linux Updater:** Tray "Check for Updates" now 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 automatically installs the new AppImage via `electron-updater`, matching the macOS and Windows tray flow. AppImages managed by a system package (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow.
|
||||
|
||||
- **Subsync:** The subtitle sync dialog now always opens the manual picker; the `subsync.defaultMode` config option has been removed.
|
||||
|
||||
- **Jellyfin:** The server presets dropdown in Jellyfin setup is replaced by a single editable server URL field.
|
||||
- **Jellyfin:** The server presets dropdown in Jellyfin setup is removed; setup now shows a single editable server URL field.
|
||||
|
||||
- **AniSkip:** The key binding setting now uses click-to-learn key capture instead of raw text entry.
|
||||
|
||||
@@ -29,25 +27,9 @@
|
||||
|
||||
- **Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
|
||||
|
||||
- **Runtime:** The bundled Electron runtime is updated from 39.8.6 to 42.2.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **macOS Overlay:** Significantly improved overlay focus and stability: the overlay hides when mpv loses focus or is minimized, stays stable through transient window-tracking misses, remains correctly layered during stats mouse passthrough, and opens over fullscreen mpv without switching Spaces. Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. The overlay also stays stable when clicking from the overlay back into mpv. Background tracking overhead is reduced while mpv is stably focused.
|
||||
|
||||
- **Linux/Hyprland Overlay:** Overlay placement refreshes after leaving mpv fullscreen so the visible overlay stays aligned to the player. The visible overlay remains stacked above mpv after mpv regains focus from clicks, and is suspended while the in-player stats window is open.
|
||||
|
||||
- **Jellyfin Playback:** Resolved a wide range of Jellyfin discovery issues: the active item is no longer reloaded during startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, duplicate ready signals no longer re-show the overlay, and long-lived sidebar ffmpeg extractors no longer run against stream URLs. Discovery now correctly handles delayed Japanese subtitle selection and prevents later-loading foreign tracks from stealing the active Japanese track.
|
||||
|
||||
- **Jellyfin Subtitles:** Improved subtitle timing by preferring default embedded streams over external sidecars, stripping Jellyfin's server-selected stream from playback URLs, suppressing mpv auto-selection while SubMiner stages managed tracks, and automatically correcting clear Japanese-vs-English cue timeline offsets. Per-stream subtitle delay shifts are restored on load. Track selection now tolerates transient `track-list` read failures and numeric string track IDs on Linux.
|
||||
|
||||
- **Jellyfin Overlay:** The visible subtitle overlay now shows automatically during Jellyfin playback so `subtitleStyle` appearance applies. The bundled mpv plugin is injected when SubMiner auto-launches mpv for Jellyfin so mpv-side keybindings work without overlay focus. The `y-t` overlay toggle is reliable and remains sticky across stream redirects. Passive Linux/Hyprland overlay shows no longer steal keyboard focus from mpv.
|
||||
|
||||
- **Jellyfin Remote Progress:** Fixed progress sync for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows. Play and Resume are now distinct: Play starts from the beginning while Resume starts at the saved position. Final progress reports use SubMiner's last known position when mpv resets during stop. Discovery resume correctly handles `StartPositionTicks: 0` for items with saved progress.
|
||||
|
||||
- **Jellyfin Identity:** Cast device identity is now derived from the OS hostname. Multiple SubMiner installs no longer share the same remote-session identity, and SubMiner always reports itself as the client regardless of legacy configurable identity fields.
|
||||
|
||||
- **Jellyfin Tray:** The discovery tray checkbox stays in sync on Linux after tray, CLI, or startup remote-session changes. Stale discovery sessions restart automatically when the server no longer lists the SubMiner cast target. Library discovery works correctly when the app log level is set above info.
|
||||
- **macOS Overlay:** Significantly improved overlay focus and stability: the overlay now hides when mpv loses focus or is minimized, stays stable through transient window-tracking misses, remains correctly layered during stats mouse passthrough, and opens over fullscreen mpv without switching Spaces. Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. Background tracking overhead is reduced while mpv is stably focused.
|
||||
|
||||
- **Subtitle Sync Modal:** Fixed a macOS issue where opening the subtitle sync modal would flash and disappear on the first attempt, or leave stale state after syncing.
|
||||
|
||||
@@ -55,43 +37,37 @@
|
||||
|
||||
- **AniList Progress:** Progress threshold checks now use fresh playback position data so updates fire correctly when playback reaches or skips past the watched threshold. Season-specific results are preferred for multi-season files, and a clear message is shown when the matched season is not in Planning or Watching status.
|
||||
|
||||
- **Anki:** Sentence-audio padding is now opt-in by default. When padding is configured, animated AVIF freeze-frame duration is correctly aligned to the word audio length without double-counting sentence padding. Multi-line sentence mining stays aligned when repeated subtitle text appears in the selected history range. Manual clipboard card updates from YouTube playback now use mpv's resolved stream URLs for generated audio and images.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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; and Windows retains the native NSIS update path while routing updater HTTP through the main process. GitHub release lookups avoid Electron networking on Linux and macOS. Set `updates.channel` to `"prerelease"` to receive beta and RC builds.
|
||||
- **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 come to the front reliably, unsupported builds show a manual-install message, and Windows keeps the native NSIS update path while routing updater HTTP through the main process. GitHub release lookups now avoid Electron networking on Linux and macOS.
|
||||
|
||||
- **Setup - macOS:** First-run setup now recognizes existing `subminer` installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed.
|
||||
- **Setup - macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed - both return control to the terminal without requiring Ctrl+C.
|
||||
|
||||
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is available on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can close correctly without mpv running.
|
||||
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running.
|
||||
|
||||
- **Launcher:** Launcher-opened videos reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends. `subminer settings` on macOS no longer emits Electron menu diagnostics. Linux first-run launcher installs now build with a valid Bun shebang.
|
||||
- **Launcher - Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
|
||||
|
||||
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive.
|
||||
- **Launcher:** Launcher-opened videos now reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends.
|
||||
|
||||
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay can render text before video playback begins.
|
||||
|
||||
- **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered.
|
||||
|
||||
- **Shortcuts:** Native mpv menu shortcuts are disabled during managed macOS playback so configured SubMiner shortcuts work while mpv has focus. Session shortcuts including `stats.markWatchedKey` are correctly wired through mpv. The visible overlay receives focus when entering multi-line copy/mine selection so number keys work on macOS and Windows.
|
||||
- **macOS Shortcuts:** Native mpv menu shortcuts are disabled during managed macOS playback so configured SubMiner shortcuts work while mpv has focus. Session shortcuts including `stats.markWatchedKey` are now correctly wired through mpv.
|
||||
|
||||
- **Overlay Restart:** The visible overlay and subtitle stream stay alive after restarting SubMiner from the `y-r` shortcut, with correct bounds reapplication on Linux and user-paused playback preserved through readiness gates.
|
||||
- **Overlay Restart:** The visible overlay and subtitle stream now stay alive after restarting SubMiner from the `y-r` shortcut, with correct bounds reapplication on Linux and user-paused playback preserved through readiness gates.
|
||||
|
||||
- **Stats:** In-player stats layering is fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window. Jellyfin playback stats are grouped under item metadata instead of stream URLs, so watched episodes merge with matching local library titles and display clean names.
|
||||
- **WebSocket:** The subtitle WebSocket is now plain-text only; annotation spans and token metadata are sent exclusively on the annotation WebSocket.
|
||||
|
||||
- **Sidebar:** Yomitan lookup popups opened from the subtitle sidebar now correctly pause playback when popup auto-pause is enabled.
|
||||
|
||||
- **Discord Rich Presence:** Presence no longer falls back to Jellyfin stream URLs; Jellyfin playback titles are primed before loading tokenized streams so presence shows the show/episode title.
|
||||
|
||||
- **WebSocket:** The regular subtitle WebSocket now sends plain text only; annotation spans and token metadata are sent exclusively on the annotation WebSocket.
|
||||
- **Jellyfin:** Fixed the setup popup login path on Windows using an IPC bridge, with immediate login progress feedback and a timeout for unreachable server attempts.
|
||||
|
||||
- **Windows:** Startup failures now show a native error dialog and write fatal details to the app log instead of exiting silently.
|
||||
|
||||
- **Yomitan:** Fixed Yomitan popups not opening when overlay startup races the Yomitan extension load.
|
||||
|
||||
- **Settings:** Search now works across all categories, narrows correctly on multi-word terms, and hides settings with dedicated editors. Live saves for subtitle CSS declarations apply immediately to open overlays. Legacy subtitle appearance options and hover token colors are automatically migrated into `subtitleStyle.css`. The note-fields note type picker defaults to the configured Anki deck's note type, then `Kiku`, then `Lapis`, leaving it blank for manual selection otherwise. User config files are preserved during legacy config compatibility handling. The generated example config uses the same CSS declaration paths written by the Settings window.
|
||||
- **Settings:** Settings window search now searches across all categories, narrows correctly on multi-word terms, and hides settings with dedicated editors. Live saves for subtitle CSS declarations apply immediately to open overlays. Legacy subtitle appearance options and hover token colors are automatically migrated into `subtitleStyle.css`.
|
||||
|
||||
- **Build - Linux:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same invocation.
|
||||
- **Build - Linux:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
|
||||
|
||||
### Docs
|
||||
|
||||
|
||||
@@ -374,74 +374,6 @@ test('writeChangelogArtifacts renders breaking changes section above type sectio
|
||||
}
|
||||
});
|
||||
|
||||
test('writeChangelogArtifacts prompts Claude to summarize the final stable outcome instead of prerelease churn', async () => {
|
||||
const { writeChangelogArtifacts } = await loadModule();
|
||||
const workspace = createWorkspace('stable-outcome-prompt');
|
||||
const projectRoot = path.join(workspace, 'SubMiner');
|
||||
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'changes', '001.md'),
|
||||
[
|
||||
'type: added',
|
||||
'area: config',
|
||||
'',
|
||||
'- Added a dedicated Config window with launcher entry points.',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'changes', '002.md'),
|
||||
[
|
||||
'type: changed',
|
||||
'area: config',
|
||||
'breaking: true',
|
||||
'',
|
||||
'- Renamed the Config window to Settings window and changed the launcher entry point to `subminer settings`.',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'changes', '003.md'),
|
||||
[
|
||||
'type: fixed',
|
||||
'area: config',
|
||||
'',
|
||||
'- Fixed Settings window search and live subtitle CSS saves.',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
const stub = defaultStubClaude();
|
||||
writeChangelogArtifacts({
|
||||
cwd: projectRoot,
|
||||
version: '0.12.0',
|
||||
date: '2026-05-24',
|
||||
deps: { runClaude: stub.runClaude },
|
||||
});
|
||||
|
||||
const prompts = stub.calls.map((call) => call.input);
|
||||
assert.equal(prompts.length, 2, 'expected changelog and release-notes prompts');
|
||||
for (const prompt of prompts) {
|
||||
assert.match(prompt, /Treat the fragment list as one cumulative release outcome/);
|
||||
assert.match(
|
||||
prompt,
|
||||
/only if the final release requires action from users upgrading from the previous stable release/,
|
||||
);
|
||||
assert.match(prompt, /Config window.*Settings window/s);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('verifyChangelogFragments rejects invalid metadata', async () => {
|
||||
const { verifyChangelogFragments } = await loadModule();
|
||||
const workspace = createWorkspace('lint-invalid');
|
||||
@@ -643,74 +575,6 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
|
||||
}
|
||||
});
|
||||
|
||||
test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease bullets instead of appending fix churn', async () => {
|
||||
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||
const workspace = createWorkspace('prerelease-net-outcome-prompt');
|
||||
const projectRoot = path.join(workspace, 'SubMiner');
|
||||
const existingNotes = [
|
||||
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||
'',
|
||||
'## Highlights',
|
||||
'### Added',
|
||||
'- Config Window: Previous beta entry.',
|
||||
'',
|
||||
'## Installation',
|
||||
'',
|
||||
'See the README and docs/installation guide for full setup steps.',
|
||||
'',
|
||||
'## Assets',
|
||||
'',
|
||||
'- Linux: `SubMiner.AppImage`',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'package.json'),
|
||||
JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'changes', '001.md'),
|
||||
[
|
||||
'type: changed',
|
||||
'area: config',
|
||||
'breaking: true',
|
||||
'',
|
||||
'- Renamed the Config window to Settings window.',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'changes', '002.md'),
|
||||
['type: fixed', 'area: config', '', '- Fixed Settings window search.'].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
const stub = recordingRunClaude(() => '### Added\n- Settings Window: Current beta state.');
|
||||
writePrereleaseNotesForVersion({
|
||||
cwd: projectRoot,
|
||||
version: '0.12.0-beta.2',
|
||||
deps: { runClaude: stub.runClaude },
|
||||
});
|
||||
|
||||
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
|
||||
const prompt = stub.calls[0]!.input;
|
||||
assert.match(prompt, /EXISTING PRERELEASE NOTES/);
|
||||
assert.match(prompt, /Existing prerelease notes are a baseline, not an immutable changelog/);
|
||||
assert.match(prompt, /replace stale beta or RC wording/);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
|
||||
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||
const workspace = createWorkspace('prerelease-rc-notes');
|
||||
|
||||
@@ -235,13 +235,6 @@ const POLISH_PROMPT_INSTRUCTIONS = `You are formatting a software release change
|
||||
|
||||
You will receive a list of FRAGMENT entries below. Each fragment has metadata (type, area, breaking) and one or more bullet points written by the engineer who shipped that change. Your job is to merge, dedupe, and rewrite these fragments into a polished, user-facing release body.
|
||||
|
||||
## Release Outcome Rules
|
||||
|
||||
- Treat the fragment list as one cumulative release outcome, not a chronological log of beta/RC churn.
|
||||
- Put a fragment in ### Breaking Changes only if the final release requires action from users upgrading from the previous stable release. A breaking: true marker is a warning to preserve and evaluate the substance, not an automatic section choice.
|
||||
- If a breaking or fixed fragment only changes behavior introduced by another pending fragment in the same release cycle, merge it into the final Added or Changed bullet. Example: if fragments first add a Config window and later rename or fix it as a Settings window, output one Settings Window bullet under Added, not separate Config window, Breaking Changes, or Fixed bullets.
|
||||
- Multiple fixes within the same prerelease cycle should collapse into one current-state bullet that describes the final behavior.
|
||||
|
||||
## Output Rules
|
||||
|
||||
1. Output Markdown ONLY. No preamble, no commentary, no closing remarks. Start directly with the first section heading.
|
||||
@@ -265,7 +258,7 @@ You will receive a list of FRAGMENT entries below. Each fragment has metadata (t
|
||||
- Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
|
||||
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
|
||||
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
|
||||
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them.
|
||||
- Preserve the substance of every breaking change in ### Breaking Changes. Do not soften or omit them.
|
||||
5. Do not invent features. Every bullet must be grounded in the input fragments.
|
||||
6. Do not include the version heading (## v...) — that wrapper is added by the caller.
|
||||
|
||||
@@ -378,7 +371,7 @@ function polishFragmentsWithClaude(
|
||||
? [
|
||||
'## Existing Prerelease Notes',
|
||||
'',
|
||||
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, Installation, or Assets sections.',
|
||||
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Reuse those highlight bullets as the baseline, preserve their meaning and wording where possible, then merge in only new or changed fragment material. Deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, Installation, or Assets sections.',
|
||||
'',
|
||||
].join('\n')
|
||||
: '';
|
||||
|
||||
@@ -87,12 +87,6 @@ local process = process_module.create({
|
||||
detect_backend = function()
|
||||
return "x11"
|
||||
end,
|
||||
is_linux = function()
|
||||
return false
|
||||
end,
|
||||
is_subminer_app_running_async = function(callback)
|
||||
callback(false)
|
||||
end,
|
||||
},
|
||||
options_helper = {
|
||||
coerce_bool = function(value, default_value)
|
||||
@@ -131,79 +125,4 @@ for _, timeout_seconds in ipairs(recorded.timeouts) do
|
||||
end
|
||||
assert_true(retry_timeout_seen, "expected shorter bounded retry timeout")
|
||||
|
||||
do
|
||||
local visibility_state = {
|
||||
binary_path = "/tmp/subminer",
|
||||
overlay_running = true,
|
||||
texthooker_running = false,
|
||||
visible_overlay_requested = false,
|
||||
}
|
||||
local visibility_calls = {}
|
||||
local visibility_mp = {}
|
||||
|
||||
function visibility_mp.command_native_async(command, callback)
|
||||
visibility_calls[#visibility_calls + 1] = command
|
||||
if callback then
|
||||
callback(false, { status = 1, stdout = "", stderr = "failed" }, "failed")
|
||||
end
|
||||
end
|
||||
|
||||
local visibility_process = process_module.create({
|
||||
mp = visibility_mp,
|
||||
opts = {
|
||||
backend = "x11",
|
||||
socket_path = "/tmp/subminer.sock",
|
||||
log_level = "debug",
|
||||
texthooker_enabled = true,
|
||||
texthooker_port = 5174,
|
||||
auto_start_visible_overlay = false,
|
||||
},
|
||||
state = visibility_state,
|
||||
binary = {
|
||||
ensure_binary_available = function()
|
||||
return true
|
||||
end,
|
||||
},
|
||||
environment = {
|
||||
detect_backend = function()
|
||||
return "x11"
|
||||
end,
|
||||
is_linux = function()
|
||||
return false
|
||||
end,
|
||||
is_subminer_app_running_async = function(callback)
|
||||
callback(true)
|
||||
end,
|
||||
},
|
||||
options_helper = {
|
||||
coerce_bool = function(value, default_value)
|
||||
if value == true or value == "yes" or value == "true" then
|
||||
return true
|
||||
end
|
||||
if value == false or value == "no" or value == "false" then
|
||||
return false
|
||||
end
|
||||
return default_value
|
||||
end,
|
||||
},
|
||||
log = {
|
||||
subminer_log = function(_level, _scope, line)
|
||||
recorded.logs[#recorded.logs + 1] = line
|
||||
end,
|
||||
show_osd = function(_) end,
|
||||
normalize_log_level = function(value)
|
||||
return value or "info"
|
||||
end,
|
||||
},
|
||||
})
|
||||
|
||||
visibility_process.run_control_command_async("show-visible-overlay")
|
||||
|
||||
assert_true(#visibility_calls == 1, "expected visible overlay command to run")
|
||||
assert_true(
|
||||
visibility_state.visible_overlay_requested == false,
|
||||
"failed visible-overlay command should not update requested visibility state"
|
||||
)
|
||||
end
|
||||
|
||||
print("plugin process retry regression tests: OK")
|
||||
|
||||
@@ -368,10 +368,21 @@ assert_true(
|
||||
|
||||
starter.fn()
|
||||
|
||||
local modified_digit = nil
|
||||
for _, binding in ipairs(recorded.bindings) do
|
||||
if binding.keys == "Ctrl+Shift+3" then
|
||||
modified_digit = binding
|
||||
break
|
||||
end
|
||||
end
|
||||
assert_true(modified_digit ~= nil, "numeric selection should bind Ctrl+Shift+3")
|
||||
|
||||
modified_digit.fn()
|
||||
|
||||
local call = recorded.async_calls[#recorded.async_calls]
|
||||
assert_true(call ~= nil, "multi-line shortcut should invoke CLI action")
|
||||
assert_true(call ~= nil, "modified digit should invoke CLI action")
|
||||
assert_true(call[1] == "/tmp/subminer", "CLI action should use configured binary")
|
||||
assert_true(call[2] == "--mine-sentence-multiple", "CLI action should enter mine sentence count selector")
|
||||
assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count")
|
||||
assert_true(call[2] == "--mine-sentence-count", "CLI action should mine sentence count")
|
||||
assert_true(call[3] == "3", "CLI action should pass selected count")
|
||||
|
||||
print("plugin session binding regression tests: OK")
|
||||
|
||||
@@ -645,36 +645,6 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local scenario = {
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
path = "/media/aborted-app-managed.m3u8",
|
||||
media_title = "Aborted App Managed",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
}
|
||||
local recorded, err = run_plugin_scenario(scenario)
|
||||
assert_true(recorded ~= nil, "plugin failed to load for aborted app-managed scenario: " .. tostring(err))
|
||||
recorded.script_messages["subminer-managed-subtitles-loading"]()
|
||||
fire_event(recorded, "end-file", { reason = "error" })
|
||||
scenario.path = "/media/next-normal.mkv"
|
||||
scenario.media_title = "Next Normal"
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(
|
||||
count_start_calls(recorded.async_calls) == 1,
|
||||
"aborted app-managed playback should not leak pending state into the next item"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local scenario = {
|
||||
process_list = "",
|
||||
@@ -744,7 +714,7 @@ do
|
||||
fire_event(recorded, "start-file")
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
||||
"app-side hide sync should suppress path-changing Jellyfin redirect visible overlay reassertion"
|
||||
)
|
||||
assert_true(
|
||||
@@ -782,13 +752,13 @@ do
|
||||
"duplicate same-tick visible overlay toggles should hide once"
|
||||
)
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
||||
"duplicate same-tick visible overlay toggles should not immediately show the overlay again"
|
||||
)
|
||||
scenario.now = 10.5
|
||||
recorded.script_messages["subminer-toggle"]()
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
|
||||
"later visible overlay toggle should still show after duplicate suppression window"
|
||||
)
|
||||
end
|
||||
@@ -874,7 +844,7 @@ do
|
||||
"y-t should avoid app-side toggle when plugin knows the overlay is visible"
|
||||
)
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
||||
"manual y-t hide should suppress duplicate auto-start and ready-time visible overlay reassertion"
|
||||
)
|
||||
assert_true(
|
||||
@@ -883,68 +853,6 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "no",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Jellyfin Managed Playback",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for managed Jellyfin subtitle preload scenario: " .. tostring(err))
|
||||
assert_true(
|
||||
recorded.script_messages["subminer-managed-subtitles-loading"] ~= nil,
|
||||
"managed subtitle preload script message should be registered"
|
||||
)
|
||||
recorded.script_messages["subminer-managed-subtitles-loading"]()
|
||||
fire_event(recorded, "start-file")
|
||||
fire_event(recorded, "file-loaded")
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(
|
||||
not has_property_set(recorded.property_sets, "sid", "auto"),
|
||||
"managed Jellyfin preload should not rearm primary subtitle auto-selection before app-selected subtitles load"
|
||||
)
|
||||
assert_true(
|
||||
not has_property_set(recorded.property_sets, "secondary-sid", "auto"),
|
||||
"managed Jellyfin preload should not rearm secondary subtitle auto-selection before app-selected subtitles load"
|
||||
)
|
||||
assert_true(
|
||||
not has_property_set(recorded.property_sets, "sub-auto", "fuzzy"),
|
||||
"managed Jellyfin preload should not re-enable subtitle autoloading before app-selected subtitles load"
|
||||
)
|
||||
assert_true(
|
||||
count_start_calls(recorded.async_calls) == 0,
|
||||
"managed Jellyfin preload should let the app show the overlay after subtitle preload instead of plugin auto-start"
|
||||
)
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
|
||||
"managed Jellyfin preload should not reassert the visible overlay during duplicate file-loaded events"
|
||||
)
|
||||
assert_true(
|
||||
count_property_set(recorded.property_sets, "pause", true) == 0,
|
||||
"managed Jellyfin preload should not arm the plugin pause gate before app-selected subtitles load"
|
||||
)
|
||||
fire_event(recorded, "end-file", { reason = "stop" })
|
||||
fire_event(recorded, "start-file")
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(
|
||||
count_property_set(recorded.property_sets, "sid", "auto") == 1,
|
||||
"managed subtitle preload suppression should only apply to one playback lifecycle"
|
||||
)
|
||||
assert_true(
|
||||
count_start_calls(recorded.async_calls) == 1,
|
||||
"plugin auto-start should resume after the managed Jellyfin lifecycle ends"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
@@ -1194,8 +1102,8 @@ do
|
||||
recorded.script_messages["subminer-restart"]()
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
||||
"manual restart should avoid a second visible overlay restore after launch already requested visibility"
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
|
||||
"manual restart should re-assert visible overlay after launch and readiness even when auto-start visibility is disabled"
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1590,8 +1498,8 @@ do
|
||||
"auto-start with visible overlay enabled should not include --hide-visible-overlay on --start"
|
||||
)
|
||||
assert_true(
|
||||
find_control_call(recorded.async_calls, "--show-visible-overlay") == nil,
|
||||
"auto-start with visible overlay enabled should rely on the --start visibility flag instead of a separate --show-visible-overlay command"
|
||||
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil,
|
||||
"auto-start with visible overlay enabled should issue a separate --show-visible-overlay command"
|
||||
)
|
||||
assert_true(
|
||||
not has_property_set(recorded.property_sets, "pause", true),
|
||||
@@ -1622,8 +1530,8 @@ do
|
||||
"duplicate file-loaded events should not issue duplicate --start commands while overlay is already running"
|
||||
)
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
|
||||
"duplicate auto-start should not re-assert visible overlay state when it is already requested"
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
|
||||
"duplicate auto-start should re-assert visible overlay state when overlay is already running"
|
||||
)
|
||||
assert_true(
|
||||
count_osd_message(recorded.osd, "SubMiner: Already running") == 0,
|
||||
@@ -1658,8 +1566,8 @@ do
|
||||
"duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running"
|
||||
)
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
|
||||
"duplicate pause-until-ready auto-start should not re-assert visible overlay after the start command already requested it"
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 3,
|
||||
"duplicate pause-until-ready auto-start should re-assert visible overlay on initial start, ready, and later file load"
|
||||
)
|
||||
assert_true(
|
||||
count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 1,
|
||||
@@ -1720,8 +1628,8 @@ do
|
||||
"autoplay-ready should show loaded OSD message"
|
||||
)
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
|
||||
"autoplay-ready should not re-assert visible overlay state after the start command already requested it"
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
|
||||
"autoplay-ready should re-assert visible overlay state"
|
||||
)
|
||||
assert_true(
|
||||
#recorded.periodic_timers == 1,
|
||||
@@ -1755,8 +1663,8 @@ do
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
|
||||
"duplicate autoplay-ready signals should not spawn visible overlay restore commands when start already requested visibility"
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
|
||||
"duplicate autoplay-ready signals should not repeatedly spawn visible overlay restore commands"
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1821,7 +1729,7 @@ do
|
||||
)
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
||||
"manual toggle-off before readiness should suppress ready-time visible overlay restore"
|
||||
)
|
||||
assert_true(
|
||||
@@ -1856,7 +1764,7 @@ do
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
||||
"manual toggle-off should suppress repeated ready-time visible overlay restores for the same session"
|
||||
)
|
||||
end
|
||||
@@ -1886,7 +1794,7 @@ do
|
||||
fire_event(recorded, "file-loaded")
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
||||
"manual toggle-off should suppress duplicate auto-start visible overlay reassertion"
|
||||
)
|
||||
assert_true(
|
||||
@@ -1921,7 +1829,7 @@ do
|
||||
fire_event(recorded, "end-file", { reason = "redirect" })
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
|
||||
"manual toggle-off should suppress same-media reload visible overlay reassertion"
|
||||
)
|
||||
assert_true(
|
||||
@@ -1960,7 +1868,7 @@ do
|
||||
fire_event(recorded, "start-file")
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 0,
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
|
||||
"manual toggle-off should suppress path-changing Jellyfin redirect visible overlay reassertion even if media-title drops"
|
||||
)
|
||||
assert_true(
|
||||
@@ -2134,8 +2042,8 @@ do
|
||||
"auto-start with visible overlay disabled should not include --show-visible-overlay on --start"
|
||||
)
|
||||
assert_true(
|
||||
find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil,
|
||||
"auto-start with visible overlay disabled should rely on the --start visibility flag instead of a separate --hide-visible-overlay command"
|
||||
find_control_call(recorded.async_calls, "--hide-visible-overlay") ~= nil,
|
||||
"auto-start with visible overlay disabled should issue a separate --hide-visible-overlay command"
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
|
||||
assert.equal(leadInSeconds, 1.25);
|
||||
});
|
||||
|
||||
test('resolveAnimatedImageLeadInSeconds does not double-count sentence audio padding', async () => {
|
||||
test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audio duration', async () => {
|
||||
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
|
||||
config: {
|
||||
fields: {
|
||||
@@ -87,7 +87,7 @@ test('resolveAnimatedImageLeadInSeconds does not double-count sentence audio pad
|
||||
logWarn: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(leadInSeconds, 1.25);
|
||||
assert.equal(leadInSeconds, 1.75);
|
||||
});
|
||||
|
||||
test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => {
|
||||
|
||||
@@ -39,6 +39,14 @@ function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'med
|
||||
return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false;
|
||||
}
|
||||
|
||||
function resolveSentenceAudioStartOffsetSeconds(config: Pick<AnkiConnectConfig, 'media'>): number {
|
||||
const configuredPadding = config.media?.audioPadding;
|
||||
if (typeof configuredPadding === 'number' && Number.isFinite(configuredPadding)) {
|
||||
return configuredPadding;
|
||||
}
|
||||
return DEFAULT_ANKI_CONNECT_CONFIG.media.audioPadding;
|
||||
}
|
||||
|
||||
export async function probeAudioDurationSeconds(
|
||||
buffer: Buffer,
|
||||
filename: string,
|
||||
@@ -127,5 +135,5 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
|
||||
totalLeadInSeconds += durationSeconds;
|
||||
}
|
||||
|
||||
return totalLeadInSeconds;
|
||||
return totalLeadInSeconds + resolveSentenceAudioStartOffsetSeconds(config);
|
||||
}
|
||||
|
||||
@@ -175,99 +175,3 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
||||
assert.deepEqual(updatedFields[0], { Sentence: '字幕' });
|
||||
assert.equal(mergeCalls.length, 0);
|
||||
});
|
||||
|
||||
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
|
||||
const audioPaths: string[] = [];
|
||||
const imagePaths: string[] = [];
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
const { service, updatedFields, storedMedia } = createManualUpdateService({
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
audio: 'ExpressionAudio',
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
imageFormat: 'jpg',
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {
|
||||
overwriteAudio: false,
|
||||
overwriteImage: false,
|
||||
},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getTimingTracker: () =>
|
||||
({
|
||||
findTiming: (text: string) => {
|
||||
if (text === '一行目') return { startTime: 10, endTime: 12 };
|
||||
if (text === '二行目') return { startTime: 12.5, endTime: 14 };
|
||||
return null;
|
||||
},
|
||||
}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
currentTimePos: 13,
|
||||
currentAudioStreamIndex: 0,
|
||||
requestProperty: async (name: string) => {
|
||||
assert.equal(name, 'stream-open-filename');
|
||||
return edlSource;
|
||||
},
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async () => 0,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: '単語' },
|
||||
Sentence: { value: '' },
|
||||
ExpressionAudio: { value: '[sound:auto-expression.mp3]' },
|
||||
SentenceAudio: { value: '[sound:auto-sentence.mp3]' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (_noteId, fields) => {
|
||||
updatedFields.push(fields);
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path) => {
|
||||
audioPaths.push(path);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async (path) => {
|
||||
imagePaths.push(path);
|
||||
return Buffer.from('image');
|
||||
},
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
});
|
||||
|
||||
await service.updateLastAddedFromClipboard('一行目\n\n二行目');
|
||||
|
||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
|
||||
assert.match(updatedFields[0]?.Picture ?? '', /^<img src="image_\d+\.jpg">$/);
|
||||
});
|
||||
|
||||
@@ -237,19 +237,14 @@ export class CardCreationService {
|
||||
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
|
||||
);
|
||||
|
||||
const audioSourcePath = this.deps.getConfig().media?.generateAudio
|
||||
? await resolveMediaGenerationInputPath(mpvClient, 'audio')
|
||||
: null;
|
||||
const videoPath = this.deps.getConfig().media?.generateImage
|
||||
? await resolveMediaGenerationInputPath(mpvClient, 'video')
|
||||
: null;
|
||||
|
||||
if (this.deps.getConfig().media?.generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
? await this.mediaGenerateAudio(audioSourcePath, rangeStart, rangeEnd)
|
||||
: null;
|
||||
const audioBuffer = await this.mediaGenerateAudio(
|
||||
mpvClient.currentVideoPath,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
@@ -276,14 +271,12 @@ export class CardCreationService {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.generateImageFilename();
|
||||
const imageBuffer = videoPath
|
||||
? await this.generateImageBuffer(
|
||||
videoPath,
|
||||
const imageBuffer = await this.generateImageBuffer(
|
||||
mpvClient.currentVideoPath,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
animatedLeadInSeconds,
|
||||
)
|
||||
: null;
|
||||
);
|
||||
|
||||
if (imageBuffer) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
|
||||
@@ -61,7 +61,6 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.texthooker.launchAtStartup, false);
|
||||
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
|
||||
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
|
||||
assert.equal(config.ankiConnect.media.audioPadding, 0);
|
||||
assert.equal(config.anilist.enabled, false);
|
||||
assert.equal(config.anilist.characterDictionary.enabled, false);
|
||||
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
|
||||
@@ -154,7 +153,6 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.updates.channel, 'stable');
|
||||
assert.equal(config.mpv.socketPath, '/tmp/subminer-socket');
|
||||
assert.equal(config.mpv.backend, 'auto');
|
||||
assert.equal(config.mpv.profile, '');
|
||||
assert.equal(config.mpv.autoStartSubMiner, true);
|
||||
assert.equal(config.mpv.pauseUntilOverlayReady, true);
|
||||
assert.equal(config.mpv.subminerBinaryPath, '');
|
||||
@@ -362,7 +360,6 @@ test('parses managed mpv plugin runtime settings from config', () => {
|
||||
"mpv": {
|
||||
"socketPath": "/tmp/custom-subminer.sock",
|
||||
"backend": "x11",
|
||||
"profile": " anime ",
|
||||
"autoStartSubMiner": false,
|
||||
"pauseUntilOverlayReady": false,
|
||||
"subminerBinaryPath": "/opt/SubMiner/SubMiner.AppImage",
|
||||
@@ -377,7 +374,6 @@ test('parses managed mpv plugin runtime settings from config', () => {
|
||||
const config = validService.getConfig();
|
||||
assert.equal(config.mpv.socketPath, '/tmp/custom-subminer.sock');
|
||||
assert.equal(config.mpv.backend, 'x11');
|
||||
assert.equal(config.mpv.profile, 'anime');
|
||||
assert.equal(config.mpv.autoStartSubMiner, false);
|
||||
assert.equal(config.mpv.pauseUntilOverlayReady, false);
|
||||
assert.equal(config.mpv.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||
@@ -391,7 +387,6 @@ test('parses managed mpv plugin runtime settings from config', () => {
|
||||
"mpv": {
|
||||
"socketPath": "",
|
||||
"backend": "weston",
|
||||
"profile": 12,
|
||||
"autoStartSubMiner": "yes",
|
||||
"pauseUntilOverlayReady": "no",
|
||||
"subminerBinaryPath": 42,
|
||||
@@ -407,7 +402,6 @@ test('parses managed mpv plugin runtime settings from config', () => {
|
||||
const warnings = invalidService.getWarnings();
|
||||
assert.equal(invalidConfig.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
|
||||
assert.equal(invalidConfig.mpv.backend, DEFAULT_CONFIG.mpv.backend);
|
||||
assert.equal(invalidConfig.mpv.profile, DEFAULT_CONFIG.mpv.profile);
|
||||
assert.equal(invalidConfig.mpv.autoStartSubMiner, DEFAULT_CONFIG.mpv.autoStartSubMiner);
|
||||
assert.equal(invalidConfig.mpv.pauseUntilOverlayReady, DEFAULT_CONFIG.mpv.pauseUntilOverlayReady);
|
||||
assert.equal(invalidConfig.mpv.subminerBinaryPath, DEFAULT_CONFIG.mpv.subminerBinaryPath);
|
||||
@@ -415,7 +409,6 @@ test('parses managed mpv plugin runtime settings from config', () => {
|
||||
assert.equal(invalidConfig.mpv.aniskipButtonKey, DEFAULT_CONFIG.mpv.aniskipButtonKey);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.socketPath'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.backend'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.profile'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.autoStartSubMiner'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.pauseUntilOverlayReady'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.subminerBinaryPath'));
|
||||
|
||||
@@ -24,7 +24,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
upstreamUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
tags: ['SubMiner'],
|
||||
deck: '',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
audio: 'ExpressionAudio',
|
||||
@@ -44,14 +43,14 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
imageType: 'static',
|
||||
imageFormat: 'jpg',
|
||||
imageQuality: 92,
|
||||
imageMaxWidth: 0,
|
||||
imageMaxHeight: 0,
|
||||
imageMaxWidth: undefined,
|
||||
imageMaxHeight: undefined,
|
||||
animatedFps: 10,
|
||||
animatedMaxWidth: 640,
|
||||
animatedMaxHeight: 0,
|
||||
animatedMaxHeight: undefined,
|
||||
animatedCrf: 35,
|
||||
syncAnimatedImageToWordAudio: true,
|
||||
audioPadding: 0,
|
||||
audioPadding: 0.5,
|
||||
fallbackDuration: 3.0,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
@@ -89,15 +88,12 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
},
|
||||
jimaku: {
|
||||
apiBaseUrl: 'https://jimaku.cc',
|
||||
apiKey: '',
|
||||
apiKeyCommand: '',
|
||||
languagePreference: 'ja',
|
||||
maxEntryResults: 10,
|
||||
},
|
||||
mpv: {
|
||||
executablePath: '',
|
||||
launchMode: 'normal',
|
||||
profile: '',
|
||||
socketPath: getDefaultMpvSocketPath(),
|
||||
backend: 'auto',
|
||||
autoStartSubMiner: true,
|
||||
|
||||
@@ -105,7 +105,6 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'mpv.executablePath',
|
||||
'mpv.launchMode',
|
||||
'mpv.profile',
|
||||
'mpv.socketPath',
|
||||
'mpv.backend',
|
||||
'mpv.autoStartSubMiner',
|
||||
|
||||
@@ -58,13 +58,6 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.deck',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.deck,
|
||||
description:
|
||||
'Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.word',
|
||||
kind: 'string',
|
||||
@@ -207,14 +200,14 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageMaxWidth,
|
||||
description:
|
||||
'Maximum width for static images, in pixels. Set to 0 to preserve the source resolution.',
|
||||
'Optional maximum width for static images. Leave unset to preserve the source resolution.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageMaxHeight',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageMaxHeight,
|
||||
description:
|
||||
'Maximum height for static images, in pixels. Set to 0 to preserve the source resolution.',
|
||||
'Optional maximum height for static images. Leave unset to preserve the source resolution.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedFps',
|
||||
@@ -233,7 +226,7 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.animatedMaxHeight,
|
||||
description:
|
||||
'Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.',
|
||||
'Optional maximum height for animated AVIF captures. Leave unset to preserve aspect ratio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedCrf',
|
||||
@@ -352,20 +345,6 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.jimaku.apiBaseUrl,
|
||||
description: 'Base URL of the Jimaku subtitle search API.',
|
||||
},
|
||||
{
|
||||
path: 'jimaku.apiKey',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jimaku.apiKey,
|
||||
description:
|
||||
'Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.',
|
||||
},
|
||||
{
|
||||
path: 'jimaku.apiKeyCommand',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jimaku.apiKeyCommand,
|
||||
description:
|
||||
'Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.',
|
||||
},
|
||||
{
|
||||
path: 'jimaku.languagePreference',
|
||||
kind: 'enum',
|
||||
@@ -471,13 +450,6 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.mpv.launchMode,
|
||||
description: 'Default window state for SubMiner-managed mpv launches.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.profile',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.mpv.profile,
|
||||
description:
|
||||
'Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.socketPath',
|
||||
kind: 'string',
|
||||
|
||||
@@ -175,7 +175,6 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
'Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.',
|
||||
'autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.',
|
||||
'Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.',
|
||||
'Set mpv.profile to pass an mpv profile to managed mpv launches; leave it blank to pass none.',
|
||||
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
|
||||
],
|
||||
key: 'mpv',
|
||||
|
||||
@@ -254,13 +254,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const profile = asString(src.mpv.profile);
|
||||
if (profile !== undefined) {
|
||||
resolved.mpv.profile = profile.trim();
|
||||
} else if (src.mpv.profile !== undefined) {
|
||||
warn('mpv.profile', src.mpv.profile, resolved.mpv.profile, 'Expected string.');
|
||||
}
|
||||
|
||||
const socketPath = asString(src.mpv.socketPath);
|
||||
if (socketPath !== undefined && socketPath.trim().length > 0) {
|
||||
resolved.mpv.socketPath = socketPath.trim();
|
||||
|
||||
@@ -24,8 +24,6 @@ test('settings registry splits viewing into appearance and behavior categories',
|
||||
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
|
||||
assert.equal(field('mpv.launchMode').category, 'behavior');
|
||||
assert.equal(field('mpv.launchMode').section, 'mpv Playback');
|
||||
assert.equal(field('mpv.profile').category, 'behavior');
|
||||
assert.equal(field('mpv.profile').section, 'mpv Playback');
|
||||
assert.ok(
|
||||
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
|
||||
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
|
||||
@@ -296,7 +294,6 @@ test('settings registry keeps unsafe config siblings restart-required', () => {
|
||||
'ankiConnect.url',
|
||||
'ankiConnect.proxy.enabled',
|
||||
'mpv.socketPath',
|
||||
'mpv.profile',
|
||||
'websocket.port',
|
||||
]) {
|
||||
assert.equal(field(path).restartBehavior, 'restart', path);
|
||||
|
||||
@@ -180,7 +180,6 @@ const PATH_ORDER = new Map<string, number>(
|
||||
'mpv.backend',
|
||||
'mpv.subminerBinaryPath',
|
||||
'mpv.aniskipEnabled',
|
||||
'mpv.profile',
|
||||
'mpv.launchMode',
|
||||
'mpv.executablePath',
|
||||
'mpv.aniskipButtonKey',
|
||||
@@ -222,7 +221,6 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'mpv.executablePath': 'mpv Executable Path',
|
||||
'mpv.subminerBinaryPath': 'SubMiner Binary Path',
|
||||
'mpv.socketPath': 'mpv IPC Socket Path',
|
||||
'mpv.profile': 'mpv Profile',
|
||||
'mpv.autoStartSubMiner': 'Auto-start SubMiner',
|
||||
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||
|
||||
@@ -116,12 +116,6 @@ export {
|
||||
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
|
||||
ticksToSeconds as jellyfinTicksToSecondsRuntime,
|
||||
} from './jellyfin';
|
||||
export { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay';
|
||||
export {
|
||||
estimateSubtitleTimingOffset,
|
||||
type SubtitleTimingOffsetOptions,
|
||||
type SubtitleTimingOffsetResult,
|
||||
} from './subtitle-timing-offset';
|
||||
export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
export {
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay';
|
||||
|
||||
function statePath(name: string): string {
|
||||
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-delay-')), name);
|
||||
}
|
||||
|
||||
test('jellyfin subtitle delay store saves and loads delay by item and stream', () => {
|
||||
const filePath = statePath('delays.json');
|
||||
|
||||
assert.equal(
|
||||
saveJellyfinSubtitleDelay({
|
||||
filePath,
|
||||
itemId: 'episode-1',
|
||||
streamIndex: 3,
|
||||
delaySeconds: 1.25,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 1.25);
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), null);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle delay store preserves other stream delays when updating one stream', () => {
|
||||
const filePath = statePath('delays.json');
|
||||
|
||||
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 1.25 });
|
||||
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4, delaySeconds: -0.5 });
|
||||
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 2 });
|
||||
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 2);
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), -0.5);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle delay store ignores invalid files and values', () => {
|
||||
const filePath = statePath('delays.json');
|
||||
fs.writeFileSync(filePath, '{');
|
||||
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), null);
|
||||
assert.equal(
|
||||
saveJellyfinSubtitleDelay({
|
||||
filePath,
|
||||
itemId: 'episode-1',
|
||||
streamIndex: 3,
|
||||
delaySeconds: Number.NaN,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
type JellyfinSubtitleDelayStore = {
|
||||
version?: unknown;
|
||||
delays?: unknown;
|
||||
};
|
||||
|
||||
type JellyfinSubtitleDelayParams = {
|
||||
filePath: string;
|
||||
itemId: string;
|
||||
streamIndex: number;
|
||||
};
|
||||
|
||||
type SaveJellyfinSubtitleDelayParams = JellyfinSubtitleDelayParams & {
|
||||
delaySeconds: number;
|
||||
};
|
||||
|
||||
function storeKey(itemId: string, streamIndex: number): string {
|
||||
return JSON.stringify([itemId, streamIndex]);
|
||||
}
|
||||
|
||||
function readDelayMap(filePath: string): Record<string, number> {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as JellyfinSubtitleDelayStore;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
!parsed.delays ||
|
||||
typeof parsed.delays !== 'object'
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
const delays: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(parsed.delays as Record<string, unknown>)) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
delays[key] = value;
|
||||
}
|
||||
}
|
||||
return delays;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadJellyfinSubtitleDelay(params: JellyfinSubtitleDelayParams): number | null {
|
||||
const delay = readDelayMap(params.filePath)[storeKey(params.itemId, params.streamIndex)];
|
||||
return typeof delay === 'number' && Number.isFinite(delay) ? delay : null;
|
||||
}
|
||||
|
||||
export function saveJellyfinSubtitleDelay(params: SaveJellyfinSubtitleDelayParams): boolean {
|
||||
if (!Number.isFinite(params.delaySeconds)) return false;
|
||||
try {
|
||||
const delays = readDelayMap(params.filePath);
|
||||
delays[storeKey(params.itemId, params.streamIndex)] = params.delaySeconds;
|
||||
const dir = path.dirname(params.filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(params.filePath, JSON.stringify({ version: 1, delays }, null, 2));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -229,7 +229,6 @@ test('resolvePlaybackPlan chooses direct play when allowed', async () => {
|
||||
assert.equal(plan.mode, 'direct');
|
||||
assert.match(plan.url, /Videos\/movie-1\/stream\?/);
|
||||
assert.doesNotMatch(plan.url, /SubtitleStreamIndex=/);
|
||||
assert.equal(new URL(plan.url).searchParams.get('StartTimeTicks'), null);
|
||||
assert.equal(plan.subtitleStreamIndex, null);
|
||||
assert.equal(ticksToSeconds(plan.startTimeTicks), 2);
|
||||
} finally {
|
||||
@@ -571,7 +570,7 @@ test('resolvePlaybackPlan preserves episode metadata, stream selection, and resu
|
||||
const url = new URL(plan.url);
|
||||
assert.equal(url.searchParams.get('AudioStreamIndex'), '6');
|
||||
assert.equal(url.searchParams.get('SubtitleStreamIndex'), '9');
|
||||
assert.equal(url.searchParams.get('StartTimeTicks'), null);
|
||||
assert.equal(url.searchParams.get('StartTimeTicks'), '35000000');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -233,6 +233,9 @@ function createDirectPlayUrl(
|
||||
if (plan.subtitleStreamIndex !== null) {
|
||||
query.set('SubtitleStreamIndex', String(plan.subtitleStreamIndex));
|
||||
}
|
||||
if (plan.startTimeTicks > 0) {
|
||||
query.set('StartTimeTicks', String(plan.startTimeTicks));
|
||||
}
|
||||
return `${session.serverUrl}/Videos/${itemId}/stream?${query.toString()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
handleMultiCopyDigit,
|
||||
mineSentenceCard,
|
||||
} from './mining';
|
||||
import { SubtitleTimingTracker } from '../../subtitle-timing-tracker';
|
||||
|
||||
test('copyCurrentSubtitle reports tracker and subtitle guards', () => {
|
||||
const osd: string[] = [];
|
||||
@@ -208,76 +207,3 @@ test('handleMineSentenceDigit increments successful card count', async () => {
|
||||
|
||||
assert.equal(cardsMined, 1);
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => {
|
||||
const created: Array<{ sentence: string; startTime: number; endTime: number }> = [];
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
|
||||
try {
|
||||
tracker.recordSubtitle('same', 1, 2);
|
||||
tracker.recordSubtitle('other', 3, 4);
|
||||
tracker.recordSubtitle('same', 5, 6);
|
||||
|
||||
handleMineSentenceDigit(3, {
|
||||
subtitleTimingTracker: tracker,
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async (sentence, startTime, endTime) => {
|
||||
created.push({ sentence, startTime, endTime });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
getCurrentSecondarySubText: () => undefined,
|
||||
showMpvOsd: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(created, [{ sentence: 'same other same', startTime: 1, endTime: 6 }]);
|
||||
} finally {
|
||||
tracker.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit joins per-entry secondary subtitles when available', async () => {
|
||||
const created: Array<{ sentence: string; secondarySub?: string }> = [];
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
const recordSubtitleWithSecondary = tracker.recordSubtitle as (
|
||||
text: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
secondaryText?: string,
|
||||
) => void;
|
||||
|
||||
try {
|
||||
recordSubtitleWithSecondary.call(tracker, 'one', 1, 2, 'translation one');
|
||||
recordSubtitleWithSecondary.call(tracker, 'two', 3, 4, 'translation two');
|
||||
|
||||
handleMineSentenceDigit(2, {
|
||||
subtitleTimingTracker: tracker,
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => {
|
||||
created.push({ sentence, secondarySub });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
getCurrentSecondarySubText: () => 'current translation only',
|
||||
showMpvOsd: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(created, [
|
||||
{ sentence: 'one two', secondarySub: 'translation one translation two' },
|
||||
]);
|
||||
} finally {
|
||||
tracker.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
|
||||
|
||||
interface SubtitleTimingTrackerLike {
|
||||
getRecentBlocks: (count: number) => string[];
|
||||
getRecentEntries?: (count: number) => SubtitleTimingBlock[];
|
||||
getCurrentSubtitle: () => string | null;
|
||||
findTiming: (text: string) => { startTime: number; endTime: number } | null;
|
||||
}
|
||||
@@ -82,19 +79,6 @@ function requireAnkiIntegration(
|
||||
return ankiIntegration;
|
||||
}
|
||||
|
||||
function getSecondarySubTextForMinedBlocks(
|
||||
entries: SubtitleTimingBlock[] | undefined,
|
||||
getCurrentSecondarySubText: () => string | undefined,
|
||||
): string | undefined {
|
||||
const secondaryBlocks = entries
|
||||
?.map((entry) => entry.secondaryText?.trim())
|
||||
.filter((text): text is string => Boolean(text));
|
||||
if (secondaryBlocks && secondaryBlocks.length > 0) {
|
||||
return secondaryBlocks.join(' ');
|
||||
}
|
||||
return getCurrentSecondarySubText();
|
||||
}
|
||||
|
||||
export async function updateLastCardFromClipboard(deps: {
|
||||
ankiIntegration: AnkiIntegrationLike | null;
|
||||
readClipboardText: () => string;
|
||||
@@ -162,20 +146,17 @@ export function handleMineSentenceDigit(
|
||||
): void {
|
||||
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
|
||||
|
||||
const entries = deps.subtitleTimingTracker.getRecentEntries?.(count);
|
||||
const blocks =
|
||||
entries?.map((entry) => entry.displayText) ?? deps.subtitleTimingTracker.getRecentBlocks(count);
|
||||
const blocks = deps.subtitleTimingTracker.getRecentBlocks(count);
|
||||
if (blocks.length === 0) {
|
||||
deps.showMpvOsd('No subtitle history available');
|
||||
return;
|
||||
}
|
||||
|
||||
const timings: { startTime: number; endTime: number }[] =
|
||||
entries ??
|
||||
blocks.flatMap((block) => {
|
||||
const timing = deps.subtitleTimingTracker?.findTiming(block);
|
||||
return timing ? [timing] : [];
|
||||
});
|
||||
const timings: { startTime: number; endTime: number }[] = [];
|
||||
for (const block of blocks) {
|
||||
const timing = deps.subtitleTimingTracker.findTiming(block);
|
||||
if (timing) timings.push(timing);
|
||||
}
|
||||
|
||||
if (timings.length === 0) {
|
||||
deps.showMpvOsd('Subtitle timing not found');
|
||||
@@ -185,13 +166,9 @@ export function handleMineSentenceDigit(
|
||||
const rangeStart = Math.min(...timings.map((t) => t.startTime));
|
||||
const rangeEnd = Math.max(...timings.map((t) => t.endTime));
|
||||
const sentence = blocks.join(' ');
|
||||
const secondarySubText = getSecondarySubTextForMinedBlocks(
|
||||
entries,
|
||||
deps.getCurrentSecondarySubText,
|
||||
);
|
||||
const cardsToMine = 1;
|
||||
deps.ankiIntegration
|
||||
.createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText)
|
||||
.createSentenceCard(sentence, rangeStart, rangeEnd, deps.getCurrentSecondarySubText())
|
||||
.then((created) => {
|
||||
if (created) {
|
||||
deps.onCardsMined?.(cardsToMine);
|
||||
|
||||
@@ -78,7 +78,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
setPendingPauseAtSubEnd: (value: boolean) => void;
|
||||
getPauseAtTime: () => number | null;
|
||||
setPauseAtTime: (value: number | null) => void;
|
||||
autoLoadSecondarySubTrack: (path: string) => void;
|
||||
autoLoadSecondarySubTrack: () => void;
|
||||
setCurrentVideoPath: (value: string) => void;
|
||||
emitSecondarySubtitleVisibility: (payload: { visible: boolean }) => void;
|
||||
setPreviousSecondarySubVisibility: (visible: boolean) => void;
|
||||
@@ -303,7 +303,7 @@ export async function dispatchMpvProtocolMessage(
|
||||
const path = (msg.data as string) || '';
|
||||
deps.setCurrentVideoPath(path);
|
||||
deps.emitMediaPathChange({ path });
|
||||
deps.autoLoadSecondarySubTrack(path);
|
||||
deps.autoLoadSecondarySubTrack();
|
||||
deps.syncCurrentAudioStreamIndex();
|
||||
} else if (msg.name === 'sub-pos') {
|
||||
deps.emitSubtitleMetricsChange({ subPos: msg.data as number });
|
||||
|
||||
@@ -6,10 +6,7 @@ import {
|
||||
MpvIpcClientProtocolDeps,
|
||||
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
|
||||
} from './mpv';
|
||||
import {
|
||||
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
||||
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||
} from './mpv-protocol';
|
||||
import { MPV_REQUEST_ID_TRACK_LIST_AUDIO } from './mpv-protocol';
|
||||
|
||||
function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClientDeps {
|
||||
return {
|
||||
@@ -96,53 +93,6 @@ test('MpvIpcClient clears cached media title when media path changes', async ()
|
||||
assert.equal(client.currentMediaTitle, null);
|
||||
});
|
||||
|
||||
test('MpvIpcClient skips secondary subtitle autoload when media path is managed', async () => {
|
||||
const commands: unknown[] = [];
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv.sock',
|
||||
makeDeps({
|
||||
getResolvedConfig: () =>
|
||||
({
|
||||
secondarySub: {
|
||||
autoLoadSecondarySub: true,
|
||||
secondarySubLanguages: ['en'],
|
||||
},
|
||||
}) as any,
|
||||
shouldAutoLoadSecondarySubTrack: () => false,
|
||||
} as any),
|
||||
);
|
||||
(client as any).send = (command: unknown) => {
|
||||
commands.push(command);
|
||||
return true;
|
||||
};
|
||||
(globalThis as any).setTimeout = (callback: () => void) => {
|
||||
callback();
|
||||
return 0;
|
||||
};
|
||||
|
||||
try {
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'path',
|
||||
data: 'http://pve-main:8096/Videos/item/stream',
|
||||
});
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
commands.some(
|
||||
(command) =>
|
||||
Array.isArray((command as { command?: unknown[] }).command) &&
|
||||
(command as { command: unknown[] }).command[0] === 'get_property' &&
|
||||
(command as { command: unknown[] }).command[1] === 'track-list' &&
|
||||
(command as { request_id?: number }).request_id === MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('MpvIpcClient parses JSON line protocol in processBuffer', () => {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
|
||||
@@ -105,7 +105,6 @@ export interface MpvIpcClientProtocolDeps {
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
}
|
||||
@@ -405,8 +404,8 @@ export class MpvIpcClient implements MpvClient {
|
||||
setPauseAtTime: (value: number | null) => {
|
||||
this.pauseAtTime = value;
|
||||
},
|
||||
autoLoadSecondarySubTrack: (path: string) => {
|
||||
this.autoLoadSecondarySubTrack(path);
|
||||
autoLoadSecondarySubTrack: () => {
|
||||
this.autoLoadSecondarySubTrack();
|
||||
},
|
||||
setCurrentVideoPath: (value: string) => {
|
||||
this.currentVideoPath = value;
|
||||
@@ -430,12 +429,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
};
|
||||
}
|
||||
|
||||
private autoLoadSecondarySubTrack(path: string): void {
|
||||
const normalizedPath = path.trim();
|
||||
if (!normalizedPath) return;
|
||||
if (this.deps.shouldAutoLoadSecondarySubTrack?.(normalizedPath) === false) {
|
||||
return;
|
||||
}
|
||||
private autoLoadSecondarySubTrack(): void {
|
||||
const config = this.deps.getResolvedConfig();
|
||||
if (!config.secondarySub?.autoLoadSecondarySub) return;
|
||||
const languages = config.secondarySub.secondarySubLanguages;
|
||||
|
||||
@@ -197,68 +197,6 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () =>
|
||||
assert.ok(!calls.includes('osd'));
|
||||
});
|
||||
|
||||
test('non-native passive overlay stays click-through after subsequent visibility updates', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
overlayInteractionActive: false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
|
||||
} as never);
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
overlayInteractionActive: false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
|
||||
} as never);
|
||||
|
||||
assert.equal(calls.includes('mouse-ignore:false:plain'), false);
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
});
|
||||
|
||||
test('suspended visible overlay hides without refreshing bounds or z-order', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
|
||||
@@ -181,13 +181,12 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
!isTrackedWindowsTargetMinimized &&
|
||||
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
|
||||
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
|
||||
const isNonNativePassiveOverlay =
|
||||
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
|
||||
const shouldIgnoreMouseEvents =
|
||||
shouldUseMacOSMousePassthrough ||
|
||||
forceMousePassthrough ||
|
||||
isNonNativePassiveOverlay ||
|
||||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
|
||||
const isNonNativePassiveOverlay =
|
||||
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
|
||||
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
||||
const shouldKeepTrackedWindowsOverlayTopmost =
|
||||
!args.isWindowsPlatform ||
|
||||
|
||||
@@ -843,7 +843,7 @@ export function createStatsApp(
|
||||
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
|
||||
const mediaGen = new MediaGenerator();
|
||||
|
||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0.5;
|
||||
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
||||
|
||||
const startSec = startMs / 1000;
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
export type StatsWindowLayerSuspensionState = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export function createStatsWindowLayerSuspensionState(): StatsWindowLayerSuspensionState {
|
||||
return { count: 0 };
|
||||
}
|
||||
|
||||
export function isStatsWindowLayerSuspended(state: StatsWindowLayerSuspensionState): boolean {
|
||||
return state.count > 0;
|
||||
}
|
||||
|
||||
export function suspendStatsWindowLayer(state: StatsWindowLayerSuspensionState): boolean {
|
||||
state.count += 1;
|
||||
return state.count === 1;
|
||||
}
|
||||
|
||||
export function restoreStatsWindowLayer(state: StatsWindowLayerSuspensionState): boolean {
|
||||
if (state.count <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.count -= 1;
|
||||
return state.count === 0;
|
||||
}
|
||||
|
||||
export function resetStatsWindowLayerSuspension(state: StatsWindowLayerSuspensionState): void {
|
||||
state.count = 0;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createStatsWindowLayerSuspensionState,
|
||||
isStatsWindowLayerSuspended,
|
||||
resetStatsWindowLayerSuspension,
|
||||
restoreStatsWindowLayer,
|
||||
suspendStatsWindowLayer,
|
||||
} from './stats-window-layer';
|
||||
|
||||
test('stats window layer suspension reset clears missed native dialog closes', () => {
|
||||
const state = createStatsWindowLayerSuspensionState();
|
||||
|
||||
assert.equal(suspendStatsWindowLayer(state), true);
|
||||
assert.equal(suspendStatsWindowLayer(state), false);
|
||||
assert.equal(isStatsWindowLayerSuspended(state), true);
|
||||
|
||||
resetStatsWindowLayerSuspension(state);
|
||||
|
||||
assert.equal(isStatsWindowLayerSuspended(state), false);
|
||||
assert.equal(restoreStatsWindowLayer(state), false);
|
||||
assert.equal(suspendStatsWindowLayer(state), true);
|
||||
});
|
||||
@@ -15,18 +15,11 @@ import {
|
||||
STATS_WINDOW_TITLE,
|
||||
} from './stats-window-runtime.js';
|
||||
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js';
|
||||
import {
|
||||
createStatsWindowLayerSuspensionState,
|
||||
isStatsWindowLayerSuspended,
|
||||
resetStatsWindowLayerSuspension,
|
||||
restoreStatsWindowLayer,
|
||||
suspendStatsWindowLayer,
|
||||
} from './stats-window-layer.js';
|
||||
|
||||
let statsWindow: BrowserWindow | null = null;
|
||||
let toggleRegistered = false;
|
||||
let nativeDialogLayerRegistered = false;
|
||||
const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState();
|
||||
let nativeDialogLayerSuspensionCount = 0;
|
||||
|
||||
export interface StatsWindowOptions {
|
||||
/** Absolute path to stats/dist/ directory */
|
||||
@@ -74,7 +67,7 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
|
||||
}
|
||||
|
||||
export function promoteStatsOverlayAbovePlayback(): boolean {
|
||||
if (isStatsWindowLayerSuspended(nativeDialogLayerSuspension)) {
|
||||
if (nativeDialogLayerSuspensionCount > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -98,7 +91,8 @@ export function demoteStatsOverlayBelowDialogs(): boolean {
|
||||
}
|
||||
|
||||
export function suspendStatsWindowLayerForNativeDialog(): void {
|
||||
if (!suspendStatsWindowLayer(nativeDialogLayerSuspension)) {
|
||||
nativeDialogLayerSuspensionCount += 1;
|
||||
if (nativeDialogLayerSuspensionCount !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,13 +100,14 @@ export function suspendStatsWindowLayerForNativeDialog(): void {
|
||||
}
|
||||
|
||||
export function restoreStatsWindowLayerAfterNativeDialog(): void {
|
||||
if (restoreStatsWindowLayer(nativeDialogLayerSuspension)) {
|
||||
promoteStatsOverlayAbovePlayback();
|
||||
}
|
||||
if (nativeDialogLayerSuspensionCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
function resetStatsWindowLayerAfterLifecycleEnd(): void {
|
||||
resetStatsWindowLayerSuspension(nativeDialogLayerSuspension);
|
||||
nativeDialogLayerSuspensionCount -= 1;
|
||||
if (nativeDialogLayerSuspensionCount === 0) {
|
||||
promoteStatsOverlayAbovePlayback();
|
||||
}
|
||||
}
|
||||
|
||||
export async function withStatsWindowLayerSuspendedForNativeDialog<T>(
|
||||
@@ -177,7 +172,6 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
||||
statsWindow.on('closed', () => {
|
||||
options.onVisibilityChanged?.(false);
|
||||
statsWindow = null;
|
||||
resetStatsWindowLayerAfterLifecycleEnd();
|
||||
});
|
||||
|
||||
statsWindow.webContents.on('before-input-event', (event, input) => {
|
||||
@@ -228,5 +222,4 @@ export function destroyStatsWindow(): void {
|
||||
statsWindow.destroy();
|
||||
statsWindow = null;
|
||||
}
|
||||
resetStatsWindowLayerAfterLifecycleEnd();
|
||||
}
|
||||
|
||||
@@ -89,40 +89,6 @@ Dialogue: 0,0:00:04.00,0:00:05.00,Default,,0,0,0,,line-3`,
|
||||
assert.equal(Math.abs((delta as number) + 1.5) < 0.0001, true);
|
||||
});
|
||||
|
||||
test('shift subtitle delay reports cumulative delay after adjacent cue shift', async () => {
|
||||
const shiftedDelays: number[] = [];
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
createMpvClient({
|
||||
'track-list': [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
external: true,
|
||||
'external-filename': '/tmp/subs.srt',
|
||||
},
|
||||
],
|
||||
sid: 2,
|
||||
'sub-start': 3.0,
|
||||
'sub-delay': 0.5,
|
||||
}),
|
||||
loadSubtitleSourceText: async () => `1
|
||||
00:00:03,000 --> 00:00:04,000
|
||||
line-1
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:06,000
|
||||
line-2`,
|
||||
sendMpvCommand: () => {},
|
||||
showMpvOsd: () => {},
|
||||
onSubtitleDelayShifted: (delay) => shiftedDelays.push(delay),
|
||||
});
|
||||
|
||||
await handler('next');
|
||||
|
||||
assert.deepEqual(shiftedDelays, [2.5]);
|
||||
});
|
||||
|
||||
test('shift subtitle delay throws when no next cue exists', async () => {
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
|
||||
@@ -21,7 +21,6 @@ type SubtitleDelayShiftDeps = {
|
||||
loadSubtitleSourceText: (source: string) => Promise<string>;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
onSubtitleDelayShifted?: (delaySeconds: number) => void;
|
||||
};
|
||||
|
||||
function asTrackId(value: unknown): number | null {
|
||||
@@ -176,11 +175,10 @@ export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelay
|
||||
throw new Error('MPV not connected.');
|
||||
}
|
||||
|
||||
const [trackListRaw, sidRaw, subStartRaw, subDelayRaw] = await Promise.all([
|
||||
const [trackListRaw, sidRaw, subStartRaw] = await Promise.all([
|
||||
client.requestProperty('track-list'),
|
||||
client.requestProperty('sid'),
|
||||
client.requestProperty('sub-start'),
|
||||
client.requestProperty('sub-delay'),
|
||||
]);
|
||||
|
||||
const currentStart =
|
||||
@@ -200,11 +198,6 @@ export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelay
|
||||
const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction);
|
||||
const delta = targetStart - currentStart;
|
||||
deps.sendMpvCommand(['add', 'sub-delay', delta]);
|
||||
const currentDelay =
|
||||
typeof subDelayRaw === 'number' && Number.isFinite(subDelayRaw) ? subDelayRaw : 0;
|
||||
try {
|
||||
deps.onSubtitleDelayShifted?.(currentDelay + delta);
|
||||
} catch {}
|
||||
deps.showMpvOsd('Subtitle delay: ${sub-delay}');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { estimateSubtitleTimingOffset } from './subtitle-timing-offset';
|
||||
|
||||
function cue(startTime: number) {
|
||||
return { startTime, endTime: startTime + 1, text: `cue ${startTime}` };
|
||||
}
|
||||
|
||||
test('estimate subtitle timing offset detects a late Jellyfin subtitle timeline', () => {
|
||||
const primary = [
|
||||
34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814,
|
||||
87.988, 90.991, 94.094, 97.097,
|
||||
].map(cue);
|
||||
const reference = [
|
||||
3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56,
|
||||
].map(cue);
|
||||
|
||||
const result = estimateSubtitleTimingOffset(primary, reference);
|
||||
|
||||
assert.ok(result);
|
||||
assert.ok(result.offsetSeconds > -32);
|
||||
assert.ok(result.offsetSeconds < -31);
|
||||
assert.ok(result.matchCount >= 8);
|
||||
assert.ok(result.meanErrorSeconds <= 0.75);
|
||||
});
|
||||
|
||||
test('estimate subtitle timing offset favors the early episode timeline', () => {
|
||||
const primary = [
|
||||
34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814,
|
||||
87.988, 90.991, 94.094, 97.097, 207.974, 212.579, 222.422, 228.095, 232.432, 238.271, 244.778,
|
||||
246.78, 249.282, 251.284, 253.62, 256.289, 259.626, 262.129, 264.965, 267.634, 270.303, 274.407,
|
||||
277.077, 280.08, 284.084, 288.421, 291.925, 295.262, 298.431, 301.101, 306.773, 308.942,
|
||||
312.946, 316.283, 321.621, 326.626, 331.131, 336.069, 340.407, 343.41, 351.418, 355.422,
|
||||
357.924, 362.429, 365.432, 370.604, 373.273, 377.944, 381.114, 384.618, 387.621, 390.957,
|
||||
396.73, 399.232, 401.568, 403.57, 405.572, 407.574, 409.743, 412.746, 418.752, 425.258, 427.26,
|
||||
435.602, 440.44, 442.942, 445.445, 449.783,
|
||||
].map(cue);
|
||||
const reference = [
|
||||
3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56, 165.77, 172.81,
|
||||
176.1, 177.27, 186.33, 191.33, 195.78, 201.83, 212.9, 214.09, 216.73, 220.2, 222.91, 225.65,
|
||||
232.8, 237.92, 242.23, 243.28, 247.53, 252.04, 255.9, 258.86, 262.09, 264.43, 276.07, 278.01,
|
||||
280.98, 285.67, 289.89, 294.57, 300, 303.56, 308.58, 316.37, 318.38, 319.86, 325.38, 328.82,
|
||||
333.68, 335.26, 336.82, 340.11, 342.11, 344.36, 346.39, 347.53, 350.92, 370.18, 372.88, 376.43,
|
||||
388.2, 390.57, 403.96, 406.36, 409.72, 413.78, 425.55, 432.76, 435.03, 438.06, 443.73, 448.31,
|
||||
450.57, 457.62, 463.41, 465.85, 473.79, 480.59,
|
||||
].map(cue);
|
||||
|
||||
const result = estimateSubtitleTimingOffset(primary, reference);
|
||||
|
||||
assert.ok(result);
|
||||
assert.ok(result.offsetSeconds > -32);
|
||||
assert.ok(result.offsetSeconds < -31);
|
||||
});
|
||||
|
||||
test('estimate subtitle timing offset ignores subtitle timelines that are already aligned', () => {
|
||||
const starts = [1, 5, 9, 14, 20, 25, 31, 38];
|
||||
|
||||
const result = estimateSubtitleTimingOffset(
|
||||
starts.map(cue),
|
||||
starts.map((start) => cue(start + 0.04)),
|
||||
);
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('estimate subtitle timing offset rejects weak timeline matches', () => {
|
||||
const primary = [10, 20, 30, 40, 50, 60, 70, 80].map(cue);
|
||||
const reference = [1, 2, 3, 4, 5, 6, 7, 8].map(cue);
|
||||
|
||||
const result = estimateSubtitleTimingOffset(primary, reference);
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
||||
|
||||
export type SubtitleTimingOffsetResult = {
|
||||
offsetSeconds: number;
|
||||
matchCount: number;
|
||||
meanErrorSeconds: number;
|
||||
maxErrorSeconds: number;
|
||||
};
|
||||
|
||||
export type SubtitleTimingOffsetOptions = {
|
||||
maxCueCount?: number;
|
||||
maxOffsetSeconds?: number;
|
||||
matchThresholdSeconds?: number;
|
||||
maxMeanErrorSeconds?: number;
|
||||
minMatchCount?: number;
|
||||
minMatchRatio?: number;
|
||||
minUsefulOffsetSeconds?: number;
|
||||
};
|
||||
|
||||
type OffsetScore = SubtitleTimingOffsetResult;
|
||||
|
||||
const DEFAULT_MAX_CUE_COUNT = 60;
|
||||
const DEFAULT_MAX_OFFSET_SECONDS = 180;
|
||||
const DEFAULT_MATCH_THRESHOLD_SECONDS = 1;
|
||||
const DEFAULT_MAX_MEAN_ERROR_SECONDS = 0.75;
|
||||
const DEFAULT_MIN_MATCH_COUNT = 8;
|
||||
const DEFAULT_MIN_MATCH_RATIO = 0.25;
|
||||
const DEFAULT_MIN_USEFUL_OFFSET_SECONDS = 0.25;
|
||||
|
||||
function normalizeCueStarts(cues: SubtitleCue[], maxCueCount: number): number[] {
|
||||
const starts = cues
|
||||
.map((cue) => cue.startTime)
|
||||
.filter((start) => Number.isFinite(start) && start >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
const deduped: number[] = [];
|
||||
for (const start of starts) {
|
||||
const previous = deduped[deduped.length - 1];
|
||||
if (previous === undefined || Math.abs(start - previous) > 0.05) {
|
||||
deduped.push(start);
|
||||
}
|
||||
if (deduped.length >= maxCueCount) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function roundToMillis(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function scoreOffset(
|
||||
primaryStarts: number[],
|
||||
referenceStarts: number[],
|
||||
offsetSeconds: number,
|
||||
matchThresholdSeconds: number,
|
||||
): OffsetScore {
|
||||
let primaryIndex = 0;
|
||||
let referenceIndex = 0;
|
||||
let matchCount = 0;
|
||||
let totalErrorSeconds = 0;
|
||||
let maxErrorSeconds = 0;
|
||||
|
||||
while (primaryIndex < primaryStarts.length && referenceIndex < referenceStarts.length) {
|
||||
const shiftedPrimary = primaryStarts[primaryIndex]! + offsetSeconds;
|
||||
const reference = referenceStarts[referenceIndex]!;
|
||||
const errorSeconds = Math.abs(shiftedPrimary - reference);
|
||||
if (errorSeconds <= matchThresholdSeconds) {
|
||||
matchCount += 1;
|
||||
totalErrorSeconds += errorSeconds;
|
||||
maxErrorSeconds = Math.max(maxErrorSeconds, errorSeconds);
|
||||
primaryIndex += 1;
|
||||
referenceIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shiftedPrimary < reference) {
|
||||
primaryIndex += 1;
|
||||
} else {
|
||||
referenceIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
offsetSeconds,
|
||||
matchCount,
|
||||
meanErrorSeconds: matchCount > 0 ? totalErrorSeconds / matchCount : Number.POSITIVE_INFINITY,
|
||||
maxErrorSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function isBetterScore(next: OffsetScore, current: OffsetScore | null): boolean {
|
||||
if (current === null) return true;
|
||||
if (next.matchCount !== current.matchCount) return next.matchCount > current.matchCount;
|
||||
if (next.meanErrorSeconds !== current.meanErrorSeconds) {
|
||||
return next.meanErrorSeconds < current.meanErrorSeconds;
|
||||
}
|
||||
return Math.abs(next.offsetSeconds) < Math.abs(current.offsetSeconds);
|
||||
}
|
||||
|
||||
export function estimateSubtitleTimingOffset(
|
||||
primaryCues: SubtitleCue[],
|
||||
referenceCues: SubtitleCue[],
|
||||
options: SubtitleTimingOffsetOptions = {},
|
||||
): SubtitleTimingOffsetResult | null {
|
||||
const maxCueCount = options.maxCueCount ?? DEFAULT_MAX_CUE_COUNT;
|
||||
const maxOffsetSeconds = options.maxOffsetSeconds ?? DEFAULT_MAX_OFFSET_SECONDS;
|
||||
const matchThresholdSeconds = options.matchThresholdSeconds ?? DEFAULT_MATCH_THRESHOLD_SECONDS;
|
||||
const maxMeanErrorSeconds = options.maxMeanErrorSeconds ?? DEFAULT_MAX_MEAN_ERROR_SECONDS;
|
||||
const minMatchCount = options.minMatchCount ?? DEFAULT_MIN_MATCH_COUNT;
|
||||
const minMatchRatio = options.minMatchRatio ?? DEFAULT_MIN_MATCH_RATIO;
|
||||
const minUsefulOffsetSeconds =
|
||||
options.minUsefulOffsetSeconds ?? DEFAULT_MIN_USEFUL_OFFSET_SECONDS;
|
||||
|
||||
const primaryStarts = normalizeCueStarts(primaryCues, maxCueCount);
|
||||
const referenceStarts = normalizeCueStarts(referenceCues, maxCueCount);
|
||||
const comparableCueCount = Math.min(primaryStarts.length, referenceStarts.length);
|
||||
if (comparableCueCount < minMatchCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = new Set<number>();
|
||||
for (const primaryStart of primaryStarts) {
|
||||
for (const referenceStart of referenceStarts) {
|
||||
const offsetSeconds = roundToMillis(referenceStart - primaryStart);
|
||||
if (Math.abs(offsetSeconds) <= maxOffsetSeconds) {
|
||||
candidates.add(offsetSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let best: OffsetScore | null = null;
|
||||
for (const offsetSeconds of candidates) {
|
||||
if (Math.abs(offsetSeconds) < minUsefulOffsetSeconds) {
|
||||
continue;
|
||||
}
|
||||
const score = scoreOffset(primaryStarts, referenceStarts, offsetSeconds, matchThresholdSeconds);
|
||||
if (score.matchCount < minMatchCount) {
|
||||
continue;
|
||||
}
|
||||
if (score.matchCount / comparableCueCount < minMatchRatio) {
|
||||
continue;
|
||||
}
|
||||
if (score.meanErrorSeconds > maxMeanErrorSeconds) {
|
||||
continue;
|
||||
}
|
||||
if (isBetterScore(score, best)) {
|
||||
best = score;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user