docs: add mpv.launchMode to config docs, add changelog:docs generator, format

- Document the new mpv.launchMode option in the configuration docs page
- Add changelog:docs command to auto-generate docs-site/changelog.md from root CHANGELOG.md
- Add breaking changes support to the changelog fragment generator
- Fix docs-sync test to only compare current minor release headings
- Apply prettier formatting to source files
This commit is contained in:
2026-04-07 01:06:43 -07:00
parent bc7dde3b02
commit de4f3efa30
19 changed files with 420 additions and 247 deletions

View File

@@ -12,10 +12,21 @@ area: overlay
- Added auto-pause toggle when opening the popup. - Added auto-pause toggle when opening the popup.
``` ```
For breaking changes, add `breaking: true`:
```md
type: changed
area: config
breaking: true
- Renamed `foo.bar` to `foo.baz`.
```
Rules: Rules:
- `type` required: `added`, `changed`, `fixed`, `docs`, or `internal` - `type` required: `added`, `changed`, `fixed`, `docs`, or `internal`
- `area` required: short product area like `overlay`, `launcher`, `release` - `area` required: short product area like `overlay`, `launcher`, `release`
- `breaking` optional: set to `true` to flag as a breaking change
- each non-empty body line becomes a bullet - each non-empty body line becomes a bullet
- `README.md` is ignored by the generator - `README.md` is ignored by the generator
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment - if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment

View File

