diff --git a/changes/README.md b/changes/README.md index b811b385..28dc8871 100644 --- a/changes/README.md +++ b/changes/README.md @@ -12,10 +12,21 @@ area: overlay - 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: - `type` required: `added`, `changed`, `fixed`, `docs`, or `internal` - `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 - `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 diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 2e7c543a..dbf6725a 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -127,6 +127,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 and window launch mode - [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading ## Core Settings @@ -237,10 +238,10 @@ This stream includes subtitle text plus token metadata (N+1, known-word, frequen } ``` -| Option | Values | Description | -| --------- | ------------------ | -------------------------------------------------------- | -| `enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) | -| `port` | number | Annotation websocket port (default: 6678) | +| Option | Values | Description | +| --------- | --------------- | -------------------------------------------------------------- | +| `enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) | +| `port` | number | Annotation websocket port (default: 6678) | ### Texthooker @@ -257,10 +258,10 @@ See `config.example.jsonc` for detailed configuration options. } ``` -| Option | Values | Description | -| ---------------- | --------------- | ------------------------------------------------------------------------------------------------ | -| `launchAtStartup`| `true`, `false` | Start texthooker automatically with SubMiner startup (default: `true`) | -| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) | +| Option | Values | Description | +| ----------------- | --------------- | ---------------------------------------------------------------------- | +| `launchAtStartup` | `true`, `false` | Start texthooker automatically with SubMiner startup (default: `true`) | +| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) | ## Subtitle Display @@ -365,24 +366,24 @@ Configure the parsed-subtitle sidebar modal. } ``` -| Option | Values | Description | -| --------------------------- | ---------------- | -------------------------------------------------------------------------------- | -| `enabled` | boolean | Enable subtitle sidebar support (`true` 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 | -| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) | -| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list | -| `autoScroll` | boolean | Keep the active cue in view while playback advances | -| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) | -| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) | -| `backgroundColor` | string | Sidebar shell background color | -| `textColor` | hex color | Default cue text color | -| `fontFamily` | string | CSS `font-family` value applied to sidebar cue text | -| `fontSize` | number | Base sidebar cue font size in CSS pixels (default: `16`) | -| `timestampColor` | hex color | Cue timestamp color | -| `activeLineColor` | hex color | Active cue text color | -| `activeLineBackgroundColor` | string | Active cue background color | -| `hoverLineBackgroundColor` | string | Hovered cue background color | +| Option | Values | Description | +| --------------------------- | --------- | ------------------------------------------------------------------------------------------------------- | +| `enabled` | boolean | Enable subtitle sidebar support (`true` 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 | +| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) | +| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list | +| `autoScroll` | boolean | Keep the active cue in view while playback advances | +| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) | +| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) | +| `backgroundColor` | string | Sidebar shell background color | +| `textColor` | hex color | Default cue text color | +| `fontFamily` | string | CSS `font-family` value applied to sidebar cue text | +| `fontSize` | number | Base sidebar cue font size in CSS pixels (default: `16`) | +| `timestampColor` | hex color | Cue timestamp color | +| `activeLineColor` | hex color | Active cue text color | +| `activeLineBackgroundColor` | string | Active 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. @@ -466,25 +467,25 @@ See `config.example.jsonc` for detailed configuration options and more examples. **Default keybindings:** -| Key | Command | Description | -| -------------------- | ---------------------------- | ------------------------------------- | -| `Space` | `["cycle", "pause"]` | Toggle pause | -| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | -| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | -| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser | -| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker | -| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | -| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds | -| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds | -| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds | -| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle | -| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle | -| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous 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+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end | -| `KeyQ` | `["quit"]` | Quit mpv | -| `Ctrl+KeyW` | `["quit"]` | Quit mpv | +| Key | Command | Description | +| -------------------- | ----------------------------- | --------------------------------------- | +| `Space` | `["cycle", "pause"]` | Toggle pause | +| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | +| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | +| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser | +| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker | +| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | +| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds | +| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds | +| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds | +| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle | +| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle | +| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous 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+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end | +| `KeyQ` | `["quit"]` | Quit mpv | +| `Ctrl+KeyW` | `["quit"]` | Quit mpv | **Custom keybindings example:** @@ -604,7 +605,7 @@ Important behavior: "leftStickPress": 9, "rightStickPress": 10, "leftTrigger": 6, - "rightTrigger": 7 + "rightTrigger": 7, }, "bindings": { "toggleLookup": { "kind": "button", "buttonIndex": 0 }, @@ -619,9 +620,9 @@ Important behavior: "leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "horizontal" }, "leftStickVertical": { "kind": "axis", "axisIndex": 1, "dpadFallback": "vertical" }, "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": { "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 | -| ------------------ | --------------------- | ---------------------------------------------------- | -| `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 | -| `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`) | +| Option | Values | Description | +| ------------------ | -------------------- | ------------------------------------------------------------- | +| `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 | +| `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: @@ -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. -| Option | Values | Description | -| --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | -| `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) | -| `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.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`) | -| `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.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.audio` | string | Card field for audio files (default: `ExpressionAudio`) | -| `fields.image` | string | Card field for images (default: `Picture`) | -| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | -| `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`) | -| `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.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | -| `media.generateAudio` | `true`, `false` | Generate audio clips from video (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.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | -| `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.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.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.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) | 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` (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.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.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.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.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.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.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) | -| `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`. | -| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | +| Option | Values | Description | +| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | +| `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) | +| `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.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`) | +| `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.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.audio` | string | Card field for audio files (default: `ExpressionAudio`) | +| `fields.image` | string | Card field for images (default: `Picture`) | +| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | +| `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`) | +| `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.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | +| `media.generateAudio` | `true`, `false` | Generate audio clips from video (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.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | +| `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.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.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.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) | 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` (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.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.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.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.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.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.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) | +| `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`. | +| `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. 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 | | ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `defaultMode` | `"auto"`, `"manual"` | `auto`: try `alass` against secondary subtitle, then fallback to `ffsubsync`; `manual`: open overlay picker | -| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. | -| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. | +| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. | +| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. | | `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. | | `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `_retimed.`. | @@ -1055,18 +1056,18 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin } ``` -| Option | Values | Description | -| ------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------ | -| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | -| `accessToken` | string | Optional explicit AniList access token override (default: empty string) | -| `characterDictionary.enabled` | `true`, `false` | Enable automatic import/update of the merged SubMiner character dictionary for recent AniList media | -| `characterDictionary.refreshTtlHours` | number | Legacy compatibility setting. Parsed and preserved, but merged dictionary retention is now usage-based | -| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) | -| `characterDictionary.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.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.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile | +| Option | Values | Description | +| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- | +| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | +| `accessToken` | string | Optional explicit AniList access token override (default: empty string) | +| `characterDictionary.enabled` | `true`, `false` | Enable automatic import/update of the merged SubMiner character dictionary for recent AniList media | +| `characterDictionary.refreshTtlHours` | number | Legacy compatibility setting. Parsed and preserved, but merged dictionary retention is now usage-based | +| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) | +| `characterDictionary.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.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.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. @@ -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. | External-profile mode behavior: @@ -1208,12 +1209,12 @@ Discord Rich Presence is enabled by default. SubMiner publishes a polished activ } ``` -| Option | Values | Description | -| ------------------ | ------------------------------------------------- | ---------------------------------------------------------- | -| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) | -| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) | -| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | -| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | +| Option | Values | Description | +| ------------------ | ------------------------------------------------ | ---------------------------------------------------------- | +| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) | +| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) | +| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | +| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | 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. -| Preset | Idle details | Small image text | Vibe | -| ------------ | ----------------------------------- | ------------------ | --------------------------------------- | -| **`default`**| `Sentence Mining` | `日本語学習中` | Clean, bilingual flair | -| `meme` | `Mining and crafting (Anki cards)` | `Sentence Mining` | Minecraft-inspired joke | -| `japanese` | `文の採掘中` | `イマージョン学習` | Fully Japanese | -| `minimal` | `SubMiner` | *(none)* | Bare essentials, no small image overlay | +| Preset | Idle details | Small image text | Vibe | +| ------------- | ---------------------------------- | ------------------ | --------------------------------------- | +| **`default`** | `Sentence Mining` | `日本語学習中` | Clean, bilingual flair | +| `meme` | `Mining and crafting (Anki cards)` | `Sentence Mining` | Minecraft-inspired joke | +| `japanese` | `文の採掘中` | `イマージョン学習` | Fully Japanese | +| `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. @@ -1273,23 +1274,23 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles } ``` -| Option | Values | Description | -| ------------------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. | -| `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `/immersion.sqlite`. | -| `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. | -| `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`. | -| `payloadCapBytes` | integer (`64`-`8192`) | Event payload byte cap before truncation marker. Default `256`. | -| `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`. | -| `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.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.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.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). | +| Option | Values | Description | +| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. | +| `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `/immersion.sqlite`. | +| `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. | +| `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`. | +| `payloadCapBytes` | integer (`64`-`8192`) | Event payload byte cap before truncation marker. Default `256`. | +| `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`. | +| `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.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.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.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: @@ -1326,11 +1327,11 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t } ``` -| Option | Values | Description | -| ----------------- | ----------------- | --------------------------------------------------------------------------- | -| `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`. | -| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. | +| Option | Values | Description | +| ----------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------- | +| `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`. | +| `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`. | 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 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 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 | -| --------------------- | -------------------- | ---------------------------------------------------------------------------------------------- | -| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) | +| Option | Values | Description | +| --------------------- | -------- | ------------------------------------------------------------------------------------------------ | +| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) | Current launcher behavior: diff --git a/docs-site/docs-sync.test.ts b/docs-site/docs-sync.test.ts index 6e7bf0b5..b6a4fe07 100644 --- a/docs-site/docs-sync.test.ts +++ b/docs-site/docs-sync.test.ts @@ -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 developmentContents = readFileSync(new URL('./development.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'); function extractReleaseHeadings(content: string, count: number): string[] { @@ -17,6 +20,13 @@ function extractReleaseHeadings(content: string, count: number): string[] { .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', () => { expect(usageContents).not.toContain('--mode preprocess'); 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('### 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', () => { - expect(extractReleaseHeadings(changelogContents, 3)).toEqual(extractReleaseHeadings(rootChangelogContents, 3)); +test('docs changelog keeps the current minor release headings aligned with the root changelog', () => { + const docsHeadings = extractCurrentMinorHeadings(changelogContents); + expect(docsHeadings.length).toBeGreaterThan(0); + expect(docsHeadings).toEqual(extractReleaseHeadings(rootChangelogContents, docsHeadings.length)); }); diff --git a/launcher/aniskip-metadata.ts b/launcher/aniskip-metadata.ts index 92d083ea..3dee9dde 100644 --- a/launcher/aniskip-metadata.ts +++ b/launcher/aniskip-metadata.ts @@ -125,10 +125,7 @@ function titleOverlapScore(expectedTitle: string, candidateTitle: string): numbe if (!expected || !candidate) return 0; if (candidate.includes(expected)) return 120; - if ( - candidate.split(' ').length >= 2 && - ` ${expected} `.includes(` ${candidate} `) - ) { + if (candidate.split(' ').length >= 2 && ` ${expected} `.includes(` ${candidate} `)) { return 90; } diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index a618b3f6..1486e6a6 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -715,18 +715,18 @@ function runFindAppBinaryWindowsInstallDirCase(): void { process.env.SUBMINER_BINARY_PATH = installDir; withPlatform('win32', () => { - withExistsAndStatSyncStubs( - { existingPaths: [appExe], directoryPaths: [installDir] }, - () => { - withAccessSyncStub( - (filePath) => filePath === appExe, - () => { - const result = findAppBinary(path.win32.join(baseDir, 'launcher', 'SubMiner.exe'), path.win32); - assert.equal(result, appExe); - }, - ); - }, - ); + withExistsAndStatSyncStubs({ existingPaths: [appExe], directoryPaths: [installDir] }, () => { + withAccessSyncStub( + (filePath) => filePath === appExe, + () => { + const result = findAppBinary( + path.win32.join(baseDir, 'launcher', 'SubMiner.exe'), + path.win32, + ); + assert.equal(result, appExe); + }, + ); + }); }); } finally { os.homedir = originalHomedir; diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 9900de53..61cf3049 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -264,10 +264,7 @@ function getLinuxDesktopEnv(env: NodeJS.ProcessEnv): LinuxDesktopEnv { }; } -function shouldForceX11MpvBackend( - args: Pick, - env: NodeJS.ProcessEnv, -): boolean { +function shouldForceX11MpvBackend(args: Pick, env: NodeJS.ProcessEnv): boolean { if (process.platform !== 'linux' || !env.DISPLAY?.trim()) { return false; } diff --git a/package.json b/package.json index 6a075784..51267a10 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,9 @@ "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: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:docs": "bun run scripts/build-changelog.ts docs", "changelog:lint": "bun run scripts/build-changelog.ts lint", "changelog:pr-check": "bun run scripts/build-changelog.ts pr-check", "changelog:release-notes": "bun run scripts/build-changelog.ts release-notes", diff --git a/scripts/build-changelog.test.ts b/scripts/build-changelog.test.ts index a6d7186b..d100e1a6 100644 --- a/scripts/build-changelog.test.ts +++ b/scripts/build-changelog.test.ts @@ -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 () => { const { verifyChangelogFragments } = await loadModule(); const workspace = createWorkspace('lint-invalid'); diff --git a/scripts/build-changelog.ts b/scripts/build-changelog.ts index ac96a61f..63bc2d3a 100644 --- a/scripts/build-changelog.ts +++ b/scripts/build-changelog.ts @@ -23,6 +23,7 @@ type FragmentType = 'added' | 'changed' | 'fixed' | 'docs' | 'internal'; type ChangeFragment = { area: string; + breaking: boolean; bullets: string[]; path: string; type: FragmentType; @@ -144,6 +145,7 @@ function parseFragmentMetadata( ): { area: string; body: string; + breaking: boolean; type: FragmentType; } { const lines = content.split(/\r?\n/); @@ -186,9 +188,12 @@ function parseFragmentMetadata( throw new Error(`${fragmentPath} must include at least one changelog bullet.`); } + const breaking = metadata.get('breaking')?.toLowerCase() === 'true'; + return { area, body, + breaking, type: type as FragmentType, }; } @@ -199,6 +204,7 @@ function readChangeFragments(cwd: string, deps?: ChangelogFsDeps): ChangeFragmen const parsed = parseFragmentMetadata(readFileSync(fragmentPath, 'utf8'), fragmentPath); return { area: parsed.area, + breaking: parsed.breaking, bullets: normalizeFragmentBullets(parsed.body), path: fragmentPath, type: parsed.type, @@ -219,10 +225,22 @@ function renderFragmentBullet(fragment: ChangeFragment, bullet: string): 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); if (typeFragments.length === 0) { - return []; + continue; } const bullets = typeFragments @@ -230,8 +248,8 @@ function renderGroupedChanges(fragments: ChangeFragment[]): string { fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)), ) .join('\n'); - return [`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`]; - }); + sections.push(`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`); + } return sections.join('\n\n'); } @@ -487,6 +505,99 @@ function resolveChangedPathsFromGit( .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): 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(); + 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('
', `v${minor}.x`, ''); + for (const section of group) { + const htmlBody = section.body.replace(/^### (.+)$/gm, '**$1**'); + lines.push(`

v${section.version} (${section.date})

`, '', htmlBody, ''); + } + lines.push('
', ''); + } + } + + 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 { const cwd = options?.cwd ?? process.cwd(); const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync; @@ -599,6 +710,11 @@ function main(): void { return; } + if (command === 'docs') { + generateDocsChangelog(options); + return; + } + throw new Error(`Unknown changelog command: ${command}`); } diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 3da46b45..0b7ab18a 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -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/, ); assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./); - assert.match( - output, - /"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/, - ); + assert.match(output, /"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/); assert.match( output, /"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/, diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index bab1934e..012d01e1 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -251,8 +251,7 @@ export function buildIntegrationConfigOptionRegistry( kind: 'enum', enumValues: MPV_LAUNCH_MODE_VALUES, defaultValue: defaultConfig.mpv.launchMode, - description: - 'Default window state for SubMiner-managed mpv launches.', + description: 'Default window state for SubMiner-managed mpv launches.', }, { path: 'jellyfin.enabled', diff --git a/src/main/runtime/first-run-setup-plugin.test.ts b/src/main/runtime/first-run-setup-plugin.test.ts index 0e7789de..075c2621 100644 --- a/src/main/runtime/first-run-setup-plugin.test.ts +++ b/src/main/runtime/first-run-setup-plugin.test.ts @@ -237,10 +237,7 @@ test('detectInstalledFirstRunPlugin detects plugin installed in canonical mpv co fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true }); fs.writeFileSync(pluginEntrypointPath, '-- plugin'); - assert.equal( - detectInstalledFirstRunPlugin(installPaths), - true, - ); + assert.equal(detectInstalledFirstRunPlugin(installPaths), true); }); }); @@ -256,10 +253,7 @@ test('detectInstalledFirstRunPlugin ignores scoped plugin layout path', () => { fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true }); fs.writeFileSync(pluginEntrypointPath, '-- plugin'); - assert.equal( - detectInstalledFirstRunPlugin(installPaths), - false, - ); + assert.equal(detectInstalledFirstRunPlugin(installPaths), false); }); }); @@ -272,10 +266,7 @@ test('detectInstalledFirstRunPlugin ignores legacy loader file', () => { fs.mkdirSync(path.dirname(legacyLoaderPath), { recursive: true }); fs.writeFileSync(legacyLoaderPath, '-- plugin'); - assert.equal( - detectInstalledFirstRunPlugin(installPaths), - false, - ); + assert.equal(detectInstalledFirstRunPlugin(installPaths), false); }); }); @@ -288,9 +279,6 @@ test('detectInstalledFirstRunPlugin requires main.lua in subminer directory', () fs.mkdirSync(pluginDir, { recursive: true }); fs.writeFileSync(path.join(pluginDir, 'not_main.lua'), '-- plugin'); - assert.equal( - detectInstalledFirstRunPlugin(installPaths), - false, - ); + assert.equal(detectInstalledFirstRunPlugin(installPaths), false); }); }); diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts index 3d6dfb64..3006defd 100644 --- a/src/main/runtime/windows-mpv-launch.ts +++ b/src/main/runtime/windows-mpv-launch.ts @@ -152,13 +152,7 @@ export async function launchWindowsMpv( try { await deps.spawnDetached( mpvPath, - buildWindowsMpvLaunchArgs( - targets, - extraArgs, - binaryPath, - pluginEntrypointPath, - launchMode, - ), + buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath, launchMode), ); return { ok: true, mpvPath }; } catch (error) { diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 33f6c9a7..8d97a3a9 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -126,9 +126,7 @@ export function createKeyboardHandlers( } function acceleratorToKeyString(accelerator: string): string | null { - const normalized = accelerator - .replace(/\s+/g, '') - .replace(/cmdorctrl/gi, 'CommandOrControl'); + const normalized = accelerator.replace(/\s+/g, '').replace(/cmdorctrl/gi, 'CommandOrControl'); if (!normalized) return null; const parts = normalized.split('+').filter(Boolean); const keyToken = parts.pop(); diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index 84074387..4092fca2 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -75,7 +75,10 @@ function createListStub() { } 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'); } if ( diff --git a/src/shared/log-files.ts b/src/shared/log-files.ts index db06927e..7e837824 100644 --- a/src/shared/log-files.ts +++ b/src/shared/log-files.ts @@ -22,17 +22,12 @@ function daysFromCivil(year: number, month: number, day: number): bigint { const yearOfEra = adjustedYear - era * 400; const monthIndex = month + (month > 2 ? -3 : 9); const dayOfYear = floorDiv(153 * monthIndex + 2, 5) + day - 1; - const dayOfEra = - yearOfEra * 365 + floorDiv(yearOfEra, 4) - floorDiv(yearOfEra, 100) + dayOfYear; + const dayOfEra = yearOfEra * 365 + floorDiv(yearOfEra, 4) - floorDiv(yearOfEra, 100) + dayOfYear; return BigInt(era * 146097 + dayOfEra - 719468); } function dateToEpochMs(date: Date): bigint { - const dayCount = daysFromCivil( - date.getUTCFullYear(), - date.getUTCMonth() + 1, - date.getUTCDate(), - ); + const dayCount = daysFromCivil(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()); const timeOfDayMs = BigInt( ((date.getUTCHours() * 60 + date.getUTCMinutes()) * 60 + date.getUTCSeconds()) * 1000 + date.getUTCMilliseconds(), diff --git a/src/shared/mpv-launch-mode.ts b/src/shared/mpv-launch-mode.ts index a73fbb6f..e6372e81 100644 --- a/src/shared/mpv-launch-mode.ts +++ b/src/shared/mpv-launch-mode.ts @@ -1,7 +1,10 @@ import type { MpvLaunchMode } from '../types/config'; -export const MPV_LAUNCH_MODE_VALUES = ['normal', 'maximized', 'fullscreen'] as const satisfies - readonly MpvLaunchMode[]; +export const MPV_LAUNCH_MODE_VALUES = [ + 'normal', + 'maximized', + 'fullscreen', +] as const satisfies readonly MpvLaunchMode[]; export function parseMpvLaunchMode(value: unknown): MpvLaunchMode | undefined { if (typeof value !== 'string') { diff --git a/src/shared/setup-state.test.ts b/src/shared/setup-state.test.ts index d52a24b4..f600ea8c 100644 --- a/src/shared/setup-state.test.ts +++ b/src/shared/setup-state.test.ts @@ -203,13 +203,7 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults' 'main.lua', ), pluginDir: path.posix.join(macHomeDir, '.config', 'mpv', 'scripts', 'subminer'), - pluginConfigPath: path.posix.join( - macHomeDir, - '.config', - 'mpv', - 'script-opts', - 'subminer.conf', - ), + pluginConfigPath: path.posix.join(macHomeDir, '.config', 'mpv', 'script-opts', 'subminer.conf'), }); assert.deepEqual(resolveDefaultMpvInstallPaths('win32', 'C:\\Users\\tester', undefined), { diff --git a/src/shared/setup-state.ts b/src/shared/setup-state.ts index dd5f6828..a08a62d0 100644 --- a/src/shared/setup-state.ts +++ b/src/shared/setup-state.ts @@ -242,8 +242,8 @@ export function resolveDefaultMpvInstallPaths( const platformPath = getPlatformPath(platform); const mpvConfigDir = platform === 'linux' || platform === 'darwin' - ? platformPath.join(xdgConfigHome?.trim() || platformPath.join(homeDir, '.config'), 'mpv') - : platformPath.join(homeDir, 'AppData', 'Roaming', 'mpv'); + ? platformPath.join(xdgConfigHome?.trim() || platformPath.join(homeDir, '.config'), 'mpv') + : platformPath.join(homeDir, 'AppData', 'Roaming', 'mpv'); return { supported: platform === 'linux' || platform === 'darwin' || platform === 'win32',