@@ -127,6 +127,7 @@ The configuration file includes several main sections:
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates - [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite - [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
- [**Stats Dashboard**](#stats-dashboard) - Local dashboard and overlay for immersion progress - [**Stats Dashboard**](#stats-dashboard) - Local dashboard and overlay for immersion progress
- [**MPV Launcher**](#mpv-launcher) - mpv executable path and window launch mode
- [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading - [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading
## Core Settings ## Core Settings
@@ -237,10 +238,10 @@ This stream includes subtitle text plus token metadata (N+1, known-word, frequen
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------- | ------------------ | -------------------------------------------------------- | | --------- | --------------- | -------------------------------------------------------------- |
| `enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) | | `enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) |
| `port` | number | Annotation websocket port (default: 6678) | | `port` | number | Annotation websocket port (default: 6678) |
### Texthooker ### Texthooker
@@ -257,10 +258,10 @@ See `config.example.jsonc` for detailed configuration options.
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------ | | ----------------- | --------------- | ---------------------------------------------------------------------- |
| `launchAtStartup`| `true`, `false` | Start texthooker automatically with SubMiner startup (default: `true`) | | `launchAtStartup` | `true`, `false` | Start texthooker automatically with SubMiner startup (default: `true`) |
| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) | | `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) |
## Subtitle Display ## Subtitle Display
@@ -365,24 +366,24 @@ Configure the parsed-subtitle sidebar modal.
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------------------------- | ---------------- | -------------------------------------------------------------------------------- | | --------------------------- | --------- | ------------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | Enable subtitle sidebar support (`true` by default) | | `enabled` | boolean | Enable subtitle sidebar support (`true` by default) |
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) | | `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout | | `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) | | `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list | | `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list |
| `autoScroll` | boolean | Keep the active cue in view while playback advances | | `autoScroll` | boolean | Keep the active cue in view while playback advances |
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) | | `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) | | `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
| `backgroundColor` | string | Sidebar shell background color | | `backgroundColor` | string | Sidebar shell background color |
| `textColor` | hex color | Default cue text color | | `textColor` | hex color | Default cue text color |
| `fontFamily` | string | CSS `font-family` value applied to sidebar cue text | | `fontFamily` | string | CSS `font-family` value applied to sidebar cue text |
| `fontSize` | number | Base sidebar cue font size in CSS pixels (default: `16`) | | `fontSize` | number | Base sidebar cue font size in CSS pixels (default: `16`) |
| `timestampColor` | hex color | Cue timestamp color | | `timestampColor` | hex color | Cue timestamp color |
| `activeLineColor` | hex color | Active cue text color | | `activeLineColor` | hex color | Active cue text color |
| `activeLineBackgroundColor` | string | Active cue background color | | `activeLineBackgroundColor` | string | Active cue background color |
| `hoverLineBackgroundColor` | string | Hovered cue background color | | `hoverLineBackgroundColor` | string | Hovered cue background color |
The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog. The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog.
@@ -466,25 +467,25 @@ See `config.example.jsonc` for detailed configuration options and more examples.
**Default keybindings:** **Default keybindings:**
| Key | Command | Description | | Key | Command | Description |
| -------------------- | ---------------------------- | ------------------------------------- | | -------------------- | ----------------------------- | --------------------------------------- |
| `Space` | `["cycle", "pause"]` | Toggle pause | | `Space` | `["cycle", "pause"]` | Toggle pause |
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | | `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | | `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser | | `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker | | `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | | `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds | | `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds | | `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds | | `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle | | `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle | | `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue | | `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue |
| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue | | `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue |
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end | | `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end | | `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
| `KeyQ` | `["quit"]` | Quit mpv | | `KeyQ` | `["quit"]` | Quit mpv |
| `Ctrl+KeyW` | `["quit"]` | Quit mpv | | `Ctrl+KeyW` | `["quit"]` | Quit mpv |
**Custom keybindings example:** **Custom keybindings example:**
@@ -604,7 +605,7 @@ Important behavior:
"leftStickPress": 9, "leftStickPress": 9,
"rightStickPress": 10, "rightStickPress": 10,
"leftTrigger": 6, "leftTrigger": 6,
"rightTrigger": 7 "rightTrigger": 7,
}, },
"bindings": { "bindings": {
"toggleLookup": { "kind": "button", "buttonIndex": 0 }, "toggleLookup": { "kind": "button", "buttonIndex": 0 },
@@ -619,9 +620,9 @@ Important behavior:
"leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "horizontal" }, "leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "horizontal" },
"leftStickVertical": { "kind": "axis", "axisIndex": 1, "dpadFallback": "vertical" }, "leftStickVertical": { "kind": "axis", "axisIndex": 1, "dpadFallback": "vertical" },
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" }, "rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" } "rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" },
} },
} },
} }
``` ```
@@ -649,9 +650,9 @@ If you bind a discrete action to an axis manually, include `direction`:
{ {
"controller": { "controller": {
"bindings": { "bindings": {
"toggleLookup": { "kind": "axis", "axisIndex": 5, "direction": "positive" } "toggleLookup": { "kind": "axis", "axisIndex": 5, "direction": "positive" },
} },
} },
} }
``` ```
@@ -758,15 +759,15 @@ Anki reads this provider directly. Legacy subtitle fallback keeps the same provi
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------ | --------------------- | ---------------------------------------------------- | | ------------------ | -------------------- | ------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable shared AI provider features | | `enabled` | `true`, `false` | Enable shared AI provider features |
| `apiKey` | string | Static API key for the shared provider | | `apiKey` | string | Static API key for the shared provider |
| `apiKeyCommand` | string | Shell command used to resolve the API key | | `apiKeyCommand` | string | Shell command used to resolve the API key |
| `baseUrl` | string (URL) | OpenAI-compatible base URL | | `baseUrl` | string (URL) | OpenAI-compatible base URL |
| `model` | string | Optional model override for shared provider workflows | | `model` | string | Optional model override for shared provider workflows |
| `systemPrompt` | string | Optional system prompt override for shared provider workflows | | `systemPrompt` | string | Optional system prompt override for shared provider workflows |
| `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) | | `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) |
SubMiner uses the shared provider for: SubMiner uses the shared provider for:
@@ -844,59 +845,59 @@ This example is intentionally compact. The option table below documents availabl
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation. **Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description | | Option | Values | Description |
| --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | | `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | | `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | | `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) | | `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) | | `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) | | `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | | `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | | `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. | | `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). | | `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | | `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | | `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) | | `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | | `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) | | `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) | | `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. | | `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. | | `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | | `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) | | `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) | | `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) | | `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | | `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) | | `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. | | `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. | | `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) | | `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) | | `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. | | `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.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.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) | | `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.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | | `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode` (default: `true`) | | `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) | | `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | | `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | | `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) | | `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) | | `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). | | `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. | | `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) | | `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). | | `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). | | `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). | | `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | | `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) | | `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time | | `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | | `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | | `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides. `ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config. API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
@@ -1022,8 +1023,8 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`. Both ar
| Option | Values | Description | | Option | Values | Description |
| ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- | | ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `defaultMode` | `"auto"`, `"manual"` | `auto`: try `alass` against secondary subtitle, then fallback to `ffsubsync`; `manual`: open overlay picker | | `defaultMode` | `"auto"`, `"manual"` | `auto`: try `alass` against secondary subtitle, then fallback to `ffsubsync`; `manual`: open overlay picker |
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. | | `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. | | `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. | | `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. | | `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
@@ -1055,18 +1056,18 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------ | | -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | | `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) | | `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
| `characterDictionary.enabled` | `true`, `false` | Enable automatic import/update of the merged SubMiner character dictionary for recent AniList media | | `characterDictionary.enabled` | `true`, `false` | Enable automatic import/update of the merged SubMiner character dictionary for recent AniList media |
| `characterDictionary.refreshTtlHours` | number | Legacy compatibility setting. Parsed and preserved, but merged dictionary retention is now usage-based | | `characterDictionary.refreshTtlHours` | number | Legacy compatibility setting. Parsed and preserved, but merged dictionary retention is now usage-based |
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) | | `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | Legacy compatibility setting. Parsed and preserved, but merged dictionary eviction is now usage-based | | `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | Legacy compatibility setting. Parsed and preserved, but merged dictionary eviction is now usage-based |
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries | | `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries | | `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries | | `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile | | `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior. When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior.
@@ -1122,8 +1123,8 @@ For GameSentenceMiner on Linux, the default overlay profile path is typically `~
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `externalProfilePath` | string path | Optional absolute path, or a path beginning with `~` (expanded to your home directory), to another app's Yomitan Electron profile. SubMiner loads that profile read-only and reuses its dictionaries/settings. | | `externalProfilePath` | string path | Optional absolute path, or a path beginning with `~` (expanded to your home directory), to another app's Yomitan Electron profile. SubMiner loads that profile read-only and reuses its dictionaries/settings. |
External-profile mode behavior: External-profile mode behavior:
@@ -1208,12 +1209,12 @@ Discord Rich Presence is enabled by default. SubMiner publishes a polished activ
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------ | ------------------------------------------------- | ---------------------------------------------------------- | | ------------------ | ------------------------------------------------ | ---------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) | | `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) |
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) | | `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | | `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | | `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
Setup steps: Setup steps:
@@ -1225,12 +1226,12 @@ Setup steps:
While playing media, the **Details** line always shows the current media title and **State** shows `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss`. The preset controls what appears when idle and the tooltip text on images. While playing media, the **Details** line always shows the current media title and **State** shows `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss`. The preset controls what appears when idle and the tooltip text on images.
| Preset | Idle details | Small image text | Vibe | | Preset | Idle details | Small image text | Vibe |
| ------------ | ----------------------------------- | ------------------ | --------------------------------------- | | ------------- | ---------------------------------- | ------------------ | --------------------------------------- |
| **`default`**| `Sentence Mining` | `日本語学習中` | Clean, bilingual flair | | **`default`** | `Sentence Mining` | `日本語学習中` | Clean, bilingual flair |
| `meme` | `Mining and crafting (Anki cards)` | `Sentence Mining` | Minecraft-inspired joke | | `meme` | `Mining and crafting (Anki cards)` | `Sentence Mining` | Minecraft-inspired joke |
| `japanese` | `文の採掘中` | `イマージョン学習` | Fully Japanese | | `japanese` | `文の採掘中` | `イマージョン学習` | Fully Japanese |
| `minimal` | `SubMiner` | *(none)* | Bare essentials, no small image overlay | | `minimal` | `SubMiner` | _(none)_ | Bare essentials, no small image overlay |
All presets use the `subminer-logo` large image with `SubMiner` tooltip. No activity button is shown by default. All presets use the `subminer-logo` large image with `SubMiner` tooltip. No activity button is shown by default.
@@ -1273,23 +1274,23 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | | ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. | | `enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. |
| `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `<config dir>/immersion.sqlite`. | | `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `<config dir>/immersion.sqlite`. |
| `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. | | `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. |
| `flushIntervalMs` | integer (`50`-`60000`) | Maximum queue delay before flush. Default `500ms`. | | `flushIntervalMs` | integer (`50`-`60000`) | Maximum queue delay before flush. Default `500ms`. |
| `queueCap` | integer (`100`-`100000`) | In-memory queue cap. Overflow drops oldest writes. Default `1000`. | | `queueCap` | integer (`100`-`100000`) | In-memory queue cap. Overflow drops oldest writes. Default `1000`. |
| `payloadCapBytes` | integer (`64`-`8192`) | Event payload byte cap before truncation marker. Default `256`. | | `payloadCapBytes` | integer (`64`-`8192`) | Event payload byte cap before truncation marker. Default `256`. |
| `maintenanceIntervalMs` | integer (`60000`-`604800000`) | Prune + rollup maintenance cadence. Default `86400000` (24h). | | `maintenanceIntervalMs` | integer (`60000`-`604800000`) | Prune + rollup maintenance cadence. Default `86400000` (24h). |
| `retentionMode` | `preset`,`advanced` | Retention mode. `preset` applies `retentionPreset`, `advanced` uses explicit values only. Default `preset`. | | `retentionMode` | `preset`,`advanced` | Retention mode. `preset` applies `retentionPreset`, `advanced` uses explicit values only. Default `preset`. |
| `retentionPreset` | `minimal`,`balanced`,`deep-history` | Retention preset used when `retentionMode = "preset"`. Default `balanced`. | | `retentionPreset` | `minimal`,`balanced`,`deep-history` | Retention preset used when `retentionMode = "preset"`. Default `balanced`. |
| `retention.eventsDays` | integer (`0`-`3650`) | Raw event retention window in days. Default `0` (keep all). | | `retention.eventsDays` | integer (`0`-`3650`) | Raw event retention window in days. Default `0` (keep all). |
| `retention.telemetryDays` | integer (`0`-`3650`) | Telemetry retention window in days. Default `0` (keep all). | | `retention.telemetryDays` | integer (`0`-`3650`) | Telemetry retention window in days. Default `0` (keep all). |
| `retention.sessionsDays` | integer (`0`-`3650`) | Session retention window in days. Default `0` (keep all). | | `retention.sessionsDays` | integer (`0`-`3650`) | Session retention window in days. Default `0` (keep all). |
| `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). | | `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). |
| `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). | | `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). |
| `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). | | `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). |
You can also disable immersion tracking for a single session using: You can also disable immersion tracking for a single session using:
@@ -1326,11 +1327,11 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ----------------- | ----------------- | --------------------------------------------------------------------------- | | ----------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------- |
| `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. | | `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
| `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. | | `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. |
| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. | | `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. |
| `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `false`. | | `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `false`. |
Usage notes: Usage notes:
@@ -1340,6 +1341,30 @@ Usage notes:
- The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear. - The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear.
- The UI includes Overview, Library, Trends, Vocabulary, and Sessions tabs. - The UI includes Overview, Library, Trends, Vocabulary, and Sessions tabs.
### MPV Launcher
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": "",
"launchMode": "normal"
}
}
```
| 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 `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
Launch mode behavior:
- **`normal`** — mpv opens at its default window size with no extra flags.
- **`maximized`** — mpv starts maximized via `--window-maximized=yes`, keeping taskbar access.
- **`fullscreen`** — mpv starts in true fullscreen via `--fullscreen`.
### YouTube Playback Settings ### YouTube Playback Settings
Set defaults used by managed subtitle auto-selection and the `subminer` launcher YouTube flow: Set defaults used by managed subtitle auto-selection and the `subminer` launcher YouTube flow:
@@ -1352,9 +1377,9 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------------------- | -------------------- | ---------------------------------------------------------------------------------------------- | | --------------------- | -------- | ------------------------------------------------------------------------------------------------ |
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) | | `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
Current launcher behavior: Current launcher behavior:

View File

@@ -8,7 +8,10 @@ const installationContents = readFileSync(new URL('./installation.md', import.me
const mpvPluginContents = readFileSync(new URL('./mpv-plugin.md', import.meta.url), 'utf8'); const mpvPluginContents = readFileSync(new URL('./mpv-plugin.md', import.meta.url), 'utf8');
const developmentContents = readFileSync(new URL('./development.md', import.meta.url), 'utf8'); const developmentContents = readFileSync(new URL('./development.md', import.meta.url), 'utf8');
const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url), 'utf8'); const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url), 'utf8');
const ankiIntegrationContents = readFileSync(new URL('./anki-integration.md', import.meta.url), 'utf8'); const ankiIntegrationContents = readFileSync(
new URL('./anki-integration.md', import.meta.url),
'utf8',
);
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8'); const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
function extractReleaseHeadings(content: string, count: number): string[] { function extractReleaseHeadings(content: string, count: number): string[] {
@@ -17,6 +20,13 @@ function extractReleaseHeadings(content: string, count: number): string[] {
.slice(0, count); .slice(0, count);
} }
function extractCurrentMinorHeadings(content: string): string[] {
const allHeadings = Array.from(content.matchAll(/^## v(\d+\.\d+)\.\d+[^\n]*$/gm));
if (allHeadings.length === 0) return [];
const currentMinor = allHeadings[0]![1];
return allHeadings.filter(([, minor]) => minor === currentMinor).map(([heading]) => heading);
}
test('docs reflect current launcher and release surfaces', () => { test('docs reflect current launcher and release surfaces', () => {
expect(usageContents).not.toContain('--mode preprocess'); expect(usageContents).not.toContain('--mode preprocess');
expect(usageContents).not.toContain('"automatic" (default)'); expect(usageContents).not.toContain('"automatic" (default)');
@@ -44,9 +54,11 @@ test('docs reflect current launcher and release surfaces', () => {
expect(configurationContents).toContain('youtube.primarySubLanguages'); expect(configurationContents).toContain('youtube.primarySubLanguages');
expect(configurationContents).toContain('### Shared AI Provider'); expect(configurationContents).toContain('### Shared AI Provider');
expect(changelogContents).toContain('## v0.5.1 (2026-03-09)'); expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
}); });
test('docs changelog keeps the newest release headings aligned with the root changelog', () => { test('docs changelog keeps the current minor release headings aligned with the root changelog', () => {
expect(extractReleaseHeadings(changelogContents, 3)).toEqual(extractReleaseHeadings(rootChangelogContents, 3)); const docsHeadings = extractCurrentMinorHeadings(changelogContents);
expect(docsHeadings.length).toBeGreaterThan(0);
expect(docsHeadings).toEqual(extractReleaseHeadings(rootChangelogContents, docsHeadings.length));
}); });

View File

@@ -125,10 +125,7 @@ function titleOverlapScore(expectedTitle: string, candidateTitle: string): numbe
if (!expected || !candidate) return 0; if (!expected || !candidate) return 0;
if (candidate.includes(expected)) return 120; if (candidate.includes(expected)) return 120;
if ( if (candidate.split(' ').length >= 2 && ` ${expected} `.includes(` ${candidate} `)) {
candidate.split(' ').length >= 2 &&
` ${expected} `.includes(` ${candidate} `)
) {
return 90; return 90;
} }

View File

@@ -715,18 +715,18 @@ function runFindAppBinaryWindowsInstallDirCase(): void {
process.env.SUBMINER_BINARY_PATH = installDir; process.env.SUBMINER_BINARY_PATH = installDir;
withPlatform('win32', () => { withPlatform('win32', () => {
withExistsAndStatSyncStubs( withExistsAndStatSyncStubs({ existingPaths: [appExe], directoryPaths: [installDir] }, () => {
{ existingPaths: [appExe], directoryPaths: [installDir] }, withAccessSyncStub(
() => { (filePath) => filePath === appExe,
withAccessSyncStub( () => {
(filePath) => filePath === appExe, const result = findAppBinary(
() => { path.win32.join(baseDir, 'launcher', 'SubMiner.exe'),
const result = findAppBinary(path.win32.join(baseDir, 'launcher', 'SubMiner.exe'), path.win32); path.win32,
assert.equal(result, appExe); );
}, assert.equal(result, appExe);
); },
}, );
); });
}); });
} finally { } finally {
os.homedir = originalHomedir; os.homedir = originalHomedir;

View File

@@ -264,10 +264,7 @@ function getLinuxDesktopEnv(env: NodeJS.ProcessEnv): LinuxDesktopEnv {
}; };
} }
function shouldForceX11MpvBackend( function shouldForceX11MpvBackend(args: Pick<Args, 'backend'>, env: NodeJS.ProcessEnv): boolean {
args: Pick<Args, 'backend'>,
env: NodeJS.ProcessEnv,
): boolean {
if (process.platform !== 'linux' || !env.DISPLAY?.trim()) { if (process.platform !== 'linux' || !env.DISPLAY?.trim()) {
return false; return false;
} }

View File

@@ -20,8 +20,9 @@
"dev:stats": "cd stats && bun run dev", "dev:stats": "cd stats && bun run dev",
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets", "build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap", "build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
"changelog:build": "bun run scripts/build-changelog.ts build", "changelog:build": "bun run scripts/build-changelog.ts build && bun run changelog:docs",
"changelog:check": "bun run scripts/build-changelog.ts check", "changelog:check": "bun run scripts/build-changelog.ts check",
"changelog:docs": "bun run scripts/build-changelog.ts docs",
"changelog:lint": "bun run scripts/build-changelog.ts lint", "changelog:lint": "bun run scripts/build-changelog.ts lint",
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check", "changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
"changelog:release-notes": "bun run scripts/build-changelog.ts release-notes", "changelog:release-notes": "bun run scripts/build-changelog.ts release-notes",

View File

@@ -197,6 +197,49 @@ test('verifyChangelogReadyForRelease rejects explicit release versions that do n
} }
}); });
test('writeChangelogArtifacts renders breaking changes section above type sections', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('breaking-changes');
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: changed', 'area: config', 'breaking: true', '', '- Renamed `foo` to `bar`.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '002.md'),
['type: fixed', 'area: overlay', '', '- Fixed subtitle rendering.'].join('\n'),
'utf8',
);
try {
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
});
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
const breakingIndex = changelog.indexOf('### Breaking Changes');
const changedIndex = changelog.indexOf('### Changed');
const fixedIndex = changelog.indexOf('### Fixed');
assert.notEqual(breakingIndex, -1, 'Breaking Changes section should exist');
assert.notEqual(changedIndex, -1, 'Changed section should exist');
assert.notEqual(fixedIndex, -1, 'Fixed section should exist');
assert.ok(breakingIndex < changedIndex, 'Breaking Changes should appear before Changed');
assert.ok(changedIndex < fixedIndex, 'Changed should appear before Fixed');
assert.match(changelog, /### Breaking Changes\n- Config: Renamed `foo` to `bar`\./);
assert.match(changelog, /### Changed\n- Config: Renamed `foo` to `bar`\./);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('verifyChangelogFragments rejects invalid metadata', async () => { test('verifyChangelogFragments rejects invalid metadata', async () => {
const { verifyChangelogFragments } = await loadModule(); const { verifyChangelogFragments } = await loadModule();
const workspace = createWorkspace('lint-invalid'); const workspace = createWorkspace('lint-invalid');

View File

@@ -23,6 +23,7 @@ type FragmentType = 'added' | 'changed' | 'fixed' | 'docs' | 'internal';
type ChangeFragment = { type ChangeFragment = {
area: string; area: string;
breaking: boolean;
bullets: string[]; bullets: string[];
path: string; path: string;
type: FragmentType; type: FragmentType;
@@ -144,6 +145,7 @@ function parseFragmentMetadata(
): { ): {
area: string; area: string;
body: string; body: string;
breaking: boolean;
type: FragmentType; type: FragmentType;
} { } {
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
@@ -186,9 +188,12 @@ function parseFragmentMetadata(
throw new Error(`${fragmentPath} must include at least one changelog bullet.`); throw new Error(`${fragmentPath} must include at least one changelog bullet.`);
} }
const breaking = metadata.get('breaking')?.toLowerCase() === 'true';
return { return {
area, area,
body, body,
breaking,
type: type as FragmentType, type: type as FragmentType,
}; };
} }
@@ -199,6 +204,7 @@ function readChangeFragments(cwd: string, deps?: ChangelogFsDeps): ChangeFragmen
const parsed = parseFragmentMetadata(readFileSync(fragmentPath, 'utf8'), fragmentPath); const parsed = parseFragmentMetadata(readFileSync(fragmentPath, 'utf8'), fragmentPath);
return { return {
area: parsed.area, area: parsed.area,
breaking: parsed.breaking,
bullets: normalizeFragmentBullets(parsed.body), bullets: normalizeFragmentBullets(parsed.body),
path: fragmentPath, path: fragmentPath,
type: parsed.type, type: parsed.type,
@@ -219,10 +225,22 @@ function renderFragmentBullet(fragment: ChangeFragment, bullet: string): string
} }
function renderGroupedChanges(fragments: ChangeFragment[]): string { function renderGroupedChanges(fragments: ChangeFragment[]): string {
const sections = CHANGE_TYPES.flatMap((type) => { const sections: string[] = [];
const breakingFragments = fragments.filter((fragment) => fragment.breaking);
if (breakingFragments.length > 0) {
const bullets = breakingFragments
.flatMap((fragment) =>
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
)
.join('\n');
sections.push(`### Breaking Changes\n${bullets}`);
}
for (const type of CHANGE_TYPES) {
const typeFragments = fragments.filter((fragment) => fragment.type === type); const typeFragments = fragments.filter((fragment) => fragment.type === type);
if (typeFragments.length === 0) { if (typeFragments.length === 0) {
return []; continue;
} }
const bullets = typeFragments const bullets = typeFragments
@@ -230,8 +248,8 @@ function renderGroupedChanges(fragments: ChangeFragment[]): string {
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)), fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
) )
.join('\n'); .join('\n');
return [`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`]; sections.push(`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`);
}); }
return sections.join('\n\n'); return sections.join('\n\n');
} }
@@ -487,6 +505,99 @@ function resolveChangedPathsFromGit(
.filter((entry) => entry.path); .filter((entry) => entry.path);
} }
const DOCS_CHANGELOG_PATH = path.join('docs-site', 'changelog.md');
type VersionSection = {
version: string;
date: string;
minor: string;
body: string;
};
function parseVersionSections(changelog: string): VersionSection[] {
const sectionPattern = /^## v(\d+\.\d+\.\d+) \((\d{4}-\d{2}-\d{2})\)$/gm;
const sections: VersionSection[] = [];
let match: RegExpExecArray | null;
while ((match = sectionPattern.exec(changelog)) !== null) {
const version = match[1]!;
const date = match[2]!;
const minor = version.replace(/\.\d+$/, '');
const headingEnd = match.index + match[0].length;
sections.push({ version, date, minor, body: '' });
if (sections.length > 1) {
const prev = sections[sections.length - 2]!;
prev.body = changelog.slice(prev.body as unknown as number, match.index).trim();
}
(sections[sections.length - 1] as { body: unknown }).body = headingEnd;
}
if (sections.length > 0) {
const last = sections[sections.length - 1]!;
last.body = changelog.slice(last.body as unknown as number).trim();
}
return sections;
}
export function generateDocsChangelog(options?: Pick<ChangelogOptions, 'cwd' | 'deps'>): string {
const cwd = options?.cwd ?? process.cwd();
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
const writeFileSync = options?.deps?.writeFileSync ?? fs.writeFileSync;
const log = options?.deps?.log ?? console.log;
const changelogPath = path.join(cwd, 'CHANGELOG.md');
const changelog = readFileSync(changelogPath, 'utf8');
const sections = parseVersionSections(changelog);
if (sections.length === 0) {
throw new Error('No version sections found in CHANGELOG.md');
}
const currentMinor = sections[0]!.minor;
const currentSections = sections.filter((s) => s.minor === currentMinor);
const olderSections = sections.filter((s) => s.minor !== currentMinor);
const lines: string[] = ['# Changelog', ''];
for (const section of currentSections) {
const body = section.body.replace(/^### (.+)$/gm, '**$1**');
lines.push(`## v${section.version} (${section.date})`, '', body, '');
}
if (olderSections.length > 0) {
lines.push('## Previous Versions', '');
const minorGroups = new Map<string, VersionSection[]>();
for (const section of olderSections) {
const group = minorGroups.get(section.minor) ?? [];
group.push(section);
minorGroups.set(section.minor, group);
}
for (const [minor, group] of minorGroups) {
lines.push('<details>', `<summary>v${minor}.x</summary>`, '');
for (const section of group) {
const htmlBody = section.body.replace(/^### (.+)$/gm, '**$1**');
lines.push(`<h2>v${section.version} (${section.date})</h2>`, '', htmlBody, '');
}
lines.push('</details>', '');
}
}
const output =
lines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trimEnd() + '\n';
const outputPath = path.join(cwd, DOCS_CHANGELOG_PATH);
writeFileSync(outputPath, output, 'utf8');
log(`Generated ${outputPath}`);
return outputPath;
}
export function writeReleaseNotesForVersion(options?: ChangelogOptions): string { export function writeReleaseNotesForVersion(options?: ChangelogOptions): string {
const cwd = options?.cwd ?? process.cwd(); const cwd = options?.cwd ?? process.cwd();
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync; const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
@@ -599,6 +710,11 @@ function main(): void {
return; return;
} }
if (command === 'docs') {
generateDocsChangelog(options);
return;
}
throw new Error(`Unknown changelog command: ${command}`); throw new Error(`Unknown changelog command: ${command}`);
} }

View File

@@ -2125,10 +2125,7 @@ test('template generator includes known keys', () => {
/"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/, /"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/,
); );
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./); assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
assert.match( assert.match(output, /"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/);
output,
/"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/,
);
assert.match( assert.match(
output, output,
/"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/, /"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/,

View File

@@ -251,8 +251,7 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'enum', kind: 'enum',
enumValues: MPV_LAUNCH_MODE_VALUES, enumValues: MPV_LAUNCH_MODE_VALUES,
defaultValue: defaultConfig.mpv.launchMode, defaultValue: defaultConfig.mpv.launchMode,
description: description: 'Default window state for SubMiner-managed mpv launches.',
'Default window state for SubMiner-managed mpv launches.',
}, },
{ {
path: 'jellyfin.enabled', path: 'jellyfin.enabled',

View File

@@ -237,10 +237,7 @@ test('detectInstalledFirstRunPlugin detects plugin installed in canonical mpv co
fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true }); fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true });
fs.writeFileSync(pluginEntrypointPath, '-- plugin'); fs.writeFileSync(pluginEntrypointPath, '-- plugin');
assert.equal( assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
detectInstalledFirstRunPlugin(installPaths),
true,
);
}); });
}); });
@@ -256,10 +253,7 @@ test('detectInstalledFirstRunPlugin ignores scoped plugin layout path', () => {
fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true }); fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true });
fs.writeFileSync(pluginEntrypointPath, '-- plugin'); fs.writeFileSync(pluginEntrypointPath, '-- plugin');
assert.equal( assert.equal(detectInstalledFirstRunPlugin(installPaths), false);
detectInstalledFirstRunPlugin(installPaths),
false,
);
}); });
}); });
@@ -272,10 +266,7 @@ test('detectInstalledFirstRunPlugin ignores legacy loader file', () => {
fs.mkdirSync(path.dirname(legacyLoaderPath), { recursive: true }); fs.mkdirSync(path.dirname(legacyLoaderPath), { recursive: true });
fs.writeFileSync(legacyLoaderPath, '-- plugin'); fs.writeFileSync(legacyLoaderPath, '-- plugin');
assert.equal( assert.equal(detectInstalledFirstRunPlugin(installPaths), false);
detectInstalledFirstRunPlugin(installPaths),
false,
);
}); });
}); });
@@ -288,9 +279,6 @@ test('detectInstalledFirstRunPlugin requires main.lua in subminer directory', ()
fs.mkdirSync(pluginDir, { recursive: true }); fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, 'not_main.lua'), '-- plugin'); fs.writeFileSync(path.join(pluginDir, 'not_main.lua'), '-- plugin');
assert.equal( assert.equal(detectInstalledFirstRunPlugin(installPaths), false);
detectInstalledFirstRunPlugin(installPaths),
false,
);
}); });
}); });

View File

@@ -152,13 +152,7 @@ export async function launchWindowsMpv(
try { try {
await deps.spawnDetached( await deps.spawnDetached(
mpvPath, mpvPath,
buildWindowsMpvLaunchArgs( buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath, launchMode),
targets,
extraArgs,
binaryPath,
pluginEntrypointPath,
launchMode,
),
); );
return { ok: true, mpvPath }; return { ok: true, mpvPath };
} catch (error) { } catch (error) {

View File

@@ -126,9 +126,7 @@ export function createKeyboardHandlers(
} }
function acceleratorToKeyString(accelerator: string): string | null { function acceleratorToKeyString(accelerator: string): string | null {
const normalized = accelerator const normalized = accelerator.replace(/\s+/g, '').replace(/cmdorctrl/gi, 'CommandOrControl');
.replace(/\s+/g, '')
.replace(/cmdorctrl/gi, 'CommandOrControl');
if (!normalized) return null; if (!normalized) return null;
const parts = normalized.split('+').filter(Boolean); const parts = normalized.split('+').filter(Boolean);
const keyToken = parts.pop(); const keyToken = parts.pop();

View File

@@ -75,7 +75,10 @@ function createListStub() {
} }
test.afterEach(() => { test.afterEach(() => {
if (Object.prototype.hasOwnProperty.call(globalThis, 'window') && globalThis.window === undefined) { if (
Object.prototype.hasOwnProperty.call(globalThis, 'window') &&
globalThis.window === undefined
) {
Reflect.deleteProperty(globalThis, 'window'); Reflect.deleteProperty(globalThis, 'window');
} }
if ( if (

View File

@@ -22,17 +22,12 @@ function daysFromCivil(year: number, month: number, day: number): bigint {
const yearOfEra = adjustedYear - era * 400; const yearOfEra = adjustedYear - era * 400;
const monthIndex = month + (month > 2 ? -3 : 9); const monthIndex = month + (month > 2 ? -3 : 9);
const dayOfYear = floorDiv(153 * monthIndex + 2, 5) + day - 1; const dayOfYear = floorDiv(153 * monthIndex + 2, 5) + day - 1;
const dayOfEra = const dayOfEra = yearOfEra * 365 + floorDiv(yearOfEra, 4) - floorDiv(yearOfEra, 100) + dayOfYear;
yearOfEra * 365 + floorDiv(yearOfEra, 4) - floorDiv(yearOfEra, 100) + dayOfYear;
return BigInt(era * 146097 + dayOfEra - 719468); return BigInt(era * 146097 + dayOfEra - 719468);
} }
function dateToEpochMs(date: Date): bigint { function dateToEpochMs(date: Date): bigint {
const dayCount = daysFromCivil( const dayCount = daysFromCivil(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
date.getUTCFullYear(),
date.getUTCMonth() + 1,
date.getUTCDate(),
);
const timeOfDayMs = BigInt( const timeOfDayMs = BigInt(
((date.getUTCHours() * 60 + date.getUTCMinutes()) * 60 + date.getUTCSeconds()) * 1000 + ((date.getUTCHours() * 60 + date.getUTCMinutes()) * 60 + date.getUTCSeconds()) * 1000 +
date.getUTCMilliseconds(), date.getUTCMilliseconds(),

View File

@@ -1,7 +1,10 @@
import type { MpvLaunchMode } from '../types/config'; import type { MpvLaunchMode } from '../types/config';
export const MPV_LAUNCH_MODE_VALUES = ['normal', 'maximized', 'fullscreen'] as const satisfies export const MPV_LAUNCH_MODE_VALUES = [
readonly MpvLaunchMode[]; 'normal',
'maximized',
'fullscreen',
] as const satisfies readonly MpvLaunchMode[];
export function parseMpvLaunchMode(value: unknown): MpvLaunchMode | undefined { export function parseMpvLaunchMode(value: unknown): MpvLaunchMode | undefined {
if (typeof value !== 'string') { if (typeof value !== 'string') {

View File

@@ -203,13 +203,7 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults'
'main.lua', 'main.lua',
), ),
pluginDir: path.posix.join(macHomeDir, '.config', 'mpv', 'scripts', 'subminer'), pluginDir: path.posix.join(macHomeDir, '.config', 'mpv', 'scripts', 'subminer'),
pluginConfigPath: path.posix.join( pluginConfigPath: path.posix.join(macHomeDir, '.config', 'mpv', 'script-opts', 'subminer.conf'),
macHomeDir,
'.config',
'mpv',
'script-opts',
'subminer.conf',
),
}); });
assert.deepEqual(resolveDefaultMpvInstallPaths('win32', 'C:\\Users\\tester', undefined), { assert.deepEqual(resolveDefaultMpvInstallPaths('win32', 'C:\\Users\\tester', undefined), {

View File

@@ -242,8 +242,8 @@ export function resolveDefaultMpvInstallPaths(
const platformPath = getPlatformPath(platform); const platformPath = getPlatformPath(platform);
const mpvConfigDir = const mpvConfigDir =
platform === 'linux' || platform === 'darwin' platform === 'linux' || platform === 'darwin'
? platformPath.join(xdgConfigHome?.trim() || platformPath.join(homeDir, '.config'), 'mpv') ? platformPath.join(xdgConfigHome?.trim() || platformPath.join(homeDir, '.config'), 'mpv')
: platformPath.join(homeDir, 'AppData', 'Roaming', 'mpv'); : platformPath.join(homeDir, 'AppData', 'Roaming', 'mpv');
return { return {
supported: platform === 'linux' || platform === 'darwin' || platform === 'win32', supported: platform === 'linux' || platform === 'darwin' || platform === 'win32',