mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-12 03:13:39 -07:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
71d106b417
|
|||
|
05ac3a0382
|
|||
|
2c5a803839
|
|||
|
572bdd1cf7
|
|||
|
b9fe555b94
|
|||
|
8f362063dd
|
|||
|
eb1af727bb
|
|||
|
1fc83a842d
|
|||
|
a4edf53d21
|
|||
|
1a3944aa4f
|
|||
|
2d1b6cb78e
|
|||
|
0ef95cde09
|
|||
| 94a65416ae | |||
| 0a384a22c9 | |||
|
b3b45521b6
|
|||
|
131b23efa9
|
|||
| e2afceb492 | |||
| 7be1843c41 | |||
|
c09d009a3e
|
|||
| 2007e28be8 | |||
| d5bfdcae7b | |||
| 311f1e8ee5 | |||
| e6a16a069b | |||
| af67c53dd6 | |||
|
ea79e331fa
|
|||
| ee89b0c8a9 | |||
| f2fd58cd2b |
@@ -1,7 +1,7 @@
|
||||
name: Bug Report
|
||||
description: Report something that is broken or behaving incorrectly
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
title: '[Bug]: '
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or an improvement to an existing one
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
@@ -31,6 +31,6 @@ If docs-site/ changed, also: bun run docs:test && bun run docs:build
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Added a changelog fragment, or this PR is labeled `skip-changelog` (see [`changes/README.md`](../changes/README.md))
|
||||
- [ ] Reconciled current-outcome changelog fragment(s), or this PR is labeled `skip-changelog` (see [`changes/README.md`](../changes/README.md))
|
||||
- [ ] Docs updated in the same PR if behavior, defaults, flags, shortcuts, ports, or APIs changed
|
||||
- [ ] Relevant checks pass locally (typecheck, tests, build)
|
||||
|
||||
@@ -68,7 +68,7 @@ Start here, then leave this file.
|
||||
|
||||
## Release / PR Notes
|
||||
|
||||
- User-visible PRs need one fragment in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; apply the `skip-changelog` label to opt out)
|
||||
- User-visible PRs need reconciled current-outcome fragment(s) in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; inspect existing same-PR fragments, then update/remove stale bullets or add only genuinely separate outcomes; apply the `skip-changelog` label to opt out)
|
||||
- User-visible docs changes get a `type: docs` fragment
|
||||
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
|
||||
- PR review helpers:
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## v0.16.0 (2026-06-10)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Notification Mode `both`**: `notificationType: "both"` now routes to overlay + system notifications. Users who previously used `"both"` for mpv OSD + system notifications should set `notificationType` to `"osd-system"` in `config.jsonc`. The `osd` and `osd-system` values remain valid as config-file entries but no longer appear in Settings.
|
||||
|
||||
### Added
|
||||
|
||||
- **Overlay Notifications**: New overlay notification stack (Catppuccin Macchiato theme) with configurable screen position (`notifications.overlayPosition`: top-left, top-center, top-right), 3-second transient dismissals, and persistent cards for long-running jobs like character dictionary sync.
|
||||
- **Notification History Panel**: `Ctrl/Cmd+N` (configurable via `shortcuts.toggleNotificationHistory`) opens a session log of all notifications. Works whether the overlay or mpv has focus; slides in from the notification edge; entries can be individually removed or cleared.
|
||||
- **Mined-Card Notification Actions**: Mined-card overlay notifications show generated card thumbnails and include an Open in Anki button in both live cards and history entries.
|
||||
- **Update Notification Action**: Update-available overlay notifications include an Update button to start the app update flow directly from the notification.
|
||||
- **Stats Search**: New Search tab in Stats for realtime subtitle sentence search with media context, headword matching, and mining actions for source-backed sentence cards or exact-match word/audio cards.
|
||||
|
||||
### Changed
|
||||
|
||||
- **AniSkip**: Moved intro detection from the mpv plugin to the SubMiner app. Lookups now cover every file loaded during a session including playlist advances, and `mpv.aniskipEnabled`/`mpv.aniskipButtonKey` hot-reload without restarting playback. The bundled plugin no longer makes network calls. Note: AniSkip now requires the SubMiner app to be connected; plugin-only mpv sessions will not fetch skip windows.
|
||||
- **Stats Library**: Entries are now split by detected season (season folder first, filename parsing as fallback). Existing combined-series rows are automatically migrated to per-season entries on startup. Cover art and anime details refresh immediately after a manual AniList entry change.
|
||||
- **Stats Vocabulary**: Remembers Hide Known/Hide Kana filters across sessions, applies Hide Kana filtering cross-title, collapses duplicate token variants in exclusions, and matches Related Seen Words by shared readings or kanji.
|
||||
- **Stats Trends**: Reorganized into Activity, Cumulative Totals, Efficiency, Patterns, and Library sections; disambiguated per-period vs. cumulative charts; added Words/Min and Cards/Hour efficiency charts.
|
||||
- **Stats Mining**: Sentence cards are created before slow media generation finishes; stored/requested secondary subtitles are preserved before falling back to sidecar or alass-retimed English subtitles; empty `ankiConnect.deck` falls back to Yomitan's mining deck; partial media failures are surfaced.
|
||||
- **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes.
|
||||
- **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads.
|
||||
- **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering.
|
||||
- **Update Notification Default**: New installs default `notificationType` to `both` so update alerts appear in both overlay and system notifications.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **AniList Completion**: Entries are now marked completed when a post-watch update reaches the final known episode of the season.
|
||||
- **AniSkip Markers**: Fixed intro markers disappearing after same-media mpv reloads; fixed metadata detection for intros that start at 0 seconds and common release-group filenames.
|
||||
- **Jellyfin Session**: Remote session now restarts after setup login so the websocket reconnects with fresh credentials, and stops cleanly on logout.
|
||||
- **Sentence Card Audio**: Mining a sentence card no longer fills the expression audio field; generated audio goes only to the configured sentence audio field.
|
||||
- **Stats Mining Fields**: Sentence clips update `SentenceAudio` correctly; word audio uses configured Yomitan sources; English subtitle text is not written to word cards; secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
|
||||
- **Overlay Hover Readiness**: Subtitle bars are hoverable and clickable from the first subtitle line on visible overlay startup or resume, without waiting for the next subtitle event.
|
||||
- **Startup Autoplay**: Playback is released after tokenization and overlay content are ready even when playback begins before the first subtitle line appears.
|
||||
- **Overlay Startup Feedback**: Restored mpv OSD loading spinner that starts on connect, media open, or overlay request, and clears once the overlay is content-ready and visible.
|
||||
- **Linux Overlay Input**: Notification close and action buttons remain clickable above subtitle bars on Linux.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout.
|
||||
- **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API.
|
||||
- **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.15.2 (2026-06-02)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
|
||||
.PHONY: help submodules deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
|
||||
|
||||
APP_NAME := subminer
|
||||
THEME_SOURCE := assets/themes/subminer.rasi
|
||||
@@ -72,7 +72,8 @@ help:
|
||||
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
|
||||
"" \
|
||||
"Other targets:" \
|
||||
" deps Install JS dependencies (root + stats + texthooker-ui)" \
|
||||
" submodules Initialize/update git submodules" \
|
||||
" deps Initialize submodules and install JS dependencies (root + stats + texthooker-ui)" \
|
||||
" uninstall-linux Remove Linux install artifacts" \
|
||||
" uninstall-macos Remove macOS install artifacts" \
|
||||
" uninstall-windows Remove Windows mpv plugin artifacts" \
|
||||
@@ -105,8 +106,10 @@ print-dirs:
|
||||
"MACOS_APP_SRC=$(MACOS_APP_SRC)" \
|
||||
"MACOS_ZIP_SRC=$(MACOS_ZIP_SRC)"
|
||||
|
||||
deps:
|
||||
@$(MAKE) --no-print-directory ensure-bun
|
||||
submodules:
|
||||
@git submodule update --init --recursive
|
||||
|
||||
deps: submodules ensure-bun
|
||||
@bun install
|
||||
@cd stats && bun install --frozen-lockfile
|
||||
@cd vendor/texthooker-ui && bun install --frozen-lockfile
|
||||
|
||||
@@ -121,6 +121,7 @@ Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but
|
||||
| yt-dlp | Optional | YouTube playback |
|
||||
| fzf / rofi | Optional | Video picker in the launcher |
|
||||
| alass / ffsubsync | Optional | Subtitle sync |
|
||||
| guessit | Optional | Better anime title and episode detection |
|
||||
|
||||
<details>
|
||||
<summary><b>Platform-specific install commands</b></summary>
|
||||
@@ -228,6 +229,7 @@ SubMiner builds on the work of these open-source projects:
|
||||
|
||||
| Project | Role |
|
||||
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps |
|
||||
| [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
|
||||
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
|
||||
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
|
||||
|
||||
@@ -31,6 +31,13 @@ Rules:
|
||||
- `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
|
||||
|
||||
PR branch workflow:
|
||||
|
||||
- Before adding a fragment or bullet, inspect the `changes/*.md` files already changed in the PR
|
||||
- If the new work fixes, modifies, renames, or supersedes behavior introduced or referenced by that fragment, edit or remove the stale bullet instead of adding follow-up churn
|
||||
- Add a new bullet only when it describes a truly separate user-visible outcome
|
||||
- Multiple fragment files are allowed when one PR has genuinely separate release-note outcomes, but keep them minimized and current
|
||||
|
||||
How fragments turn into a release:
|
||||
|
||||
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- Updated default overlay subtitle delay/step bindings to match mpv: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` and show subtitle delay on the OSD. Removed the old SubMiner-only adjacent-cue delay action.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: release
|
||||
|
||||
- Kept the GitHub release `What's Changed` and `New Contributors` attribution sections when CI regenerates release notes from the committed changelog.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: internal
|
||||
area: runtime
|
||||
|
||||
- Split main-process runtime wiring into focused modules without changing user-facing behavior.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Fixed manual AniList linking from the stats anime page so automatic searches drop the generated `Season N` suffix and search only the anime title.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: updates
|
||||
|
||||
- New installs now default update notifications to overlay-only instead of overlay + system notifications.
|
||||
+46
-10
@@ -172,10 +172,19 @@
|
||||
"updates": {
|
||||
"enabled": true, // Run automatic update checks in the background. Values: true | false
|
||||
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
|
||||
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
|
||||
"notificationType": "overlay", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
|
||||
}, // Automatic update check behavior.
|
||||
|
||||
// ==========================================
|
||||
// Notifications
|
||||
// Overlay notification display behavior.
|
||||
// Hot-reload: position changes apply to the next overlay notification.
|
||||
// ==========================================
|
||||
"notifications": {
|
||||
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
||||
}, // Overlay notification display behavior.
|
||||
|
||||
// ==========================================
|
||||
// Keyboard Shortcuts
|
||||
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
@@ -199,7 +208,8 @@
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
|
||||
"toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility.
|
||||
"toggleNotificationHistory": "CommandOrControl+N" // Accelerator that toggles the overlay notification history panel.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
@@ -280,15 +290,41 @@
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+BracketRight", // Key setting.
|
||||
"key": "Ctrl+Shift+ArrowLeft", // Key setting.
|
||||
"command": [
|
||||
"__sub-delay-next-line"
|
||||
"sub-step",
|
||||
-1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+BracketLeft", // Key setting.
|
||||
"key": "Ctrl+Shift+ArrowRight", // Key setting.
|
||||
"command": [
|
||||
"__sub-delay-prev-line"
|
||||
"sub-step",
|
||||
1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "KeyZ", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
-0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+KeyZ", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "KeyX", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
@@ -496,7 +532,7 @@
|
||||
"tags": [
|
||||
"SubMiner"
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.
|
||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
@@ -539,7 +575,7 @@
|
||||
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
|
||||
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
|
||||
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
|
||||
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
|
||||
"notificationType": "overlay", // Notification surface used to announce mining and update outcomes. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||
}, // Behavior setting.
|
||||
"nPlusOne": {
|
||||
@@ -634,8 +670,8 @@
|
||||
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
||||
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
||||
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
|
||||
"aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false
|
||||
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible.
|
||||
"aniskipEnabled": true, // Enable AniSkip intro detection, chapter markers, and the skip-intro key. Values: true | false
|
||||
"aniskipButtonKey": "TAB" // mpv key used to skip the detected intro while the skip prompt is visible.
|
||||
}, // SubMiner-managed mpv launch and bundled plugin options.
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -328,6 +328,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{ text: 'YouTube', link: '/youtube-integration' },
|
||||
{ text: 'Jimaku', link: '/jimaku-integration' },
|
||||
{ text: 'AniList', link: '/anilist-integration' },
|
||||
{ text: 'AniSkip', link: '/aniskip-integration' },
|
||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
|
||||
|
||||
The update flow:
|
||||
|
||||
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename. It tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
||||
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
||||
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
|
||||
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
||||
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# AniSkip Integration
|
||||
|
||||
SubMiner integrates with [AniSkip](https://aniskip.com) to automatically detect anime intro intervals and let you skip them with a single key press.
|
||||
|
||||
Intro detection runs in the SubMiner app over the mpv IPC socket. It is available whenever the overlay is connected to mpv - not just at launch - and covers every local file loaded during an mpv session, including playlist advances.
|
||||
|
||||
## Setup
|
||||
|
||||
AniSkip is opt-in. Enable it in your config:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mpv": {
|
||||
"aniskipEnabled": true,
|
||||
"aniskipButtonKey": "TAB",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Both settings hot-reload: changing them in your config takes effect immediately without restarting playback or mpv.
|
||||
|
||||
For best title and episode detection, install [`guessit`](https://github.com/guessit-io/guessit):
|
||||
|
||||
```bash
|
||||
python3 -m pip install --user guessit
|
||||
```
|
||||
|
||||
Without `guessit`, SubMiner falls back to an internal filename parser which handles most common naming conventions but may miss unusual formats.
|
||||
|
||||
## How It Works
|
||||
|
||||
On each local file load:
|
||||
|
||||
1. SubMiner infers the anime title, season, and episode number from the filename and path (using `guessit` if available, otherwise the built-in parser). Remote URLs are skipped entirely.
|
||||
2. The title is matched against MyAnimeList to resolve a MAL id.
|
||||
3. SubMiner queries the AniSkip API for an OP skip interval for that MAL id and episode.
|
||||
4. If an interval is found, SubMiner adds `AniSkip Intro Start` and `AniSkip Intro End` chapter markers to the current file and binds the skip key (`mpv.aniskipButtonKey`, default `TAB`).
|
||||
5. At the start of the intro, an OSD prompt appears for 3 seconds: `You can skip by pressing TAB` (reflects your configured key). Pressing the key at any point during the intro seeks to the intro end.
|
||||
|
||||
Results are cached per file for the app session. Reload detection is also handled: if mpv reloads the same file, SubMiner re-applies the chapter markers without a new API lookup.
|
||||
|
||||
## Triggering from mpv
|
||||
|
||||
You can trigger AniSkip actions from mpv script-messages:
|
||||
|
||||
| Command | Effect |
|
||||
| ------- | ------ |
|
||||
| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) |
|
||||
| `script-message subminer-aniskip-refresh` | Force a fresh lookup for the current file, discarding any cached result |
|
||||
|
||||
These are handled by the SubMiner app over the IPC socket.
|
||||
@@ -4,11 +4,12 @@ SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-
|
||||
This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
|
||||
|
||||
::: tip New to these terms?
|
||||
|
||||
- **Anki** is the flashcard app where your study cards live.
|
||||
- **AnkiConnect** is a free add-on that lets other programs (like SubMiner) talk to Anki over a local connection. SubMiner needs it installed to add or edit cards.
|
||||
- A **note type** (also called a "model") is the template that defines what a card looks like - for example the Kiku or Lapis templates many Japanese learners use.
|
||||
- A **field** is one labeled slot in that template, such as `Sentence`, `Expression`, or `Picture`. SubMiner fills these fields when it mines a card.
|
||||
:::
|
||||
:::
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -22,7 +23,7 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po
|
||||
|
||||
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, image, and translation fields automatically. Two detection methods are available:
|
||||
|
||||
**Proxy mode** (default) - SubMiner runs a local *proxy*: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant.
|
||||
**Proxy mode** (default) - SubMiner runs a local _proxy_: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant.
|
||||
|
||||
**Polling mode** (fallback, when the proxy is disabled) - SubMiner asks AnkiConnect every few seconds whether any new cards were added, then enriches them. Simpler setup, but with a short delay (~3 seconds).
|
||||
|
||||
@@ -36,7 +37,7 @@ In both modes, the enrichment workflow is the same:
|
||||
4. Fills the translation field from the secondary subtitle or AI.
|
||||
5. Writes metadata to the miscInfo field.
|
||||
|
||||
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks. In Settings, the AnkiConnect deck dropdown auto-fills from Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect.
|
||||
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it uses Yomitan's current mining deck when available; otherwise it searches all decks. In Settings, the AnkiConnect deck dropdown auto-fills and persists Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect.
|
||||
Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
|
||||
|
||||
### Proxy Mode Setup (Yomitan / Texthooker)
|
||||
@@ -215,11 +216,15 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
|
||||
"overwriteImage": true, // replace existing image, or append
|
||||
"mediaInsertMode": "append", // "append" or "prepend" to field content
|
||||
"autoUpdateNewCards": true, // auto-update when new card detected
|
||||
"notificationType": "osd" // "osd", "system", "both", or "none"
|
||||
"notificationType": "overlay" // "overlay", "system", "both", or "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`both` now means overlay + system notification. `osd` and `osd-system` are legacy config-file-only values; set `notificationType` to `"osd-system"` in `config.jsonc` if you previously used `both` and want to keep mpv OSD + system notifications. The Settings window shows `osd` or `osd-system` when already configured, but only offers `overlay`, `system`, `both`, and `none` as normal choices.
|
||||
|
||||
When media is available, mined-card overlay and system notifications include the same current-frame thumbnail.
|
||||
|
||||
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged.
|
||||
|
||||
## AI Translation
|
||||
@@ -350,7 +355,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
||||
"overwriteImage": true,
|
||||
"mediaInsertMode": "append",
|
||||
"autoUpdateNewCards": true,
|
||||
"notificationType": "osd",
|
||||
"notificationType": "overlay",
|
||||
},
|
||||
"ai": {
|
||||
"enabled": false,
|
||||
|
||||
@@ -30,7 +30,7 @@ launcher/ # Standalone CLI launcher wrapper and mpv helpers
|
||||
plugin/
|
||||
subminer/ # Modular mpv plugin (init · main · bootstrap · lifecycle · process
|
||||
# state · messages · hover · ui · options · environment · log
|
||||
# binary · aniskip · aniskip_match)
|
||||
# binary)
|
||||
src/
|
||||
ai/ # AI translation provider utilities (client, config)
|
||||
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
|
||||
@@ -130,7 +130,7 @@ src/renderer/
|
||||
### Launcher + Plugin Runtimes
|
||||
|
||||
- `launcher/main.ts` dispatches commands through `launcher/commands/*` and shared config readers in `launcher/config/*`. It handles mpv startup, app passthrough, Jellyfin helper commands, and playback handoff.
|
||||
- `plugin/subminer/init.lua` runs inside mpv and loads modular Lua files: `main.lua` (orchestration), `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution), `aniskip.lua` + `aniskip_match.lua` (intro-skip UX).
|
||||
- `plugin/subminer/init.lua` runs inside mpv and loads modular Lua files: `main.lua` (orchestration), `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution). AniSkip intro detection lives in the SubMiner app (`src/main/runtime/aniskip-runtime.ts`), which drives mpv chapters and the skip key over the IPC socket.
|
||||
|
||||
## Flow Diagram
|
||||
|
||||
|
||||
+57
-4
@@ -1,6 +1,59 @@
|
||||
# Changelog
|
||||
|
||||
## v0.15.2 (2026-06-02)
|
||||
## v0.16.0 (2026-06-10)
|
||||
|
||||
**Breaking Changes**
|
||||
|
||||
- **Notification Mode `both`**: `notificationType: "both"` now routes to overlay + system notifications. Users who previously used `"both"` for mpv OSD + system notifications should set `notificationType` to `"osd-system"` in `config.jsonc`. The `osd` and `osd-system` values remain valid as config-file entries but no longer appear in Settings.
|
||||
|
||||
**Added**
|
||||
|
||||
- **Overlay Notifications**: New overlay notification stack (Catppuccin Macchiato theme) with configurable screen position (`notifications.overlayPosition`: top-left, top-center, top-right), 3-second transient dismissals, and persistent cards for long-running jobs like character dictionary sync.
|
||||
- **Notification History Panel**: `Ctrl/Cmd+N` (configurable via `shortcuts.toggleNotificationHistory`) opens a session log of all notifications. Works whether the overlay or mpv has focus; slides in from the notification edge; entries can be individually removed or cleared.
|
||||
- **Mined-Card Notification Actions**: Mined-card overlay notifications show generated card thumbnails and include an Open in Anki button in both live cards and history entries.
|
||||
- **Update Notification Action**: Update-available overlay notifications include an Update button to start the app update flow directly from the notification.
|
||||
- **Stats Search**: New Search tab in Stats for realtime subtitle sentence search with media context, headword matching, and mining actions for source-backed sentence cards or exact-match word/audio cards.
|
||||
|
||||
**Changed**
|
||||
|
||||
- **AniSkip**: Moved intro detection from the mpv plugin to the SubMiner app. Lookups now cover every file loaded during a session including playlist advances, and `mpv.aniskipEnabled`/`mpv.aniskipButtonKey` hot-reload without restarting playback. The bundled plugin no longer makes network calls. Note: AniSkip now requires the SubMiner app to be connected; plugin-only mpv sessions will not fetch skip windows.
|
||||
- **Stats Library**: Entries are now split by detected season (season folder first, filename parsing as fallback). Existing combined-series rows are automatically migrated to per-season entries on startup. Cover art and anime details refresh immediately after a manual AniList entry change.
|
||||
- **Stats Vocabulary**: Remembers Hide Known/Hide Kana filters across sessions, applies Hide Kana filtering cross-title, collapses duplicate token variants in exclusions, and matches Related Seen Words by shared readings or kanji.
|
||||
- **Stats Trends**: Reorganized into Activity, Cumulative Totals, Efficiency, Patterns, and Library sections; disambiguated per-period vs. cumulative charts; added Words/Min and Cards/Hour efficiency charts.
|
||||
- **Stats Mining**: Sentence cards are created before slow media generation finishes; stored/requested secondary subtitles are preserved before falling back to sidecar or alass-retimed English subtitles; empty `ankiConnect.deck` falls back to Yomitan's mining deck; partial media failures are surfaced.
|
||||
- **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes.
|
||||
- **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads.
|
||||
- **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering.
|
||||
- **Update Notification Default**: New installs default `notificationType` to `overlay`, while `both` remains available for overlay + system notifications.
|
||||
|
||||
**Fixed**
|
||||
|
||||
- **AniList Completion**: Entries are now marked completed when a post-watch update reaches the final known episode of the season.
|
||||
- **AniSkip Markers**: Fixed intro markers disappearing after same-media mpv reloads; fixed metadata detection for intros that start at 0 seconds and common release-group filenames.
|
||||
- **Jellyfin Session**: Remote session now restarts after setup login so the websocket reconnects with fresh credentials, and stops cleanly on logout.
|
||||
- **Sentence Card Audio**: Mining a sentence card no longer fills the expression audio field; generated audio goes only to the configured sentence audio field.
|
||||
- **Stats Mining Fields**: Sentence clips update `SentenceAudio` correctly; word audio uses configured Yomitan sources; English subtitle text is not written to word cards; secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
|
||||
- **Overlay Hover Readiness**: Subtitle bars are hoverable and clickable from the first subtitle line on visible overlay startup or resume, without waiting for the next subtitle event.
|
||||
- **Startup Autoplay**: Playback is released after tokenization and overlay content are ready even when playback begins before the first subtitle line appears.
|
||||
- **Overlay Startup Feedback**: Restored mpv OSD loading spinner that starts on connect, media open, or overlay request, and clears once the overlay is content-ready and visible.
|
||||
- **Linux Overlay Input**: Notification close and action buttons remain clickable above subtitle bars on Linux.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout.
|
||||
- **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API.
|
||||
- **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn.
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
|
||||
<details>
|
||||
<summary>v0.15.x</summary>
|
||||
|
||||
<h2>v0.15.2 (2026-06-02)</h2>
|
||||
|
||||
**Changed**
|
||||
- Yomitan: Updated the bundled Yomitan build to the latest vendored revision.
|
||||
@@ -11,7 +64,7 @@
|
||||
- Overlay (macOS): Subtitle bars are now interactive immediately after autoplay starts with "wait for overlay to be ready" enabled, without requiring a manual click.
|
||||
- Overlay (macOS): Fixed overlay, subtitles, and subtitle sidebar staying hidden after a modal closes until the user clicked the mpv window; focus is now restored to mpv when the last modal closes, so playback shortcuts and the overlay reappear correctly - including in native fullscreen.
|
||||
|
||||
## v0.15.1 (2026-05-31)
|
||||
<h2>v0.15.1 (2026-05-31)</h2>
|
||||
|
||||
**Fixed**
|
||||
|
||||
@@ -27,7 +80,7 @@
|
||||
|
||||
- **Troubleshooting**: Updated Hyprland overlay docs with current Lua (`hl.window_rule`) and legacy config syntax; added troubleshooting for KDE/Wayland and other non-Hyprland/Sway Wayland sessions; added a Character Dictionary troubleshooting section; added a "See Also" index linking each feature's troubleshooting page.
|
||||
|
||||
## v0.15.0 (2026-05-29)
|
||||
<h2>v0.15.0 (2026-05-29)</h2>
|
||||
|
||||
**Breaking Changes**
|
||||
|
||||
@@ -179,7 +232,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v0.14.x</summary>
|
||||
|
||||
@@ -158,6 +158,8 @@ The three collapsible sections can be configured to start open or closed:
|
||||
|
||||
When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes.
|
||||
|
||||
These phases are emitted through the configured notification surface. Some phases are skipped when unnecessary: `generating` only appears on a cache miss, `building` only appears when the merged ZIP must be rebuilt, and `importing` only appears when Yomitan needs a new dictionary import.
|
||||
|
||||
**Phases:**
|
||||
|
||||
1. **checking** - Is there already a cached snapshot for this media ID?
|
||||
|
||||
+160
-124
@@ -52,7 +52,7 @@ The Settings window groups options by workflow instead of mirroring the raw conf
|
||||
- Tracking & App
|
||||
- Advanced
|
||||
|
||||
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and auto-fills an empty setting when one is found. Keybinding fields use click-to-learn controls instead of raw text boxes.
|
||||
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and persists it into an empty setting when one is found. Stats mining also uses Yomitan's current mining deck when `ankiConnect.deck` is empty. Keybinding fields use click-to-learn controls instead of raw text boxes.
|
||||
|
||||
The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies.
|
||||
|
||||
@@ -158,6 +158,7 @@ The configuration file includes several main sections:
|
||||
- [**MPV Launcher**](#mpv-launcher) - mpv executable path, profile, and window launch mode
|
||||
- [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading
|
||||
- [**Updates**](#updates) - Automatic update checks, notifications, and prerelease testing
|
||||
- [**Notifications**](#notifications) - Overlay notification placement
|
||||
|
||||
## Core Settings
|
||||
|
||||
@@ -196,18 +197,46 @@ Configure automatic update checks and update notifications:
|
||||
"updates": {
|
||||
"enabled": true,
|
||||
"checkIntervalHours": 24,
|
||||
"notificationType": "system",
|
||||
"notificationType": "overlay",
|
||||
"channel": "stable"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
|
||||
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
|
||||
| `notificationType` | `"system"` \| `"osd"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. |
|
||||
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
|
||||
| Option | Values | Description |
|
||||
| -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
|
||||
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
|
||||
| `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"overlay"`. `"both"` means overlay + system. |
|
||||
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
|
||||
|
||||
When `notificationType` is `"overlay"` or `"both"`, update-available overlay notifications include an **Update** button that starts the app update flow.
|
||||
|
||||
`osd` and `osd-system` are legacy config-file-only notification values. The Settings window offers `overlay`, `system`, `both`, and `none`; if your config already contains `osd` or `osd-system`, it is shown as the selected value but not offered as a normal choice. If you previously used `both` for mpv OSD + system notifications, set `notificationType` to `"osd-system"` in `config.jsonc` to keep that behavior.
|
||||
|
||||
### Notifications
|
||||
|
||||
Configure where overlay notification cards appear:
|
||||
|
||||
```json
|
||||
{
|
||||
"notifications": {
|
||||
"overlayPosition": "top-right"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ----------------- | ---------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `overlayPosition` | `"top-left"` \| `"top"` \| `"top-right"` | Position for in-overlay notification cards. Default `"top-right"`. |
|
||||
|
||||
#### Notification history panel
|
||||
|
||||
Every overlay notification shown during a session is also recorded in a notification history panel. Press `Ctrl/Cmd+N` (configurable via [`shortcuts.toggleNotificationHistory`](#shortcuts-configuration)) to toggle the panel; the binding works whether the overlay or mpv has focus. The panel slides in from the same edge the notifications use — left when `overlayPosition` is `"top-left"`, and right for `"top-right"` or `"top"` (centered). Character dictionary sync uses one live card but records each distinct phase in history. Each entry can be removed individually, or use **Clear** to empty the history. History is session-only and is not persisted across restarts.
|
||||
|
||||
Startup tokenization, subtitle annotation, and character dictionary status follow the configured notification surface. When the surface is `"overlay"` or `"both"`, SubMiner queues those startup notifications until the overlay renderer is ready instead of falling back to mpv OSD. If loading and ready states both finish before the overlay can paint, the loading card is delivered first and then updates to ready shortly after. With `"both"`, character dictionary checking/building/importing/ready status also goes to system notifications; building and importing are only emitted when that work is actually needed. The bundled mpv plugin only shows its startup OSD messages when `ankiConnect.behavior.notificationType` is set to `"osd"` or `"osd-system"` in `config.jsonc`; AniSkip prompts and skip result messages are playback feedback and still route to overlay notifications when configured.
|
||||
|
||||
The equivalent direct CLI command is `--playback-feedback <text>` (`playbackFeedback` internally). It sends that one non-empty feedback string through the same route controlled by `ankiConnect.behavior.notificationType`; it does not change the saved config.
|
||||
|
||||
### Auto-Start Overlay
|
||||
|
||||
@@ -223,7 +252,7 @@ Control whether the overlay automatically becomes visible when it connects to mp
|
||||
| -------------------- | --------------- | ----------------------------------------------------- |
|
||||
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `true`) |
|
||||
|
||||
When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime - there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
|
||||
When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime - there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness. On visible-overlay startup, SubMiner brings up the tray and visible overlay shell before tokenization and annotation warmups finish, then releases playback only after autoplay readiness.
|
||||
|
||||
On Windows, packaged plugin installs also rewrite the plugin socket path to `\\.\pipe\subminer-socket`.
|
||||
|
||||
@@ -360,7 +389,7 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
|
||||
| Option | Values | Description |
|
||||
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
||||
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
||||
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
|
||||
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||
@@ -516,11 +545,11 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------ |
|
||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) |
|
||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||
| Option | Values | Description |
|
||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match |
|
||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||
|
||||
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
|
||||
|
||||
@@ -542,26 +571,29 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
|
||||
**Default keybindings:**
|
||||
|
||||
| Key | Command | Description |
|
||||
| -------------------- | ----------------------------- | --------------------------------------- |
|
||||
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
||||
| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen |
|
||||
| `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 |
|
||||
| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen |
|
||||
| `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 |
|
||||
| `Ctrl+Shift+ArrowLeft` | `["sub-step", -1]` | Shift subtitle delay to previous cue |
|
||||
| `Ctrl+Shift+ArrowRight` | `["sub-step", 1]` | Shift subtitle delay to next cue |
|
||||
| `KeyZ` | `["add", "sub-delay", -0.1]` | Shift subtitles 100 ms earlier |
|
||||
| `Shift+KeyZ` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
|
||||
| `KeyX` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
|
||||
| `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:**
|
||||
|
||||
@@ -587,11 +619,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
{ "key": "Space", "command": null }
|
||||
```
|
||||
|
||||
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
||||
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
||||
|
||||
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
|
||||
|
||||
For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) and subtitle delay commands (`sub-delay`), SubMiner also shows an mpv OSD notification after the command runs.
|
||||
Subtitle delay commands (`sub-delay`, `sub-step`) show a native mpv OSD notification after the command runs. Subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) show playback feedback through the configured notification surface.
|
||||
|
||||
**See `config.example.jsonc`** for more keybinding examples and configuration options.
|
||||
|
||||
@@ -620,31 +652,33 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
"openControllerDebug": "Alt+Shift+C",
|
||||
"openJimaku": "Ctrl+Shift+J",
|
||||
"toggleSubtitleSidebar": "Backslash",
|
||||
"toggleNotificationHistory": "CommandOrControl+N",
|
||||
"multiCopyTimeoutMs": 3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
||||
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
||||
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
|
||||
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
||||
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
|
||||
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
|
||||
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
|
||||
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
|
||||
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
|
||||
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
||||
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
||||
| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) |
|
||||
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
||||
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) |
|
||||
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
|
||||
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
|
||||
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
||||
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
|
||||
| Option | Values | Description |
|
||||
| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
||||
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
||||
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
|
||||
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
||||
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
|
||||
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
|
||||
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
|
||||
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
|
||||
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
|
||||
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
||||
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
||||
| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) |
|
||||
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
||||
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) |
|
||||
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
|
||||
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
|
||||
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
||||
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
|
||||
| `toggleNotificationHistory` | string \| `null` | Toggles the overlay notification history panel (default: `"CommandOrControl+N"`). The panel slides in from the same edge as notifications (right when notifications are centered). |
|
||||
|
||||
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
||||
|
||||
@@ -943,57 +977,57 @@ 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 |
|
||||
| ------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ankiConnect.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 | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. In Settings, this dropdown auto-fills from Yomitan's current mining deck when available. |
|
||||
| `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) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
|
||||
| `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 using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (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.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"] }`). |
|
||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||
| `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 |
|
||||
| ------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ankiConnect.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 | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
||||
| `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) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
|
||||
| `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 using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (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.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"] }`). |
|
||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
||||
| `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.
|
||||
@@ -1122,6 +1156,8 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy
|
||||
| `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>`. |
|
||||
|
||||
Stats dashboard sentence mining also uses `alass_path` when available to align a local English sidecar against the local Japanese sidecar before filling the card translation field. This stats-only retime writes a temporary cached copy and never edits the original subtitle files.
|
||||
|
||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||
Customize it there, or set it to `null` to disable.
|
||||
|
||||
@@ -1380,9 +1416,9 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
|
||||
| `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). |
|
||||
| `retention.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). |
|
||||
| `lifetimeSummaries.global` | `true`, `false` | Maintain global lifetime stats rows (default: `true`). |
|
||||
| `lifetimeSummaries.anime` | `true`, `false` | Maintain per-anime lifetime stats rows (default: `true`). |
|
||||
| `lifetimeSummaries.media` | `true`, `false` | Maintain per-media lifetime stats rows (default: `true`). |
|
||||
| `lifetimeSummaries.global` | `true`, `false` | Maintain global lifetime stats rows (default: `true`). |
|
||||
| `lifetimeSummaries.anime` | `true`, `false` | Maintain per-anime lifetime stats rows (default: `true`). |
|
||||
| `lifetimeSummaries.media` | `true`, `false` | Maintain per-media lifetime stats rows (default: `true`). |
|
||||
|
||||
You can also disable immersion tracking for a single session using:
|
||||
|
||||
@@ -1433,7 +1469,7 @@ Usage notes:
|
||||
- The browser UI is served at `http://127.0.0.1:<serverPort>`.
|
||||
- The overlay toggle is local to the focused visible overlay window; it is not registered as a global OS shortcut.
|
||||
- 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, Search, and Sessions tabs.
|
||||
|
||||
### MPV Launcher
|
||||
|
||||
@@ -1456,18 +1492,18 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ----------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) |
|
||||
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
||||
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
||||
| `pauseUntilOverlayReady`| `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) |
|
||||
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
||||
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection and skip markers in the bundled mpv plugin (default: `true`) |
|
||||
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) |
|
||||
| Option | Values | Description |
|
||||
| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) |
|
||||
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
||||
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
||||
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) |
|
||||
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
||||
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
|
||||
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
|
||||
|
||||
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
|
||||
|
||||
|
||||
@@ -11,15 +11,10 @@ For internal architecture/workflow guidance, use `docs/README.md` at the repo ro
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
||||
cd SubMiner
|
||||
# if you cloned without --recurse-submodules:
|
||||
git submodule update --init --recursive
|
||||
|
||||
bun install
|
||||
(cd stats && bun install --frozen-lockfile)
|
||||
(cd vendor/texthooker-ui && bun install --frozen-lockfile)
|
||||
make deps
|
||||
```
|
||||
|
||||
`make deps` is still available as a convenience wrapper around the same dependency install flow.
|
||||
`make deps` initializes submodules and installs root, `stats/`, and `vendor/texthooker-ui` dependencies. The Yomitan submodule installs its own dependencies on demand during `bun run build`.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -216,7 +211,7 @@ Run `make help` for a full list of targets. Key ones:
|
||||
| `make build` | Build platform package for detected OS |
|
||||
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
||||
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
||||
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
|
||||
| `make deps` | Init submodules and install root/stats/texthooker-ui deps |
|
||||
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
|
||||
| `make generate-config` | Generate default config from centralized registry |
|
||||
| `make build-linux` | Convenience wrapper for Linux packaging |
|
||||
|
||||
@@ -18,8 +18,8 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
|
||||
{
|
||||
"immersionTracking": {
|
||||
"enabled": true,
|
||||
"dbPath": ""
|
||||
}
|
||||
"dbPath": "",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -48,13 +48,19 @@ Recent sessions, streak calendar, watch-time history, and a tracking snapshot wi
|
||||
|
||||
Cover-art library with search and sorting, per-series progress, episode drill-down, and direct links into mined cards.
|
||||
|
||||
Local files and Jellyfin items with detected season numbers are split into season-specific library entries, so `Season 1` and `Season 2` folders do not merge into one show card.
|
||||
|
||||
When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries.
|
||||
|
||||
Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard.
|
||||
|
||||
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
|
||||
|
||||

|
||||
|
||||
#### Trends
|
||||
|
||||
Watch time, sessions, words seen, and per-anime progress/pattern charts with configurable date ranges and grouping.
|
||||
Grouped into Activity (per-day/month watch time, cards, words, sessions), Cumulative Totals (running totals incl. new words seen and episodes), Efficiency (words/min, cards/hour, lookups per 100 words), Patterns (watch time by day of week and hour), and per-anime Library charts — all with configurable date ranges and grouping.
|
||||
|
||||

|
||||
|
||||
@@ -66,10 +72,14 @@ Expandable session history with new-word activity, cumulative totals, and pause/
|
||||
|
||||
#### Vocabulary
|
||||
|
||||
Top repeated words (click a bar to open the word), new-word timeline, frequency rank table with full readings, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons.
|
||||
Top repeated words (click a bar to open the word), new-word timeline, cross-title and frequency rank tables with Hide Known / Hide Kana filters, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons.
|
||||
|
||||

|
||||
|
||||
#### Search
|
||||
|
||||
Realtime search across tracked primary subtitle lines and media titles. Results show the source media, session, line number, timing, and sentence text. Secondary subtitle text is not shown or searched here because separate subtitle tracks may not line up sentence-for-sentence. Sentence cards can be mined from any result with a valid local source and timing. Word and audio card buttons appear only when the searched word exactly appears in the primary sentence text; matching text is highlighted in the result.
|
||||
|
||||
Stats server config lives under `stats`:
|
||||
|
||||
```jsonc
|
||||
@@ -78,8 +88,8 @@ Stats server config lives under `stats`:
|
||||
"toggleKey": "Backquote",
|
||||
"serverPort": 6969,
|
||||
"autoStartServer": true,
|
||||
"autoOpenBrowser": false
|
||||
}
|
||||
"autoOpenBrowser": false,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -96,15 +106,15 @@ Stats server config lives under `stats`:
|
||||
|
||||
## Mining Cards from the Stats Page
|
||||
|
||||
The Vocabulary tab's word detail panel shows example lines from your viewing history. Each example line with a valid source file offers three mining buttons:
|
||||
The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
|
||||
|
||||
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
|
||||
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio, image, and translation from the secondary subtitle if available.
|
||||
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video.
|
||||
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
|
||||
|
||||
All three modes respect your `ankiConnect` config: deck, model, field mappings, media settings (static vs AVIF, quality, dimensions), audio padding, metadata pattern, and tags. Media generation runs in parallel for faster card creation.
|
||||
|
||||
Secondary subtitle text (typically English translations) is stored alongside primary subtitles during playback and used as the translation field when mining from the stats page.
|
||||
Secondary subtitle text (typically English translations) is stored alongside primary subtitles during playback and can be used as the translation field when mining sentence cards from Search or vocabulary occurrences. The Search tab does not use that text for display or matching.
|
||||
|
||||
### Word Exclusion List
|
||||
|
||||
@@ -114,12 +124,12 @@ The Vocabulary tab toolbar includes an **Exclusions** button for hiding words fr
|
||||
|
||||
By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
|
||||
|
||||
| Data type | Retention |
|
||||
| -------------- | --------- |
|
||||
| Raw events | 0 (keep all) |
|
||||
| Telemetry | 0 (keep all) |
|
||||
| Sessions | 0 (keep all) |
|
||||
| Daily rollups | 0 (keep all) |
|
||||
| Data type | Retention |
|
||||
| --------------- | ------------ |
|
||||
| Raw events | 0 (keep all) |
|
||||
| Telemetry | 0 (keep all) |
|
||||
| Sessions | 0 (keep all) |
|
||||
| Daily rollups | 0 (keep all) |
|
||||
| Monthly rollups | 0 (keep all) |
|
||||
|
||||
Maintenance runs on startup and every 24 hours. Vacuum runs only when `retention.vacuumIntervalDays` is non-zero.
|
||||
@@ -146,24 +156,24 @@ The tracker is optimized for "keep everything" defaults:
|
||||
|
||||
All policy options live under `immersionTracking` in your config:
|
||||
|
||||
| Option | Description |
|
||||
| ------ | ----------- |
|
||||
| `batchSize` | Writes per flush batch |
|
||||
| `flushIntervalMs` | Max delay between flushes (default: 500ms) |
|
||||
| `queueCap` | Max queued writes before oldest are dropped |
|
||||
| `payloadCapBytes` | Max payload size per write |
|
||||
| `maintenanceIntervalMs` | How often maintenance runs |
|
||||
| `retention.eventsDays` | Raw event retention |
|
||||
| `retention.telemetryDays` | Telemetry retention |
|
||||
| `retention.sessionsDays` | Session retention |
|
||||
| `retention.dailyRollupsDays` | Daily rollup retention |
|
||||
| `retention.monthlyRollupsDays` | Monthly rollup retention |
|
||||
| `retention.vacuumIntervalDays` | Minimum spacing between vacuums |
|
||||
| `retentionMode` | `preset` or `advanced` |
|
||||
| `retentionPreset` | `minimal`, `balanced`, or `deep-history` (used by `retentionMode`) |
|
||||
| `lifetimeSummaries.global` | Maintain global lifetime totals |
|
||||
| `lifetimeSummaries.anime` | Maintain per-anime lifetime totals |
|
||||
| `lifetimeSummaries.media` | Maintain per-media lifetime totals |
|
||||
| Option | Description |
|
||||
| ------------------------------ | ------------------------------------------------------------------ |
|
||||
| `batchSize` | Writes per flush batch |
|
||||
| `flushIntervalMs` | Max delay between flushes (default: 500ms) |
|
||||
| `queueCap` | Max queued writes before oldest are dropped |
|
||||
| `payloadCapBytes` | Max payload size per write |
|
||||
| `maintenanceIntervalMs` | How often maintenance runs |
|
||||
| `retention.eventsDays` | Raw event retention |
|
||||
| `retention.telemetryDays` | Telemetry retention |
|
||||
| `retention.sessionsDays` | Session retention |
|
||||
| `retention.dailyRollupsDays` | Daily rollup retention |
|
||||
| `retention.monthlyRollupsDays` | Monthly rollup retention |
|
||||
| `retention.vacuumIntervalDays` | Minimum spacing between vacuums |
|
||||
| `retentionMode` | `preset` or `advanced` |
|
||||
| `retentionPreset` | `minimal`, `balanced`, or `deep-history` (used by `retentionMode`) |
|
||||
| `lifetimeSummaries.global` | Maintain global lifetime totals |
|
||||
| `lifetimeSummaries.anime` | Maintain per-anime lifetime totals |
|
||||
| `lifetimeSummaries.media` | Maintain per-media lifetime totals |
|
||||
|
||||
## Query Templates
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
||||
cd SubMiner
|
||||
bun install
|
||||
make deps
|
||||
bun run build
|
||||
|
||||
# Optional: build AppImage
|
||||
@@ -202,7 +202,7 @@ Bundled Yomitan is built during `bun run build`.
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
||||
cd SubMiner
|
||||
git submodule update --init --recursive
|
||||
make deps
|
||||
make build-macos
|
||||
```
|
||||
|
||||
@@ -216,14 +216,14 @@ The built app will be in the `release` directory (`.dmg` and `.zip`). For unsign
|
||||
```powershell
|
||||
git clone https://github.com/ksyasuda/SubMiner.git
|
||||
cd SubMiner
|
||||
git submodule update --init --recursive
|
||||
bun install
|
||||
|
||||
# Windows requires building texthooker-ui manually before the main build
|
||||
Set-Location vendor/texthooker-ui
|
||||
Set-Location stats
|
||||
bun install --frozen-lockfile
|
||||
Set-Location ../vendor/texthooker-ui
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
Set-Location ../..
|
||||
|
||||
bun run build:win
|
||||
```
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ This guide walks through the sentence mining loop - from watching a video to cre
|
||||
|
||||
## Overview
|
||||
|
||||
*Sentence mining* means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop.
|
||||
_Sentence mining_ means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop.
|
||||
|
||||
SubMiner runs as a transparent overlay on top of mpv (the video player). As subtitles play, the overlay displays them as interactive text. You hover a word, trigger a Yomitan dictionary lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, an audio clip, and a screenshot to that card - no manual copy-pasting or screen capturing.
|
||||
|
||||
@@ -122,10 +122,10 @@ By default the **primary** bar is `visible` (`subtitleStyle.primaryDefaultMode`)
|
||||
|
||||
Cycle each bar's mode at runtime with its own shortcut:
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| -------------------- | -------------------------------------------------------- | ------------------------------ |
|
||||
| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local |
|
||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------------ | -------------------------------------------------------- | ------------------------------ |
|
||||
| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local |
|
||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||
|
||||
### Modal Surfaces
|
||||
|
||||
@@ -166,6 +166,8 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
|
||||
|
||||
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
|
||||
|
||||
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
|
||||
|
||||
Install the sync tools separately - see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
|
||||
|
||||
## Texthooker
|
||||
|
||||
+18
-33
@@ -1,6 +1,6 @@
|
||||
# MPV Plugin
|
||||
|
||||
**What this is:** mpv is the video player SubMiner overlays subtitles on. The SubMiner mpv plugin is a small Lua script that runs *inside* mpv and gives you in-player keybindings to control the SubMiner overlay (start/stop/toggle, skip intro, etc.) without leaving the player window.
|
||||
**What this is:** mpv is the video player SubMiner overlays subtitles on. The SubMiner mpv plugin is a small Lua script that runs _inside_ mpv and gives you in-player keybindings to control the SubMiner overlay (start/stop/toggle, skip intro, etc.) without leaving the player window.
|
||||
|
||||
**Who needs this page:** Most users never touch the plugin directly - SubMiner-managed launches (the app, the `subminer` launcher, or the Windows shortcut) inject the bundled plugin automatically for that session, so there is nothing to install into mpv's global `scripts` directory. Read on if you launch mpv from another tool and want SubMiner's in-player controls, or you want to script mpv against SubMiner.
|
||||
|
||||
@@ -29,20 +29,20 @@ input-ipc-server=\\.\pipe\subminer-socket
|
||||
|
||||
Most plugin actions use a `y` chord prefix - press `y`, then the second key (a "chord"):
|
||||
|
||||
| Chord | Action |
|
||||
| ---------------- | -------------------------------------- |
|
||||
| `y-y` | Open menu |
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `y-o` | Open settings window |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check status |
|
||||
| `y-h` | Open session help / keybinding modal |
|
||||
| `v` | Toggle primary subtitle bar visibility |
|
||||
| `TAB` (default) | Skip intro (AniSkip) |
|
||||
| Chord | Action |
|
||||
| --------------- | -------------------------------------- |
|
||||
| `y-y` | Open menu |
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `y-o` | Open settings window |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check status |
|
||||
| `y-h` | Open session help / keybinding modal |
|
||||
| `v` | Toggle primary subtitle bar visibility |
|
||||
| `TAB` (default) | Skip intro (AniSkip) |
|
||||
|
||||
The AniSkip key is **not** a `y` chord. It defaults to `TAB` and is configurable via `mpv.aniskipButtonKey`. The legacy `y-k` chord still works as a fallback unless you remap the AniSkip key onto it.
|
||||
The AniSkip key is **not** a `y` chord and is not bound by the plugin: the SubMiner app binds it over the mpv IPC socket while it is connected. It defaults to `TAB` and is configurable via `mpv.aniskipButtonKey`. The legacy `y-k` chord still works as a fallback unless you remap the AniSkip key onto it. See [AniSkip Integration](/aniskip-integration) for setup and details.
|
||||
|
||||
The bare `v` binding is a forced mpv binding. It overrides mpv's default primary subtitle visibility toggle and routes the action to SubMiner's primary subtitle bar instead.
|
||||
|
||||
@@ -133,10 +133,10 @@ script-message subminer-options
|
||||
script-message subminer-restart
|
||||
script-message subminer-status
|
||||
script-message subminer-autoplay-ready
|
||||
script-message subminer-aniskip-refresh
|
||||
script-message subminer-skip-intro
|
||||
```
|
||||
|
||||
The AniSkip messages (`subminer-skip-intro`, `subminer-aniskip-refresh`) still exist, but they are handled by the SubMiner app over the IPC socket rather than by the plugin - see [AniSkip Integration](/aniskip-integration#triggering-from-mpv).
|
||||
|
||||
The `subminer-start` message accepts overrides:
|
||||
|
||||
```
|
||||
@@ -146,27 +146,12 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
||||
`log-level` here controls only logging verbosity passed to SubMiner.
|
||||
`--debug` is a separate app/dev-mode flag in the main CLI and should not be used here for logging.
|
||||
|
||||
## AniSkip Intro Skip
|
||||
|
||||
- AniSkip lookups are gated. The plugin only runs lookup when:
|
||||
- SubMiner launcher metadata is present, or
|
||||
- SubMiner app process is already running, or
|
||||
- You explicitly call `script-message subminer-aniskip-refresh`.
|
||||
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
|
||||
- MAL/title resolution is cached for the current mpv session.
|
||||
- When launched via `subminer`, launcher can pass `aniskip_payload` (pre-fetched AniSkip `skip-times` payload) and the plugin applies it directly without making API calls.
|
||||
- If the payload is absent or invalid, lookup falls back to title/MAL-based async fetch.
|
||||
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
||||
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
||||
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing TAB` by default; the key reflects `mpv.aniskipButtonKey`).
|
||||
- Use `script-message subminer-aniskip-refresh` after changing media metadata/options to retry lookup.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
For how the plugin's auto-start fits into the full launch sequence - including when the launcher starts the overlay instead of the plugin - see [Playback Startup Flow](./architecture#playback-startup-flow).
|
||||
|
||||
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
|
||||
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
|
||||
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay.
|
||||
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused. On cold managed background startup, SubMiner opens the tray and visible overlay shell before tokenization warmups finish, then the plugin resumes playback after SubMiner reports tokenization-ready (with a 30-second timeout fallback).
|
||||
- **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts).
|
||||
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
|
||||
- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.
|
||||
|
||||
@@ -172,10 +172,19 @@
|
||||
"updates": {
|
||||
"enabled": true, // Run automatic update checks in the background. Values: true | false
|
||||
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
|
||||
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
|
||||
"notificationType": "overlay", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
|
||||
}, // Automatic update check behavior.
|
||||
|
||||
// ==========================================
|
||||
// Notifications
|
||||
// Overlay notification display behavior.
|
||||
// Hot-reload: position changes apply to the next overlay notification.
|
||||
// ==========================================
|
||||
"notifications": {
|
||||
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
||||
}, // Overlay notification display behavior.
|
||||
|
||||
// ==========================================
|
||||
// Keyboard Shortcuts
|
||||
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
@@ -199,7 +208,8 @@
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
|
||||
"toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility.
|
||||
"toggleNotificationHistory": "CommandOrControl+N" // Accelerator that toggles the overlay notification history panel.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
@@ -280,15 +290,41 @@
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+BracketRight", // Key setting.
|
||||
"key": "Ctrl+Shift+ArrowLeft", // Key setting.
|
||||
"command": [
|
||||
"__sub-delay-next-line"
|
||||
"sub-step",
|
||||
-1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+BracketLeft", // Key setting.
|
||||
"key": "Ctrl+Shift+ArrowRight", // Key setting.
|
||||
"command": [
|
||||
"__sub-delay-prev-line"
|
||||
"sub-step",
|
||||
1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "KeyZ", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
-0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+KeyZ", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "KeyX", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
@@ -496,7 +532,7 @@
|
||||
"tags": [
|
||||
"SubMiner"
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.
|
||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
@@ -539,7 +575,7 @@
|
||||
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
|
||||
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
|
||||
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
|
||||
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
|
||||
"notificationType": "overlay", // Notification surface used to announce mining and update outcomes. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||
}, // Behavior setting.
|
||||
"nPlusOne": {
|
||||
@@ -634,8 +670,8 @@
|
||||
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
||||
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
||||
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
|
||||
"aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false
|
||||
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible.
|
||||
"aniskipEnabled": true, // Enable AniSkip intro detection, chapter markers, and the skip-intro key. Values: true | false
|
||||
"aniskipButtonKey": "TAB" // mpv key used to skip the detected intro while the skip prompt is visible.
|
||||
}, // SubMiner-managed mpv launch and bundled plugin options.
|
||||
|
||||
// ==========================================
|
||||
|
||||
+49
-45
@@ -43,31 +43,34 @@ The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcu
|
||||
|
||||
These control playback and subtitle display. They require overlay window focus.
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------------------- | --------------------------------------------------- |
|
||||
| `Space` | Toggle mpv pause |
|
||||
| `F` | Toggle fullscreen |
|
||||
| Shortcut | Action |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| `Space` | Toggle mpv pause |
|
||||
| `F` | Toggle fullscreen |
|
||||
| `V` | Cycle primary subtitle bar mode (hidden → visible → hover) |
|
||||
| `J` | Cycle primary subtitle track |
|
||||
| `Shift+J` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
||||
| `ArrowRight` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | Seek backward 5 seconds |
|
||||
| `ArrowUp` | Seek forward 60 seconds |
|
||||
| `ArrowDown` | Seek backward 60 seconds |
|
||||
| `Shift+H` | Jump to previous subtitle |
|
||||
| `Shift+L` | Jump to next subtitle |
|
||||
| `Shift+[` | Shift subtitle delay to previous subtitle cue |
|
||||
| `Shift+]` | Shift subtitle delay to next subtitle cue |
|
||||
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
|
||||
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
|
||||
| `Q` | Quit mpv |
|
||||
| `Ctrl+W` | Quit mpv |
|
||||
| `Right-click` | Toggle pause (outside subtitle area) |
|
||||
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
|
||||
| `J` | Cycle primary subtitle track |
|
||||
| `Shift+J` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
||||
| `ArrowRight` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | Seek backward 5 seconds |
|
||||
| `ArrowUp` | Seek forward 60 seconds |
|
||||
| `ArrowDown` | Seek backward 60 seconds |
|
||||
| `Shift+H` | Jump to previous subtitle |
|
||||
| `Shift+L` | Jump to next subtitle |
|
||||
| `Ctrl+Shift+Left` | Shift subtitle delay to previous subtitle cue |
|
||||
| `Ctrl+Shift+Right` | Shift subtitle delay to next subtitle cue |
|
||||
| `z` | Shift subtitles 100 ms earlier |
|
||||
| `Shift+Z` | Delay subtitles by 100 ms |
|
||||
| `x` | Delay subtitles by 100 ms |
|
||||
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
|
||||
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
|
||||
| `Q` | Quit mpv |
|
||||
| `Ctrl+W` | Quit mpv |
|
||||
| `Right-click` | Toggle pause (outside subtitle area) |
|
||||
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
|
||||
|
||||
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V`, `Ctrl/Cmd+A`, and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
||||
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V`, `Ctrl/Cmd+A`, and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
||||
|
||||
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
|
||||
|
||||
@@ -75,18 +78,19 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
|
||||
## Subtitle & Feature Shortcuts
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------------ | -------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||
| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
|
||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
|
||||
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
|
||||
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------------ | -------------------------------------------------------- | ------------------------------------------ |
|
||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||
| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
|
||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
|
||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
|
||||
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
|
||||
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
|
||||
|
||||
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
|
||||
|
||||
@@ -107,17 +111,17 @@ Controller input only drives the overlay while keyboard-only mode is enabled. Th
|
||||
|
||||
When the mpv plugin is installed, all commands use a `y` chord prefix - press `y`, then the second key within 1 second.
|
||||
|
||||
| Chord | Action |
|
||||
| ----- | -------------------------------------- |
|
||||
| `y-y` | Open SubMiner menu (OSD) |
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| Chord | Action |
|
||||
| ----- | ---------------------------------------------------------- |
|
||||
| `y-y` | Open SubMiner menu (OSD) |
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `v` | Cycle primary subtitle bar mode (hidden → visible → hover) |
|
||||
| `y-o` | Open Yomitan settings |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check overlay status |
|
||||
| `y-h` | Open session help |
|
||||
| `y-o` | Open Yomitan settings |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check overlay status |
|
||||
| `y-h` | Open session help |
|
||||
|
||||
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so it cycles the SubMiner primary subtitle bar (hidden → visible → hover) instead.
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ The detected launcher is installed in a protected path such as `/usr/local/bin/s
|
||||
|
||||
**OSD update notification did not appear**
|
||||
|
||||
`updates.notificationType: "osd"` uses the existing mpv/overlay notification path. If mpv is disconnected, SubMiner logs the update and does not force-start the overlay. Use `"system"` or `"both"` if you want OS notifications outside playback.
|
||||
`updates.notificationType: "osd"` uses the legacy mpv OSD path. If mpv is disconnected, SubMiner logs the update and does not force-start the overlay. Use `"system"` for OS notifications, `"both"` for overlay + OS notifications, or `"osd-system"` in `config.jsonc` if you want the legacy OSD + OS combination.
|
||||
|
||||
## AnkiConnect
|
||||
|
||||
|
||||
+3
-1
@@ -132,6 +132,7 @@ SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle
|
||||
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
||||
SubMiner.AppImage --start --debug # Alias for --dev
|
||||
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
||||
SubMiner.AppImage --playback-feedback "your feedback" # Route playback feedback through the configured feedback surface
|
||||
SubMiner.AppImage --yomitan # Open Yomitan settings
|
||||
SubMiner.AppImage --settings # Open SubMiner settings window
|
||||
SubMiner.AppImage --jellyfin # Open Jellyfin setup window
|
||||
@@ -163,6 +164,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
|
||||
Launcher pass-through commands also support `--password-store=<backend>` and forward it to the app when present.
|
||||
Override with e.g. `--password-store=basic_text`.
|
||||
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug` (or `SubMiner.exe --start --dev --log-level debug` on Windows).
|
||||
- `--playback-feedback <text>` (also `--playback-feedback=<text>`) sends a non-empty text string through the playback-feedback route used for recording/playback prompts. For example: `SubMiner.AppImage --playback-feedback "your feedback"`.
|
||||
|
||||
### Windows mpv Shortcut
|
||||
|
||||
@@ -287,7 +289,7 @@ Notes:
|
||||
- For YouTube URLs, `subminer` probes available YouTube subtitle tracks, reuses existing authoritative tracks when available, and downloads only missing sides.
|
||||
- Native mpv secondary subtitle rendering stays hidden so the overlay remains the visible secondary subtitle surface.
|
||||
- Primary subtitle target languages come from `youtube.primarySubLanguages` (defaults to `["ja","jpn"]`).
|
||||
- Secondary target languages come from `secondarySub.secondarySubLanguages` (empty by default; when empty, no language-based secondary track is auto-selected, though mpv's `--slang` list above still prefers English variants).
|
||||
- Secondary target languages come from `secondarySub.secondarySubLanguages` (empty by default; when empty, no language-based secondary track is auto-selected, though mpv's `--slang` list above still prefers English variants). When multiple matching secondary tracks exist, SubMiner prefers a non-Signs/Songs track.
|
||||
- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtube` and `secondarySub`.
|
||||
|
||||
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
|
||||
|
||||
@@ -265,10 +265,10 @@ script-message subminer-options
|
||||
script-message subminer-restart
|
||||
script-message subminer-status
|
||||
script-message subminer-autoplay-ready
|
||||
script-message subminer-aniskip-refresh
|
||||
script-message subminer-skip-intro
|
||||
```
|
||||
|
||||
The AniSkip messages (`subminer-skip-intro`, `subminer-aniskip-refresh`) are handled by the SubMiner app over the mpv IPC socket while it is connected.
|
||||
|
||||
The start command also accepts inline overrides:
|
||||
|
||||
```text
|
||||
@@ -283,7 +283,7 @@ Examples:
|
||||
|
||||
- send `subminer-start` after your own media-selection script chooses a file
|
||||
- send `subminer-status` before running follow-up automation
|
||||
- send `subminer-aniskip-refresh` after you update title/episode metadata
|
||||
- send `subminer-aniskip-refresh` after you update title/episode metadata (handled by the SubMiner app)
|
||||
|
||||
#### Build a launcher wrapper
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ Notes:
|
||||
- `changelog:check` now rejects tag/package version mismatches.
|
||||
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
||||
- `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
||||
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
|
||||
- Do not tag while `changes/*.md` fragments still exist.
|
||||
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
|
||||
|
||||
@@ -61,7 +61,7 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
|
||||
|
||||
#### Subtitle File Parsing
|
||||
|
||||
A new cue parser that extracts both timing and text content from subtitle files. The existing `parseSrtOrVttStartTimes` in `subtitle-delay-shift.ts` only extracts timing; this needs a companion that also extracts the dialogue text.
|
||||
A cue parser extracts both timing and text content from subtitle files for prefetching.
|
||||
|
||||
**Parsed cue structure:**
|
||||
```typescript
|
||||
|
||||
@@ -64,6 +64,25 @@ prefetch work and re-centers prefetch around the live playback time.
|
||||
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
||||
written.
|
||||
|
||||
## Startup Ready Release
|
||||
|
||||
- `mpv.pauseUntilOverlayReady` waits for tokenization warmup plus visible-overlay readiness before
|
||||
releasing the mpv startup gate.
|
||||
- Visible-overlay startup creates the tray and visible overlay shell before tokenization and
|
||||
annotation warmups continue. Cold `--start --background --managed-playback` launches still handle
|
||||
initial args before the deferred Yomitan wait.
|
||||
- Overlay-routed startup notifications are queued in the main process until an overlay window has
|
||||
finished loading. Progress notifications with the same id are upserted so spinner ticks do not
|
||||
flood a cold-start overlay, while events with distinct history ids are retained for phase-level
|
||||
history such as character dictionary checking/building/importing.
|
||||
- The mpv plugin has a 30-second fallback for cold starts; app-side retry/release budgets match that
|
||||
window so readiness can still arrive before fallback resumes playback.
|
||||
- If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and
|
||||
waits for a fresh measured subtitle rectangle before signaling readiness.
|
||||
- If mpv is before the first subtitle, SubMiner sends a synthetic warm readiness payload after
|
||||
tokenization warmup and visible overlay content-ready. This releases playback without waiting for
|
||||
a later subtitle event that cannot happen while mpv is paused.
|
||||
|
||||
## Linux/X11 Window Shape
|
||||
|
||||
- `restoreLinuxOverlayWindowShape()` reads `BrowserWindow.getBounds()` and calls `setShape()` with
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<!-- read_when: changing managed mpv startup, pause-until-ready, or visible overlay boot ordering -->
|
||||
|
||||
# Early Managed Overlay Startup Design
|
||||
|
||||
Status: approved
|
||||
Date: 2026-06-06
|
||||
|
||||
## Problem
|
||||
|
||||
Managed mpv startup can pause playback immediately, then leave SubMiner's tray and visible overlay
|
||||
unavailable until Yomitan/tokenization warmups finish. Startup notifications therefore miss the
|
||||
overlay surface and fall back to non-overlay status paths.
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
For cold `--start --background --managed-playback` launches, handle initial args before waiting for
|
||||
the deferred overlay warmup. That lets the tray and visible overlay shell initialize immediately
|
||||
while the existing tokenization warmups continue in the background.
|
||||
|
||||
The mpv plugin pause gate stays armed. Playback release still waits for SubMiner's autoplay-ready
|
||||
signal, which is emitted only after tokenization warmup and visible-overlay readiness. Existing
|
||||
second-instance attach behavior remains unchanged: when the launcher finds an already-running
|
||||
background app, it sends the same control command to that process and reuses its warmups/tokenizer.
|
||||
|
||||
## Checks
|
||||
|
||||
- Add a startup ordering regression test for managed background playback.
|
||||
- Keep the existing deferred startup ordering for non-managed launches.
|
||||
- Run the startup/runtime test slice plus SubMiner verification lane.
|
||||
@@ -0,0 +1,27 @@
|
||||
<!-- read_when: changing overlay notification hover, macOS mouse passthrough, or notification actions -->
|
||||
|
||||
# macOS Notification Hover Stability Design
|
||||
|
||||
Status: approved
|
||||
Date: 2026-06-09
|
||||
|
||||
## Problem
|
||||
|
||||
On macOS, hovering a character dictionary build notification can make the card flicker and slide as
|
||||
if it is hiding, then snap back. The likely trigger is the notification stack changing the overlay
|
||||
window's mouse-passthrough state for a progress card that has no user action.
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
Keep non-action overlay notifications visually stable and click-through on hover. Only notifications
|
||||
with explicit actions should request interactive overlay input. The notification history panel keeps
|
||||
its existing interactive behavior.
|
||||
|
||||
This avoids a macOS mouseenter/mouseleave passthrough loop for passive progress cards while
|
||||
preserving clickable notification actions.
|
||||
|
||||
## Checks
|
||||
|
||||
- Add a renderer regression test for passive notification hover.
|
||||
- Keep action-bearing notification cards interactive.
|
||||
- Run the targeted overlay notification and mouse-ignore tests.
|
||||
@@ -45,9 +45,8 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
appPath: '/tmp/subminer.app',
|
||||
launcherJellyfinConfig: {},
|
||||
|
||||
@@ -82,9 +82,8 @@ function createContext(): LauncherCommandContext {
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
appPath: '/tmp/SubMiner.AppImage',
|
||||
launcherJellyfinConfig: {},
|
||||
@@ -209,9 +208,8 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
const appPath = context.appPath ?? '';
|
||||
state.appPath = appPath;
|
||||
@@ -272,9 +270,8 @@ test('plugin auto-start playback attaches a warm background app through the laun
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
@@ -341,12 +338,12 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
let availabilityConfigDir: string | undefined;
|
||||
let overlayConfigDir: string | undefined;
|
||||
let overlayLoadingOsd: boolean | undefined;
|
||||
|
||||
try {
|
||||
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||
@@ -357,7 +354,19 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {},
|
||||
startMpv: async (
|
||||
_target,
|
||||
_targetKind,
|
||||
_args,
|
||||
_socketPath,
|
||||
_appPath,
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
overlayLoadingOsd = (
|
||||
options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined
|
||||
)?.overlayLoadingOsd;
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
|
||||
overlayConfigDir = configDir;
|
||||
@@ -374,6 +383,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
|
||||
|
||||
assert.equal(availabilityConfigDir, expectedConfigDir);
|
||||
assert.equal(overlayConfigDir, expectedConfigDir);
|
||||
assert.equal(overlayLoadingOsd, true);
|
||||
} finally {
|
||||
if (originalXdgConfigHome === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
@@ -403,9 +413,8 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -232,6 +232,14 @@ export async function runPlaybackCommandWithDeps(
|
||||
? { ...pluginRuntimeConfig, autoStart: false }
|
||||
: pluginRuntimeConfig;
|
||||
|
||||
const shouldShowOverlayLoadingOsd =
|
||||
!isAppOwnedYoutubeFlow &&
|
||||
(pluginRuntimeConfig.autoStartVisibleOverlay || args.startOverlay || args.autoStartOverlay) &&
|
||||
(pluginRuntimeConfig.autoStart ||
|
||||
args.startOverlay ||
|
||||
args.autoStartOverlay ||
|
||||
shouldLauncherAttachRunningApp);
|
||||
|
||||
const shouldPauseUntilOverlayReady =
|
||||
pluginRuntimeConfig.autoStart &&
|
||||
pluginRuntimeConfig.autoStartVisibleOverlay &&
|
||||
@@ -266,6 +274,7 @@ export async function runPlaybackCommandWithDeps(
|
||||
}
|
||||
: {}),
|
||||
backend: args.backend,
|
||||
overlayLoadingOsd: shouldShowOverlayLoadingOsd,
|
||||
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -91,8 +91,6 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
|
||||
autoStartSubMiner: false,
|
||||
pauseUntilOverlayReady: false,
|
||||
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -102,8 +100,6 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
|
||||
assert.equal(parsed.autoStartSubMiner, false);
|
||||
assert.equal(parsed.pauseUntilOverlayReady, false);
|
||||
assert.equal(parsed.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.equal(parsed.aniskipEnabled, false);
|
||||
assert.equal(parsed.aniskipButtonKey, 'F8');
|
||||
});
|
||||
|
||||
test('parseLauncherMpvConfig ignores blank subminer binary paths', () => {
|
||||
@@ -129,6 +125,11 @@ test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
|
||||
test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugin defaults', () => {
|
||||
const parsed = parsePluginRuntimeConfigFromMainConfig({
|
||||
auto_start_overlay: false,
|
||||
ankiConnect: {
|
||||
behavior: {
|
||||
notificationType: 'osd-system',
|
||||
},
|
||||
},
|
||||
texthooker: {
|
||||
launchAtStartup: false,
|
||||
},
|
||||
@@ -138,8 +139,6 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -148,10 +147,21 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi
|
||||
assert.equal(parsed.autoStart, true);
|
||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||
assert.equal(parsed.osdMessages, true);
|
||||
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.equal(parsed.texthookerEnabled, false);
|
||||
assert.equal(parsed.aniskipEnabled, false);
|
||||
assert.equal(parsed.aniskipButtonKey, 'F8');
|
||||
});
|
||||
|
||||
test('parsePluginRuntimeConfigFromMainConfig disables plugin osd messages for overlay notification routing', () => {
|
||||
const parsed = parsePluginRuntimeConfigFromMainConfig({
|
||||
ankiConnect: {
|
||||
behavior: {
|
||||
notificationType: 'both',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(parsed.osdMessages, false);
|
||||
});
|
||||
|
||||
test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed startup', () => {
|
||||
@@ -160,9 +170,8 @@ test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed
|
||||
assert.equal(parsed.autoStart, true);
|
||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||
assert.equal(parsed.osdMessages, false);
|
||||
assert.equal(parsed.texthookerEnabled, false);
|
||||
assert.equal(parsed.aniskipEnabled, true);
|
||||
assert.equal(parsed.aniskipButtonKey, 'TAB');
|
||||
});
|
||||
|
||||
test('buildPluginRuntimeScriptOptParts emits config values that override plugin defaults', () => {
|
||||
@@ -175,9 +184,8 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
'/fallback/SubMiner.AppImage',
|
||||
),
|
||||
@@ -187,10 +195,11 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
|
||||
'subminer-backend=x11',
|
||||
'subminer-auto_start=yes',
|
||||
'subminer-auto_start_visible_overlay=no',
|
||||
'subminer-overlay_loading_osd=no',
|
||||
'subminer-auto_start_pause_until_ready=yes',
|
||||
'subminer-auto_start_pause_until_ready_timeout_seconds=30',
|
||||
'subminer-osd_messages=yes',
|
||||
'subminer-texthooker_enabled=no',
|
||||
'subminer-aniskip_enabled=no',
|
||||
'subminer-aniskip_button_key=F8',
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -205,9 +214,8 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8,\nF9',
|
||||
},
|
||||
'/fallback/SubMiner.AppImage',
|
||||
),
|
||||
@@ -217,10 +225,11 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
|
||||
'subminer-backend=x11',
|
||||
'subminer-auto_start=yes',
|
||||
'subminer-auto_start_visible_overlay=no',
|
||||
'subminer-overlay_loading_osd=no',
|
||||
'subminer-auto_start_pause_until_ready=yes',
|
||||
'subminer-auto_start_pause_until_ready_timeout_seconds=30',
|
||||
'subminer-osd_messages=no',
|
||||
'subminer-texthooker_enabled=no',
|
||||
'subminer-aniskip_enabled=no',
|
||||
'subminer-aniskip_button_key=F8 F9',
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -244,8 +253,6 @@ test('parseLauncherMpvConfig reads configured mpv profile', () => {
|
||||
pauseUntilOverlayReady: undefined,
|
||||
subminerBinaryPath: undefined,
|
||||
profile: 'anime',
|
||||
aniskipEnabled: undefined,
|
||||
aniskipButtonKey: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -39,7 +39,5 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
|
||||
pauseUntilOverlayReady:
|
||||
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
|
||||
subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath),
|
||||
aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined,
|
||||
aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,10 +16,9 @@ function booleanOrDefault(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function nonEmptyStringOrDefault(value: unknown, fallback: string): string {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : fallback;
|
||||
function pluginOsdMessagesFromNotificationType(root: Record<string, unknown> | null): boolean {
|
||||
const notificationType = rootObject(rootObject(root, 'ankiConnect'), 'behavior').notificationType;
|
||||
return notificationType === 'osd' || notificationType === 'osd-system';
|
||||
}
|
||||
|
||||
function validBackendOrDefault(value: unknown, fallback: Backend): Backend {
|
||||
@@ -53,9 +52,8 @@ export function parsePluginRuntimeConfigFromMainConfig(
|
||||
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
|
||||
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
|
||||
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
|
||||
osdMessages: pluginOsdMessagesFromNotificationType(root),
|
||||
texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false),
|
||||
aniskipEnabled: booleanOrDefault(mpvConfig.aniskipEnabled, true),
|
||||
aniskipButtonKey: nonEmptyStringOrDefault(mpvConfig.aniskipButtonKey, 'TAB'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,7 +70,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}, aniskip_enabled=${parsed.aniskipEnabled}, aniskip_button_key=${parsed.aniskipButtonKey}`,
|
||||
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, osd_messages=${parsed.osdMessages}, texthooker_enabled=${parsed.texthookerEnabled}`,
|
||||
);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
+1
-35
@@ -23,8 +23,6 @@ import {
|
||||
runAppCommandCaptureOutput,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
resolveLauncherRuntimePluginPlan,
|
||||
shouldResolveAniSkipMetadataForLaunch,
|
||||
shouldResolveAniSkipMetadata,
|
||||
stopOverlay,
|
||||
startOverlay,
|
||||
state,
|
||||
@@ -387,32 +385,14 @@ test('buildRuntimeExtraScriptOptParts marks launcher-owned startup pause gate',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
}),
|
||||
['subminer-auto_start_pause_until_ready_owns_initial_pause=yes'],
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldResolveAniSkipMetadataForLaunch respects disabled runtime plugin AniSkip', () => {
|
||||
assert.equal(
|
||||
shouldResolveAniSkipMetadataForLaunch('/tmp/video.mkv', 'file', undefined, {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'TAB',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
|
||||
const error = withProcessExitIntercept(() => {
|
||||
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
||||
@@ -565,20 +545,6 @@ test('waitForUnixSocketReady returns true when socket becomes connectable before
|
||||
}
|
||||
});
|
||||
|
||||
test('shouldResolveAniSkipMetadata skips URL and YouTube-preloaded playback', () => {
|
||||
assert.equal(shouldResolveAniSkipMetadata('/media/show.mkv', 'file'), true);
|
||||
assert.equal(
|
||||
shouldResolveAniSkipMetadata('https://www.youtube.com/watch?v=test123', 'url'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldResolveAniSkipMetadata('/tmp/video123.webm', 'file', {
|
||||
primaryPath: '/tmp/video123.ja.srt',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
return {
|
||||
backend: 'x11',
|
||||
|
||||
+3
-50
@@ -27,7 +27,7 @@ import {
|
||||
shouldForwardLogLevel,
|
||||
} from './types.js';
|
||||
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
|
||||
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||
import { buildSubminerScriptOpts } from './script-opts.js';
|
||||
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
|
||||
import { nowMs } from './time.js';
|
||||
import {
|
||||
@@ -823,20 +823,6 @@ export async function loadSubtitleIntoMpv(
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldResolveAniSkipMetadata(
|
||||
target: string,
|
||||
targetKind: 'file' | 'url',
|
||||
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
||||
): boolean {
|
||||
if (targetKind !== 'file') {
|
||||
return false;
|
||||
}
|
||||
if (preloadedSubtitles?.primaryPath || preloadedSubtitles?.secondaryPath) {
|
||||
return false;
|
||||
}
|
||||
return !isYoutubeTarget(target);
|
||||
}
|
||||
|
||||
type StartMpvOptions = {
|
||||
startPaused?: boolean;
|
||||
disableYoutubeSubtitleAutoLoad?: boolean;
|
||||
@@ -844,18 +830,6 @@ type StartMpvOptions = {
|
||||
runtimePluginConfig?: PluginRuntimeConfig;
|
||||
};
|
||||
|
||||
export function shouldResolveAniSkipMetadataForLaunch(
|
||||
target: string,
|
||||
targetKind: 'file' | 'url',
|
||||
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
||||
runtimePluginConfig?: PluginRuntimeConfig,
|
||||
): boolean {
|
||||
if (runtimePluginConfig?.aniskipEnabled === false) {
|
||||
return false;
|
||||
}
|
||||
return shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles);
|
||||
}
|
||||
|
||||
export function buildRuntimeExtraScriptOptParts(
|
||||
target: string,
|
||||
targetKind: 'file' | 'url',
|
||||
@@ -946,29 +920,14 @@ export async function startMpv(
|
||||
if (options?.startPaused) {
|
||||
mpvArgs.push('--pause=yes');
|
||||
}
|
||||
const aniSkipMetadata = shouldResolveAniSkipMetadataForLaunch(
|
||||
target,
|
||||
targetKind,
|
||||
preloadedSubtitles,
|
||||
options?.runtimePluginConfig,
|
||||
)
|
||||
? await resolveAniSkipMetadataForFile(target)
|
||||
: null;
|
||||
const extraScriptOpts = buildRuntimeExtraScriptOptParts(target, targetKind, options);
|
||||
const runtimeScriptOpts = options?.runtimePluginConfig
|
||||
? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath)
|
||||
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
|
||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel, [
|
||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, [
|
||||
...runtimeScriptOpts,
|
||||
...extraScriptOpts,
|
||||
]);
|
||||
if (aniSkipMetadata) {
|
||||
log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
`AniSkip metadata (${aniSkipMetadata.source}): title="${aniSkipMetadata.title}" season=${aniSkipMetadata.season ?? '-'} episode=${aniSkipMetadata.episode ?? '-'}`,
|
||||
);
|
||||
}
|
||||
mpvArgs.push(`--script-opts=${scriptOpts}`);
|
||||
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
|
||||
|
||||
@@ -1701,13 +1660,7 @@ export function launchMpvIdleDetached(
|
||||
? buildPluginRuntimeScriptOptParts(runtimePluginConfig, appPath)
|
||||
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
|
||||
mpvArgs.push(
|
||||
`--script-opts=${buildSubminerScriptOpts(
|
||||
appPath,
|
||||
socketPath,
|
||||
null,
|
||||
args.logLevel,
|
||||
runtimeScriptOpts,
|
||||
)}`,
|
||||
`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, runtimeScriptOpts)}`,
|
||||
);
|
||||
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
|
||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildSubminerScriptOpts } from './script-opts';
|
||||
|
||||
test('buildSubminerScriptOpts preserves app and socket paths verbatim', () => {
|
||||
const scriptOpts = buildSubminerScriptOpts(
|
||||
'/Applications/SubMiner Beta.app/Contents/MacOS/SubMiner',
|
||||
'/tmp/subminer socket.sock',
|
||||
['subminer-backend=x11'],
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
scriptOpts,
|
||||
'subminer-binary_path=/Applications/SubMiner Beta.app/Contents/MacOS/SubMiner,subminer-socket_path=/tmp/subminer socket.sock,subminer-backend=x11',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildSubminerScriptOpts rejects delimiter-bearing default paths', () => {
|
||||
assert.throws(
|
||||
() => buildSubminerScriptOpts('/tmp/SubMiner,canary', '/tmp/subminer.sock'),
|
||||
/subminer-binary_path contains unsupported script option delimiter/,
|
||||
);
|
||||
assert.throws(
|
||||
() => buildSubminerScriptOpts('/tmp/SubMiner', '/tmp/subminer\nsocket.sock'),
|
||||
/subminer-socket_path contains unsupported script option delimiter/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
function sanitizeScriptOptValue(value: string): string {
|
||||
return value
|
||||
.replace(/,/g, ' ')
|
||||
.replace(/[\r\n]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function assertScriptOptPathValue(name: string, value: string): void {
|
||||
if (/[,\r\n]/.test(value)) {
|
||||
throw new Error(`${name} contains unsupported script option delimiter`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSubminerScriptOpts(
|
||||
appPath: string,
|
||||
socketPath: string,
|
||||
extraParts: string[] = [],
|
||||
): string {
|
||||
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
|
||||
const hasSocketPath = extraParts.some((part) => part.startsWith('subminer-socket_path='));
|
||||
if (!hasBinaryPath) {
|
||||
assertScriptOptPathValue('subminer-binary_path', appPath);
|
||||
}
|
||||
if (!hasSocketPath) {
|
||||
assertScriptOptPathValue('subminer-socket_path', socketPath);
|
||||
}
|
||||
const parts = [
|
||||
...(hasBinaryPath ? [] : [`subminer-binary_path=${appPath}`]),
|
||||
...(hasSocketPath ? [] : [`subminer-socket_path=${socketPath}`]),
|
||||
...extraParts.map(sanitizeScriptOptValue),
|
||||
];
|
||||
return parts.join(',');
|
||||
}
|
||||
@@ -559,7 +559,6 @@ test(
|
||||
socketPath: smokeCase.socketPath,
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
aniskipEnabled: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
+2
-4
@@ -191,8 +191,6 @@ export interface LauncherMpvConfig {
|
||||
autoStartSubMiner?: boolean;
|
||||
pauseUntilOverlayReady?: boolean;
|
||||
subminerBinaryPath?: string;
|
||||
aniskipEnabled?: boolean;
|
||||
aniskipButtonKey?: string;
|
||||
}
|
||||
|
||||
export interface LauncherLoggingConfig {
|
||||
@@ -209,9 +207,9 @@ export interface PluginRuntimeConfig {
|
||||
autoStart: boolean;
|
||||
autoStartVisibleOverlay: boolean;
|
||||
autoStartPauseUntilReady: boolean;
|
||||
overlayLoadingOsd?: boolean;
|
||||
osdMessages: boolean;
|
||||
texthookerEnabled: boolean;
|
||||
aniskipEnabled: boolean;
|
||||
aniskipButtonKey: string;
|
||||
}
|
||||
|
||||
export interface CommandExecOptions {
|
||||
|
||||
+3
-3
File diff suppressed because one or more lines are too long
@@ -1,758 +0,0 @@
|
||||
local M = {}
|
||||
local matcher = require("aniskip_match")
|
||||
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
|
||||
|
||||
function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
local utils = ctx.utils
|
||||
local opts = ctx.opts
|
||||
local state = ctx.state
|
||||
local environment = ctx.environment
|
||||
local subminer_log = ctx.log.subminer_log
|
||||
local show_osd = ctx.log.show_osd
|
||||
local request_generation = 0
|
||||
local mal_lookup_cache = {}
|
||||
local payload_cache = {}
|
||||
local title_context_cache = {}
|
||||
local base64_reverse = {}
|
||||
local base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
|
||||
for i = 1, #base64_chars do
|
||||
base64_reverse[base64_chars:sub(i, i)] = i - 1
|
||||
end
|
||||
|
||||
local function url_encode(text)
|
||||
if type(text) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
local encoded = text:gsub("\n", " ")
|
||||
encoded = encoded:gsub("([^%w%-_%.~ ])", function(char)
|
||||
return string.format("%%%02X", string.byte(char))
|
||||
end)
|
||||
return encoded:gsub(" ", "%%20")
|
||||
end
|
||||
|
||||
local function is_remote_media_path()
|
||||
local media_path = mp.get_property("path")
|
||||
if type(media_path) ~= "string" then
|
||||
return false
|
||||
end
|
||||
local trimmed = media_path:match("^%s*(.-)%s*$") or ""
|
||||
if trimmed == "" then
|
||||
return false
|
||||
end
|
||||
return trimmed:match("^%a[%w+.-]*://") ~= nil
|
||||
end
|
||||
|
||||
local function parse_json_payload(text)
|
||||
if type(text) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local parsed, parse_error = utils.parse_json(text)
|
||||
if type(parsed) == "table" then
|
||||
return parsed
|
||||
end
|
||||
return nil, parse_error
|
||||
end
|
||||
|
||||
local function decode_base64(input)
|
||||
if type(input) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local cleaned = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/")
|
||||
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
|
||||
if cleaned == "" then
|
||||
return nil
|
||||
end
|
||||
if #cleaned % 4 == 1 then
|
||||
return nil
|
||||
end
|
||||
if #cleaned % 4 ~= 0 then
|
||||
cleaned = cleaned .. string.rep("=", 4 - (#cleaned % 4))
|
||||
end
|
||||
if not cleaned:match("^[A-Za-z0-9+/%=]+$") then
|
||||
return nil
|
||||
end
|
||||
local out = {}
|
||||
local out_len = 0
|
||||
for index = 1, #cleaned, 4 do
|
||||
local c1 = cleaned:sub(index, index)
|
||||
local c2 = cleaned:sub(index + 1, index + 1)
|
||||
local c3 = cleaned:sub(index + 2, index + 2)
|
||||
local c4 = cleaned:sub(index + 3, index + 3)
|
||||
local v1 = base64_reverse[c1]
|
||||
local v2 = base64_reverse[c2]
|
||||
if not v1 or not v2 then
|
||||
return nil
|
||||
end
|
||||
local v3 = c3 == "=" and 0 or base64_reverse[c3]
|
||||
local v4 = c4 == "=" and 0 or base64_reverse[c4]
|
||||
if (c3 ~= "=" and not v3) or (c4 ~= "=" and not v4) then
|
||||
return nil
|
||||
end
|
||||
local n = (((v1 * 64 + v2) * 64 + v3) * 64 + v4)
|
||||
local b1 = math.floor(n / 65536)
|
||||
local remaining = n % 65536
|
||||
local b2 = math.floor(remaining / 256)
|
||||
local b3 = remaining % 256
|
||||
out_len = out_len + 1
|
||||
out[out_len] = string.char(b1)
|
||||
if c3 ~= "=" then
|
||||
out_len = out_len + 1
|
||||
out[out_len] = string.char(b2)
|
||||
end
|
||||
if c4 ~= "=" then
|
||||
out_len = out_len + 1
|
||||
out[out_len] = string.char(b3)
|
||||
end
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
local function resolve_launcher_payload()
|
||||
local raw_payload = type(opts.aniskip_payload) == "string" and opts.aniskip_payload or ""
|
||||
local trimmed = raw_payload:match("^%s*(.-)%s*$") or ""
|
||||
if trimmed == "" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local parsed, parse_error = parse_json_payload(trimmed)
|
||||
if type(parsed) == "table" then
|
||||
return parsed
|
||||
end
|
||||
|
||||
local url_decoded = trimmed:gsub("%%(%x%x)", function(hex)
|
||||
local value = tonumber(hex, 16)
|
||||
if value then
|
||||
return string.char(value)
|
||||
end
|
||||
return "%"
|
||||
end)
|
||||
if url_decoded ~= trimmed then
|
||||
parsed, parse_error = parse_json_payload(url_decoded)
|
||||
if type(parsed) == "table" then
|
||||
return parsed
|
||||
end
|
||||
end
|
||||
|
||||
local b64_decoded = decode_base64(trimmed)
|
||||
if type(b64_decoded) == "string" and b64_decoded ~= "" then
|
||||
parsed, parse_error = parse_json_payload(b64_decoded)
|
||||
if type(parsed) == "table" then
|
||||
return parsed
|
||||
end
|
||||
end
|
||||
|
||||
subminer_log("warn", "aniskip", "Invalid launcher AniSkip payload: " .. tostring(parse_error or "unparseable"))
|
||||
return nil
|
||||
end
|
||||
|
||||
local function run_json_curl_async(url, callback)
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url },
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
}, function(success, result, error)
|
||||
if not success or not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then
|
||||
local detail = error or (result and result.stderr) or "curl failed"
|
||||
callback(nil, detail)
|
||||
return
|
||||
end
|
||||
local parsed, parse_error = utils.parse_json(result.stdout)
|
||||
if type(parsed) ~= "table" then
|
||||
callback(nil, parse_error or "invalid json")
|
||||
return
|
||||
end
|
||||
callback(parsed, nil)
|
||||
end)
|
||||
end
|
||||
|
||||
local function parse_episode_hint(text)
|
||||
if type(text) ~= "string" or text == "" then
|
||||
return nil
|
||||
end
|
||||
local patterns = {
|
||||
"[Ss]%d+[Ee](%d+)",
|
||||
"[Ee][Pp]?[%s%._%-]*(%d+)",
|
||||
"[%s%._%-]+(%d+)[%s%._%-]+",
|
||||
}
|
||||
for _, pattern in ipairs(patterns) do
|
||||
local token = text:match(pattern)
|
||||
if token then
|
||||
local episode = tonumber(token)
|
||||
if episode and episode > 0 and episode < 10000 then
|
||||
return episode
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function cleanup_title(raw)
|
||||
if type(raw) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local cleaned = raw
|
||||
cleaned = cleaned:gsub("%b[]", " ")
|
||||
cleaned = cleaned:gsub("%b()", " ")
|
||||
cleaned = cleaned:gsub("[Ss]%d+[Ee]%d+", " ")
|
||||
cleaned = cleaned:gsub("[Ee][Pp]?[%s%._%-]*%d+", " ")
|
||||
cleaned = cleaned:gsub("[%._%-]+", " ")
|
||||
cleaned = cleaned:gsub("%s+", " ")
|
||||
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
|
||||
if cleaned == "" then
|
||||
return nil
|
||||
end
|
||||
return cleaned
|
||||
end
|
||||
|
||||
local function extract_show_title_from_path(media_path)
|
||||
if type(media_path) ~= "string" or media_path == "" then
|
||||
return nil
|
||||
end
|
||||
local normalized = media_path:gsub("\\", "/")
|
||||
local segments = {}
|
||||
for segment in normalized:gmatch("[^/]+") do
|
||||
segments[#segments + 1] = segment
|
||||
end
|
||||
for index = 1, #segments do
|
||||
local segment = segments[index] or ""
|
||||
if segment:match("^[Ss]eason[%s%._%-]*%d+$") or segment:match("^[Ss][%s%._%-]*%d+$") then
|
||||
local prior = segments[index - 1]
|
||||
local cleaned = cleanup_title(prior or "")
|
||||
if cleaned and cleaned ~= "" then
|
||||
return cleaned
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function resolve_title_and_episode()
|
||||
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||
local forced_season = tonumber(opts.aniskip_season)
|
||||
local forced_episode = tonumber(opts.aniskip_episode)
|
||||
local media_title = mp.get_property("media-title")
|
||||
local filename = mp.get_property("filename/no-ext") or mp.get_property("filename") or ""
|
||||
local path = mp.get_property("path") or ""
|
||||
local cache_key = table.concat({
|
||||
tostring(forced_title or ""),
|
||||
tostring(forced_season or ""),
|
||||
tostring(forced_episode or ""),
|
||||
tostring(media_title or ""),
|
||||
tostring(filename or ""),
|
||||
tostring(path or ""),
|
||||
}, "\31")
|
||||
local cached = title_context_cache[cache_key]
|
||||
if type(cached) == "table" then
|
||||
return cached.title, cached.episode, cached.season
|
||||
end
|
||||
local path_show_title = extract_show_title_from_path(path)
|
||||
local candidate_title = nil
|
||||
if path_show_title and path_show_title ~= "" then
|
||||
candidate_title = path_show_title
|
||||
elseif forced_title ~= "" then
|
||||
candidate_title = forced_title
|
||||
else
|
||||
candidate_title = cleanup_title(media_title) or cleanup_title(filename) or cleanup_title(path)
|
||||
end
|
||||
local episode = forced_episode or parse_episode_hint(media_title) or parse_episode_hint(filename) or parse_episode_hint(path) or 1
|
||||
title_context_cache[cache_key] = {
|
||||
title = candidate_title,
|
||||
episode = episode,
|
||||
season = forced_season,
|
||||
}
|
||||
return candidate_title, episode, forced_season
|
||||
end
|
||||
|
||||
local function select_best_mal_item(items, title, season)
|
||||
if type(items) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
local best_item = nil
|
||||
local best_score = -math.huge
|
||||
for _, item in ipairs(items) do
|
||||
if type(item) == "table" and tonumber(item.id) then
|
||||
local candidate_name = tostring(item.name or "")
|
||||
local score = matcher.title_overlap_score(title, candidate_name) + matcher.season_signal_score(season, candidate_name)
|
||||
if score > best_score then
|
||||
best_score = score
|
||||
best_item = item
|
||||
end
|
||||
end
|
||||
end
|
||||
return best_item
|
||||
end
|
||||
|
||||
local function resolve_mal_id_async(title, season, request_id, callback)
|
||||
local forced_mal_id = tonumber(opts.aniskip_mal_id)
|
||||
if forced_mal_id and forced_mal_id > 0 then
|
||||
callback(forced_mal_id, "(forced-mal-id)")
|
||||
return
|
||||
end
|
||||
if type(title) == "string" and title:match("^%d+$") then
|
||||
local numeric = tonumber(title)
|
||||
if numeric and numeric > 0 then
|
||||
callback(numeric, title)
|
||||
return
|
||||
end
|
||||
end
|
||||
if type(title) ~= "string" or title == "" then
|
||||
callback(nil, nil)
|
||||
return
|
||||
end
|
||||
|
||||
local lookup = title
|
||||
if season and season > 1 then
|
||||
lookup = string.format("%s Season %d", lookup, season)
|
||||
end
|
||||
local cache_key = string.format("%s|%s", lookup:lower(), tostring(season or "-"))
|
||||
local cached = mal_lookup_cache[cache_key]
|
||||
if cached ~= nil then
|
||||
if cached == false then
|
||||
callback(nil, lookup)
|
||||
else
|
||||
callback(cached, lookup)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup)
|
||||
run_json_curl_async(mal_url, function(mal_json, mal_error)
|
||||
if request_id ~= request_generation then
|
||||
return
|
||||
end
|
||||
if not mal_json then
|
||||
subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error))
|
||||
callback(nil, lookup)
|
||||
return
|
||||
end
|
||||
local categories = mal_json.categories
|
||||
if type(categories) ~= "table" then
|
||||
mal_lookup_cache[cache_key] = false
|
||||
callback(nil, lookup)
|
||||
return
|
||||
end
|
||||
|
||||
local all_items = {}
|
||||
for _, category in ipairs(categories) do
|
||||
if type(category) == "table" and type(category.items) == "table" then
|
||||
for _, item in ipairs(category.items) do
|
||||
all_items[#all_items + 1] = item
|
||||
end
|
||||
end
|
||||
end
|
||||
local best_item = select_best_mal_item(all_items, title, season)
|
||||
if best_item and tonumber(best_item.id) then
|
||||
local matched_id = tonumber(best_item.id)
|
||||
mal_lookup_cache[cache_key] = matched_id
|
||||
subminer_log(
|
||||
"info",
|
||||
"aniskip",
|
||||
string.format(
|
||||
'MAL candidate selected (score-based): id=%s name="%s" season_hint=%s',
|
||||
tostring(best_item.id),
|
||||
tostring(best_item.name or ""),
|
||||
tostring(season or "-")
|
||||
)
|
||||
)
|
||||
callback(matched_id, lookup)
|
||||
return
|
||||
end
|
||||
mal_lookup_cache[cache_key] = false
|
||||
callback(nil, lookup)
|
||||
end)
|
||||
end
|
||||
|
||||
local function set_intro_chapters(intro_start, intro_end)
|
||||
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
|
||||
return
|
||||
end
|
||||
local current = mp.get_property_native("chapter-list")
|
||||
local chapters = {}
|
||||
if type(current) == "table" then
|
||||
for _, chapter in ipairs(current) do
|
||||
local title = type(chapter) == "table" and chapter.title or nil
|
||||
if type(title) ~= "string" or not title:match("^AniSkip ") then
|
||||
chapters[#chapters + 1] = chapter
|
||||
end
|
||||
end
|
||||
end
|
||||
chapters[#chapters + 1] = { time = intro_start, title = "AniSkip Intro Start" }
|
||||
chapters[#chapters + 1] = { time = intro_end, title = "AniSkip Intro End" }
|
||||
table.sort(chapters, function(a, b)
|
||||
local a_time = type(a) == "table" and tonumber(a.time) or 0
|
||||
local b_time = type(b) == "table" and tonumber(b.time) or 0
|
||||
return a_time < b_time
|
||||
end)
|
||||
mp.set_property_native("chapter-list", chapters)
|
||||
end
|
||||
|
||||
local function remove_aniskip_chapters()
|
||||
local current = mp.get_property_native("chapter-list")
|
||||
if type(current) ~= "table" then
|
||||
return
|
||||
end
|
||||
local chapters = {}
|
||||
local changed = false
|
||||
for _, chapter in ipairs(current) do
|
||||
local title = type(chapter) == "table" and chapter.title or nil
|
||||
if type(title) == "string" and title:match("^AniSkip ") then
|
||||
changed = true
|
||||
else
|
||||
chapters[#chapters + 1] = chapter
|
||||
end
|
||||
end
|
||||
if changed then
|
||||
mp.set_property_native("chapter-list", chapters)
|
||||
end
|
||||
end
|
||||
|
||||
local function reset_aniskip_fields()
|
||||
state.aniskip.prompt_shown = false
|
||||
state.aniskip.found = false
|
||||
state.aniskip.mal_id = nil
|
||||
state.aniskip.title = nil
|
||||
state.aniskip.episode = nil
|
||||
state.aniskip.intro_start = nil
|
||||
state.aniskip.intro_end = nil
|
||||
state.aniskip.payload = nil
|
||||
state.aniskip.payload_source = nil
|
||||
remove_aniskip_chapters()
|
||||
end
|
||||
|
||||
local function clear_aniskip_state()
|
||||
request_generation = request_generation + 1
|
||||
reset_aniskip_fields()
|
||||
end
|
||||
|
||||
local function skip_intro_now()
|
||||
if not state.aniskip.found then
|
||||
show_osd("Intro skip unavailable")
|
||||
return
|
||||
end
|
||||
local intro_start = state.aniskip.intro_start
|
||||
local intro_end = state.aniskip.intro_end
|
||||
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
|
||||
show_osd("Intro markers missing")
|
||||
return
|
||||
end
|
||||
local now = mp.get_property_number("time-pos")
|
||||
if type(now) ~= "number" then
|
||||
show_osd("Skip unavailable")
|
||||
return
|
||||
end
|
||||
local epsilon = 0.35
|
||||
if now < (intro_start - epsilon) or now > (intro_end + epsilon) then
|
||||
show_osd("Skip intro only during intro")
|
||||
return
|
||||
end
|
||||
mp.set_property_number("time-pos", intro_end)
|
||||
show_osd("Skipped intro")
|
||||
end
|
||||
|
||||
local function update_intro_button_visibility()
|
||||
if not opts.aniskip_enabled or not opts.aniskip_show_button or not state.aniskip.found then
|
||||
return
|
||||
end
|
||||
local now = mp.get_property_number("time-pos")
|
||||
if type(now) ~= "number" then
|
||||
return
|
||||
end
|
||||
local in_intro = now >= (state.aniskip.intro_start or -1) and now < (state.aniskip.intro_end or -1)
|
||||
local intro_start = state.aniskip.intro_start or -1
|
||||
local hint_window_end = intro_start + 3
|
||||
if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then
|
||||
local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or DEFAULT_ANISKIP_BUTTON_KEY
|
||||
local message = string.format(opts.aniskip_button_text, key)
|
||||
mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3)
|
||||
state.aniskip.prompt_shown = true
|
||||
end
|
||||
end
|
||||
|
||||
local function apply_aniskip_payload(mal_id, title, episode, payload)
|
||||
local results = payload and payload.results
|
||||
if type(results) ~= "table" then
|
||||
return false
|
||||
end
|
||||
for _, item in ipairs(results) do
|
||||
if type(item) == "table" and item.skip_type == "op" and type(item.interval) == "table" then
|
||||
local intro_start = tonumber(item.interval.start_time)
|
||||
local intro_end = tonumber(item.interval.end_time)
|
||||
if intro_start and intro_end and intro_end > intro_start then
|
||||
state.aniskip.found = true
|
||||
state.aniskip.mal_id = mal_id
|
||||
state.aniskip.title = title
|
||||
state.aniskip.episode = episode
|
||||
state.aniskip.intro_start = intro_start
|
||||
state.aniskip.intro_end = intro_end
|
||||
state.aniskip.prompt_shown = false
|
||||
set_intro_chapters(intro_start, intro_end)
|
||||
subminer_log(
|
||||
"info",
|
||||
"aniskip",
|
||||
string.format(
|
||||
"Intro window %.3f -> %.3f (MAL %s, ep %s)",
|
||||
intro_start,
|
||||
intro_end,
|
||||
tostring(mal_id or "-"),
|
||||
tostring(episode or "-")
|
||||
)
|
||||
)
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function has_launcher_payload()
|
||||
return type(opts.aniskip_payload) == "string" and opts.aniskip_payload:match("%S") ~= nil
|
||||
end
|
||||
|
||||
local function is_launcher_context()
|
||||
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||
if forced_title ~= "" then
|
||||
return true
|
||||
end
|
||||
local forced_mal_id = tonumber(opts.aniskip_mal_id)
|
||||
if forced_mal_id and forced_mal_id > 0 then
|
||||
return true
|
||||
end
|
||||
local forced_episode = tonumber(opts.aniskip_episode)
|
||||
if forced_episode and forced_episode > 0 then
|
||||
return true
|
||||
end
|
||||
local forced_season = tonumber(opts.aniskip_season)
|
||||
if forced_season and forced_season > 0 then
|
||||
return true
|
||||
end
|
||||
if has_launcher_payload() then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function should_fetch_aniskip_async(trigger_source, callback)
|
||||
if is_remote_media_path() then
|
||||
callback(false, "remote-url")
|
||||
return
|
||||
end
|
||||
if trigger_source == "script-message" or trigger_source == "overlay-start" then
|
||||
callback(true, trigger_source)
|
||||
return
|
||||
end
|
||||
if is_launcher_context() then
|
||||
callback(true, "launcher-context")
|
||||
return
|
||||
end
|
||||
if type(environment.is_subminer_app_running_async) == "function" then
|
||||
environment.is_subminer_app_running_async(function(running)
|
||||
if running then
|
||||
callback(true, "subminer-app-running")
|
||||
else
|
||||
callback(false, "subminer-context-missing")
|
||||
end
|
||||
end)
|
||||
return
|
||||
end
|
||||
if environment.is_subminer_app_running() then
|
||||
callback(true, "subminer-app-running")
|
||||
return
|
||||
end
|
||||
callback(false, "subminer-context-missing")
|
||||
end
|
||||
|
||||
local function resolve_lookup_titles(primary_title)
|
||||
local media_title_fallback = cleanup_title(mp.get_property("media-title"))
|
||||
local filename_fallback = cleanup_title(mp.get_property("filename/no-ext") or mp.get_property("filename") or "")
|
||||
local path_fallback = cleanup_title(mp.get_property("path") or "")
|
||||
local lookup_titles = {}
|
||||
local seen_titles = {}
|
||||
local function push_lookup_title(candidate)
|
||||
if type(candidate) ~= "string" then
|
||||
return
|
||||
end
|
||||
local trimmed = candidate:match("^%s*(.-)%s*$") or ""
|
||||
if trimmed == "" then
|
||||
return
|
||||
end
|
||||
local key = trimmed:lower()
|
||||
if seen_titles[key] then
|
||||
return
|
||||
end
|
||||
seen_titles[key] = true
|
||||
lookup_titles[#lookup_titles + 1] = trimmed
|
||||
end
|
||||
push_lookup_title(primary_title)
|
||||
push_lookup_title(media_title_fallback)
|
||||
push_lookup_title(filename_fallback)
|
||||
push_lookup_title(path_fallback)
|
||||
return lookup_titles
|
||||
end
|
||||
|
||||
local function resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, index, last_lookup)
|
||||
local current_index = index or 1
|
||||
local current_lookup = last_lookup
|
||||
if current_index > #lookup_titles then
|
||||
callback(nil, current_lookup)
|
||||
return
|
||||
end
|
||||
local lookup_title = lookup_titles[current_index]
|
||||
subminer_log("info", "aniskip", string.format('MAL lookup attempt %d/%d using title="%s"', current_index, #lookup_titles, lookup_title))
|
||||
resolve_mal_id_async(lookup_title, season, request_id, function(mal_id, lookup)
|
||||
if request_id ~= request_generation then
|
||||
return
|
||||
end
|
||||
if mal_id then
|
||||
callback(mal_id, lookup)
|
||||
return
|
||||
end
|
||||
resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, current_index + 1, lookup or current_lookup)
|
||||
end)
|
||||
end
|
||||
|
||||
local function fetch_payload_for_episode_async(mal_id, episode, request_id, callback)
|
||||
local payload_cache_key = string.format("%d:%d", mal_id, episode)
|
||||
local cached_payload = payload_cache[payload_cache_key]
|
||||
if cached_payload ~= nil then
|
||||
if cached_payload == false then
|
||||
callback(nil, nil, true)
|
||||
else
|
||||
callback(cached_payload, nil, true)
|
||||
end
|
||||
return
|
||||
end
|
||||
local url = string.format("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", mal_id, episode)
|
||||
subminer_log("info", "aniskip", string.format("AniSkip URL=%s", url))
|
||||
run_json_curl_async(url, function(payload, fetch_error)
|
||||
if request_id ~= request_generation then
|
||||
return
|
||||
end
|
||||
if not payload then
|
||||
callback(nil, fetch_error, false)
|
||||
return
|
||||
end
|
||||
if payload.found ~= true then
|
||||
payload_cache[payload_cache_key] = false
|
||||
callback(nil, nil, false)
|
||||
return
|
||||
end
|
||||
payload_cache[payload_cache_key] = payload
|
||||
callback(payload, nil, false)
|
||||
end)
|
||||
end
|
||||
|
||||
local function fetch_payload_from_launcher(payload, mal_id, title, episode)
|
||||
if not payload then
|
||||
return false
|
||||
end
|
||||
state.aniskip.payload = payload
|
||||
state.aniskip.payload_source = "launcher"
|
||||
state.aniskip.mal_id = mal_id
|
||||
state.aniskip.title = title
|
||||
state.aniskip.episode = episode
|
||||
return apply_aniskip_payload(mal_id, title, episode, payload)
|
||||
end
|
||||
|
||||
local function fetch_aniskip_for_current_media(trigger_source)
|
||||
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
|
||||
if not opts.aniskip_enabled then
|
||||
clear_aniskip_state()
|
||||
return
|
||||
end
|
||||
|
||||
should_fetch_aniskip_async(trigger, function(allowed, reason)
|
||||
if not allowed then
|
||||
subminer_log("debug", "aniskip", "Skipping lookup: " .. tostring(reason))
|
||||
return
|
||||
end
|
||||
|
||||
request_generation = request_generation + 1
|
||||
local request_id = request_generation
|
||||
reset_aniskip_fields()
|
||||
local title, episode, season = resolve_title_and_episode()
|
||||
local lookup_titles = resolve_lookup_titles(title)
|
||||
local launcher_payload = resolve_launcher_payload()
|
||||
if launcher_payload then
|
||||
local launcher_mal_id = tonumber(opts.aniskip_mal_id)
|
||||
if not launcher_mal_id then
|
||||
launcher_mal_id = nil
|
||||
end
|
||||
if fetch_payload_from_launcher(launcher_payload, launcher_mal_id, title, episode) then
|
||||
subminer_log(
|
||||
"info",
|
||||
"aniskip",
|
||||
string.format(
|
||||
"Using launcher-provided AniSkip payload (title=%s, season=%s, episode=%s)",
|
||||
tostring(title or ""),
|
||||
tostring(season or "-"),
|
||||
tostring(episode or "-")
|
||||
)
|
||||
)
|
||||
return
|
||||
end
|
||||
subminer_log("info", "aniskip", "Launcher payload present but no OP interval was available")
|
||||
return
|
||||
end
|
||||
|
||||
subminer_log(
|
||||
"info",
|
||||
"aniskip",
|
||||
string.format(
|
||||
'Query context: trigger=%s reason=%s title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)',
|
||||
tostring(trigger),
|
||||
tostring(reason or "-"),
|
||||
tostring(title or ""),
|
||||
tostring(season or "-"),
|
||||
tostring(episode or "-"),
|
||||
tostring(opts.aniskip_title or ""),
|
||||
tostring(opts.aniskip_season or "-"),
|
||||
tostring(opts.aniskip_episode or "-"),
|
||||
tostring(opts.aniskip_mal_id or "-"),
|
||||
#lookup_titles
|
||||
)
|
||||
)
|
||||
|
||||
resolve_mal_from_candidates_async(lookup_titles, season, request_id, function(mal_id, mal_lookup)
|
||||
if request_id ~= request_generation then
|
||||
return
|
||||
end
|
||||
if not mal_id then
|
||||
subminer_log("info", "aniskip", string.format('Skipped: MAL id unavailable for query="%s"', tostring(mal_lookup or "")))
|
||||
return
|
||||
end
|
||||
subminer_log("info", "aniskip", string.format('Resolved MAL id=%d using query="%s"', mal_id, tostring(mal_lookup or "")))
|
||||
fetch_payload_for_episode_async(mal_id, episode, request_id, function(payload, fetch_error)
|
||||
if request_id ~= request_generation then
|
||||
return
|
||||
end
|
||||
if not payload then
|
||||
if fetch_error then
|
||||
subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error))
|
||||
else
|
||||
subminer_log("info", "aniskip", "AniSkip: no skip windows found")
|
||||
end
|
||||
return
|
||||
end
|
||||
state.aniskip.payload = payload
|
||||
state.aniskip.payload_source = "remote"
|
||||
if not apply_aniskip_payload(mal_id, title, episode, payload) then
|
||||
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
return {
|
||||
clear_aniskip_state = clear_aniskip_state,
|
||||
skip_intro_now = skip_intro_now,
|
||||
update_intro_button_visibility = update_intro_button_visibility,
|
||||
fetch_aniskip_for_current_media = fetch_aniskip_for_current_media,
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,150 +0,0 @@
|
||||
local M = {}
|
||||
|
||||
local function normalize_for_match(value)
|
||||
if type(value) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
return value:lower():gsub("[^%w]+", " "):gsub("%s+", " "):match("^%s*(.-)%s*$") or ""
|
||||
end
|
||||
|
||||
local MATCH_STOPWORDS = {
|
||||
the = true,
|
||||
this = true,
|
||||
that = true,
|
||||
world = true,
|
||||
animated = true,
|
||||
series = true,
|
||||
season = true,
|
||||
no = true,
|
||||
on = true,
|
||||
["and"] = true,
|
||||
}
|
||||
|
||||
local function tokenize_match_words(value)
|
||||
local normalized = normalize_for_match(value)
|
||||
local tokens = {}
|
||||
for token in normalized:gmatch("%S+") do
|
||||
if #token >= 3 and not MATCH_STOPWORDS[token] then
|
||||
tokens[#tokens + 1] = token
|
||||
end
|
||||
end
|
||||
return tokens
|
||||
end
|
||||
|
||||
local function token_set(tokens)
|
||||
local set = {}
|
||||
for _, token in ipairs(tokens) do
|
||||
set[token] = true
|
||||
end
|
||||
return set
|
||||
end
|
||||
|
||||
function M.title_overlap_score(expected_title, candidate_title)
|
||||
local expected = normalize_for_match(expected_title)
|
||||
local candidate = normalize_for_match(candidate_title)
|
||||
if expected == "" or candidate == "" then
|
||||
return 0
|
||||
end
|
||||
if candidate:find(expected, 1, true) then
|
||||
return 120
|
||||
end
|
||||
local expected_tokens = tokenize_match_words(expected_title)
|
||||
local candidate_tokens = token_set(tokenize_match_words(candidate_title))
|
||||
if #expected_tokens == 0 then
|
||||
return 0
|
||||
end
|
||||
local score = 0
|
||||
local matched = 0
|
||||
for _, token in ipairs(expected_tokens) do
|
||||
if candidate_tokens[token] then
|
||||
score = score + 30
|
||||
matched = matched + 1
|
||||
else
|
||||
score = score - 20
|
||||
end
|
||||
end
|
||||
if matched == 0 then
|
||||
score = score - 80
|
||||
end
|
||||
local coverage = matched / #expected_tokens
|
||||
if #expected_tokens >= 2 then
|
||||
if coverage >= 0.8 then
|
||||
score = score + 30
|
||||
elseif coverage >= 0.6 then
|
||||
score = score + 10
|
||||
else
|
||||
score = score - 50
|
||||
end
|
||||
elseif coverage >= 1 then
|
||||
score = score + 10
|
||||
end
|
||||
return score
|
||||
end
|
||||
|
||||
local function has_any_sequel_marker(candidate_title)
|
||||
local normalized = normalize_for_match(candidate_title)
|
||||
if normalized == "" then
|
||||
return false
|
||||
end
|
||||
local markers = {
|
||||
"season 2",
|
||||
"season 3",
|
||||
"season 4",
|
||||
"2nd season",
|
||||
"3rd season",
|
||||
"4th season",
|
||||
"second season",
|
||||
"third season",
|
||||
"fourth season",
|
||||
" ii ",
|
||||
" iii ",
|
||||
" iv ",
|
||||
}
|
||||
local padded = " " .. normalized .. " "
|
||||
for _, marker in ipairs(markers) do
|
||||
if padded:find(marker, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function M.season_signal_score(requested_season, candidate_title)
|
||||
local season = tonumber(requested_season)
|
||||
if not season or season < 1 then
|
||||
return 0
|
||||
end
|
||||
local normalized = " " .. normalize_for_match(candidate_title) .. " "
|
||||
if normalized == " " then
|
||||
return 0
|
||||
end
|
||||
|
||||
if season == 1 then
|
||||
return has_any_sequel_marker(candidate_title) and -60 or 20
|
||||
end
|
||||
|
||||
local numeric_marker = string.format(" season %d ", season)
|
||||
local ordinal_marker = string.format(" %dth season ", season)
|
||||
local roman_markers = {
|
||||
[2] = { " ii ", " second season ", " 2nd season " },
|
||||
[3] = { " iii ", " third season ", " 3rd season " },
|
||||
[4] = { " iv ", " fourth season ", " 4th season " },
|
||||
[5] = { " v ", " fifth season ", " 5th season " },
|
||||
}
|
||||
|
||||
if normalized:find(numeric_marker, 1, true) or normalized:find(ordinal_marker, 1, true) then
|
||||
return 40
|
||||
end
|
||||
local aliases = roman_markers[season] or {}
|
||||
for _, marker in ipairs(aliases) do
|
||||
if normalized:find(marker, 1, true) then
|
||||
return 40
|
||||
end
|
||||
end
|
||||
if has_any_sequel_marker(candidate_title) then
|
||||
return -20
|
||||
end
|
||||
return 5
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -56,9 +56,6 @@ function M.init()
|
||||
ctx.binary = make_lazy_proxy("binary", function()
|
||||
return require("binary").create(ctx)
|
||||
end)
|
||||
ctx.aniskip = make_lazy_proxy("aniskip", function()
|
||||
return require("aniskip").create(ctx)
|
||||
end)
|
||||
ctx.hover = make_lazy_proxy("hover", function()
|
||||
return require("hover").create(ctx)
|
||||
end)
|
||||
|
||||
@@ -10,7 +10,6 @@ function M.create(ctx)
|
||||
local state = ctx.state
|
||||
local options_helper = ctx.options_helper
|
||||
local process = ctx.process
|
||||
local aniskip = ctx.aniskip
|
||||
local hover = ctx.hover
|
||||
local subminer_log = ctx.log.subminer_log
|
||||
local show_osd = ctx.log.show_osd
|
||||
@@ -52,13 +51,6 @@ function M.create(ctx)
|
||||
return reason == "reload" or reason == "redirect"
|
||||
end
|
||||
|
||||
local function schedule_aniskip_fetch(trigger_source, delay_seconds)
|
||||
local delay = tonumber(delay_seconds) or 0
|
||||
mp.add_timeout(delay, function()
|
||||
aniskip.fetch_aniskip_for_current_media(trigger_source)
|
||||
end)
|
||||
end
|
||||
|
||||
local function clear_pending_visible_overlay_hide()
|
||||
local timer = state.pending_visible_overlay_hide_timer
|
||||
if timer and timer.kill then
|
||||
@@ -112,6 +104,14 @@ function M.create(ctx)
|
||||
return options_helper.coerce_bool(raw_visible_overlay, false)
|
||||
end
|
||||
|
||||
local function resolve_overlay_loading_osd_enabled()
|
||||
local raw_overlay_loading_osd = opts.overlay_loading_osd
|
||||
if raw_overlay_loading_osd == nil then
|
||||
raw_overlay_loading_osd = opts["overlay-loading-osd"]
|
||||
end
|
||||
return options_helper.coerce_bool(raw_overlay_loading_osd, false)
|
||||
end
|
||||
|
||||
local function next_auto_start_retry_generation()
|
||||
state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1
|
||||
return state.auto_start_retry_generation
|
||||
@@ -151,6 +151,14 @@ function M.create(ctx)
|
||||
and not (state.overlay_running and state.auto_play_ready_signal_seen == true)
|
||||
end
|
||||
|
||||
local function should_show_overlay_loading_osd()
|
||||
return (
|
||||
resolve_overlay_loading_osd_enabled()
|
||||
or (resolve_auto_start_enabled() and resolve_auto_start_visible_overlay_enabled())
|
||||
)
|
||||
and not state.suppress_ready_overlay_restore
|
||||
end
|
||||
|
||||
local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt)
|
||||
if generation ~= state.auto_start_retry_generation then
|
||||
return
|
||||
@@ -159,7 +167,6 @@ function M.create(ctx)
|
||||
return
|
||||
end
|
||||
if not resolve_auto_start_enabled() then
|
||||
schedule_aniskip_fetch("file-loaded", 0)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -178,7 +185,7 @@ function M.create(ctx)
|
||||
.. process.describe_mpv_ipc_socket_match(opts.socket_path)
|
||||
.. ")"
|
||||
)
|
||||
schedule_aniskip_fetch("file-loaded", 0)
|
||||
process.stop_overlay_loading_osd()
|
||||
return
|
||||
end
|
||||
|
||||
@@ -187,11 +194,12 @@ function M.create(ctx)
|
||||
socket_path = opts.socket_path,
|
||||
rearm_pause_until_ready = should_rearm_pause_until_ready(same_media_loaded),
|
||||
})
|
||||
-- Give the overlay process a moment to initialize before querying AniSkip.
|
||||
schedule_aniskip_fetch("overlay-start", 0.8)
|
||||
end
|
||||
|
||||
local function on_start_file()
|
||||
if should_show_overlay_loading_osd() then
|
||||
process.start_overlay_loading_osd()
|
||||
end
|
||||
if state.pending_reload_media_identity ~= nil then
|
||||
local media_identity = resolve_media_identity()
|
||||
if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then
|
||||
@@ -245,6 +253,7 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
if same_media_reload then
|
||||
process.stop_overlay_loading_osd()
|
||||
subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload")
|
||||
if state.app_managed_playback_active then
|
||||
return
|
||||
@@ -267,12 +276,12 @@ function M.create(ctx)
|
||||
local preserve_active_auto_start_gate = (
|
||||
state.overlay_running and state.auto_play_ready_gate_armed and should_auto_start and has_matching_socket
|
||||
)
|
||||
aniskip.clear_aniskip_state()
|
||||
if not preserve_active_auto_start_gate then
|
||||
process.disarm_auto_play_ready_gate()
|
||||
end
|
||||
|
||||
if state.app_managed_playback_active then
|
||||
process.stop_overlay_loading_osd()
|
||||
subminer_log("debug", "lifecycle", "Skipping plugin auto-start for app-managed subtitle preload")
|
||||
return
|
||||
end
|
||||
@@ -283,14 +292,13 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
refresh_managed_subtitle_autoloading()
|
||||
schedule_aniskip_fetch("file-loaded", 0)
|
||||
end
|
||||
|
||||
local function on_shutdown()
|
||||
next_auto_start_retry_generation()
|
||||
aniskip.clear_aniskip_state()
|
||||
hover.clear_hover_overlay()
|
||||
process.disarm_auto_play_ready_gate()
|
||||
process.stop_overlay_loading_osd()
|
||||
clear_pending_visible_overlay_hide()
|
||||
state.auto_play_ready_signal_seen = false
|
||||
state.current_media_identity = nil
|
||||
@@ -310,6 +318,7 @@ function M.create(ctx)
|
||||
hover.clear_hover_overlay()
|
||||
end)
|
||||
mp.register_event("end-file", function(event)
|
||||
process.stop_overlay_loading_osd()
|
||||
process.disarm_auto_play_ready_gate()
|
||||
hover.clear_hover_overlay()
|
||||
local reason = type(event) == "table" and event.reason or nil
|
||||
@@ -334,22 +343,12 @@ function M.create(ctx)
|
||||
mp.register_event("shutdown", function()
|
||||
hover.clear_hover_overlay()
|
||||
end)
|
||||
mp.register_event("end-file", function()
|
||||
aniskip.clear_aniskip_state()
|
||||
end)
|
||||
mp.register_event("shutdown", function()
|
||||
aniskip.clear_aniskip_state()
|
||||
end)
|
||||
mp.add_hook("on_unload", 10, function()
|
||||
hover.clear_hover_overlay()
|
||||
aniskip.clear_aniskip_state()
|
||||
end)
|
||||
mp.observe_property("sub-start", "native", function()
|
||||
hover.clear_hover_overlay()
|
||||
end)
|
||||
mp.observe_property("time-pos", "number", function()
|
||||
aniskip.update_intro_button_visibility()
|
||||
end)
|
||||
end
|
||||
|
||||
return {
|
||||
|
||||
@@ -43,8 +43,8 @@ function M.create(ctx)
|
||||
end
|
||||
end
|
||||
|
||||
local function show_osd(message)
|
||||
if opts.osd_messages then
|
||||
local function show_osd(message, options)
|
||||
if opts.osd_messages or (options and options.force == true) then
|
||||
local payload = "SubMiner: " .. message
|
||||
local sent = false
|
||||
if type(mp.osd_message) == "function" then
|
||||
|
||||
@@ -2,8 +2,8 @@ local M = {}
|
||||
|
||||
function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
local opts = ctx.opts
|
||||
local process = ctx.process
|
||||
local aniskip = ctx.aniskip
|
||||
local hover = ctx.hover
|
||||
local ui = ctx.ui
|
||||
local state = ctx.state
|
||||
@@ -43,11 +43,8 @@ function M.create(ctx)
|
||||
mp.register_script_message("subminer-autoplay-ready", function()
|
||||
process.notify_auto_play_ready()
|
||||
end)
|
||||
mp.register_script_message("subminer-aniskip-refresh", function()
|
||||
aniskip.fetch_aniskip_for_current_media("script-message")
|
||||
end)
|
||||
mp.register_script_message("subminer-skip-intro", function()
|
||||
aniskip.skip_intro_now()
|
||||
mp.register_script_message("subminer-overlay-loading-ready", function()
|
||||
process.stop_overlay_loading_osd()
|
||||
end)
|
||||
mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json)
|
||||
hover.handle_hover_message(payload_json)
|
||||
@@ -56,7 +53,9 @@ function M.create(ctx)
|
||||
hover.handle_hover_message(payload_json)
|
||||
end)
|
||||
mp.register_script_message("subminer-stats-toggle", function()
|
||||
mp.osd_message("Stats: press ` (backtick) in overlay", 3)
|
||||
if opts.osd_messages then
|
||||
mp.osd_message("Stats: press ` (backtick) in overlay", 3)
|
||||
end
|
||||
end)
|
||||
mp.register_script_message("subminer-reload-session-bindings", function()
|
||||
ctx.session_bindings.reload_bindings()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
local M = {}
|
||||
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
|
||||
|
||||
local function normalize_socket_path_option(socket_path, default_socket_path)
|
||||
if type(default_socket_path) ~= "string" then
|
||||
@@ -32,21 +31,12 @@ function M.load(options_lib, default_socket_path)
|
||||
backend = "auto",
|
||||
auto_start = false,
|
||||
auto_start_visible_overlay = false,
|
||||
overlay_loading_osd = false,
|
||||
auto_start_pause_until_ready = true,
|
||||
auto_start_pause_until_ready_owns_initial_pause = false,
|
||||
auto_start_pause_until_ready_timeout_seconds = 15,
|
||||
auto_start_pause_until_ready_timeout_seconds = 30,
|
||||
osd_messages = true,
|
||||
log_level = "info",
|
||||
aniskip_enabled = false,
|
||||
aniskip_title = "",
|
||||
aniskip_season = "",
|
||||
aniskip_mal_id = "",
|
||||
aniskip_episode = "",
|
||||
aniskip_payload = "",
|
||||
aniskip_show_button = true,
|
||||
aniskip_button_text = "You can skip by pressing %s",
|
||||
aniskip_button_key = DEFAULT_ANISKIP_BUTTON_KEY,
|
||||
aniskip_button_duration = 3,
|
||||
}
|
||||
|
||||
options_lib.read_options(opts, "subminer")
|
||||
|
||||
+116
-12
@@ -4,9 +4,12 @@ local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
||||
local OVERLAY_START_MAX_ATTEMPTS = 6
|
||||
local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2
|
||||
local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
|
||||
local OVERLAY_LOADING_OSD_PREFIX = "Overlay loading "
|
||||
local OVERLAY_LOADING_OSD_FRAMES = { "|", "/", "-", "\\" }
|
||||
local OVERLAY_LOADING_OSD_REFRESH_SECONDS = 0.18
|
||||
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
||||
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
||||
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
||||
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 30
|
||||
local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25
|
||||
|
||||
function M.create(ctx)
|
||||
@@ -53,6 +56,14 @@ function M.create(ctx)
|
||||
return options_helper.coerce_bool(raw_pause_until_ready, false)
|
||||
end
|
||||
|
||||
local function resolve_osd_messages_enabled()
|
||||
local raw_osd_messages = opts.osd_messages
|
||||
if raw_osd_messages == nil then
|
||||
raw_osd_messages = opts["osd-messages"]
|
||||
end
|
||||
return options_helper.coerce_bool(raw_osd_messages, false)
|
||||
end
|
||||
|
||||
local function resolve_pause_until_ready_owns_initial_pause()
|
||||
local raw_owns_initial_pause = opts.auto_start_pause_until_ready_owns_initial_pause
|
||||
if raw_owns_initial_pause == nil then
|
||||
@@ -246,6 +257,42 @@ function M.create(ctx)
|
||||
state.auto_play_ready_osd_timer = nil
|
||||
end
|
||||
|
||||
local function clear_overlay_loading_osd_timer()
|
||||
local timer = state.overlay_loading_osd_timer
|
||||
if timer and timer.kill then
|
||||
timer:kill()
|
||||
end
|
||||
state.overlay_loading_osd_timer = nil
|
||||
end
|
||||
|
||||
local function stop_overlay_loading_osd()
|
||||
state.overlay_loading_osd_active = false
|
||||
state.overlay_loading_osd_frame = 1
|
||||
clear_overlay_loading_osd_timer()
|
||||
end
|
||||
|
||||
local function start_overlay_loading_osd()
|
||||
if state.overlay_loading_osd_active then
|
||||
return
|
||||
end
|
||||
state.overlay_loading_osd_active = true
|
||||
state.overlay_loading_osd_frame = 1
|
||||
local function show_next_overlay_loading_frame()
|
||||
local frame_index = state.overlay_loading_osd_frame or 1
|
||||
local frame = OVERLAY_LOADING_OSD_FRAMES[frame_index] or OVERLAY_LOADING_OSD_FRAMES[1]
|
||||
show_osd(OVERLAY_LOADING_OSD_PREFIX .. frame, { force = true })
|
||||
state.overlay_loading_osd_frame = (frame_index % #OVERLAY_LOADING_OSD_FRAMES) + 1
|
||||
end
|
||||
show_next_overlay_loading_frame()
|
||||
if type(mp.add_periodic_timer) == "function" then
|
||||
state.overlay_loading_osd_timer = mp.add_periodic_timer(OVERLAY_LOADING_OSD_REFRESH_SECONDS, function()
|
||||
if state.overlay_loading_osd_active then
|
||||
show_next_overlay_loading_frame()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
local function disarm_auto_play_ready_gate(options)
|
||||
local should_resume = options == nil or options.resume_playback ~= false
|
||||
local was_armed = state.auto_play_ready_gate_armed
|
||||
@@ -264,8 +311,11 @@ function M.create(ctx)
|
||||
return false
|
||||
end
|
||||
local should_resume_playback = state.auto_play_ready_should_resume_playback == true
|
||||
if resolve_osd_messages_enabled() then
|
||||
stop_overlay_loading_osd()
|
||||
show_osd(AUTO_PLAY_READY_READY_OSD)
|
||||
end
|
||||
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||
show_osd(AUTO_PLAY_READY_READY_OSD)
|
||||
if should_resume_playback then
|
||||
mp.set_property_native("pause", false)
|
||||
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
|
||||
@@ -287,8 +337,11 @@ function M.create(ctx)
|
||||
end
|
||||
state.auto_play_ready_gate_armed = true
|
||||
mp.set_property_native("pause", true)
|
||||
show_osd(AUTO_PLAY_READY_LOADING_OSD)
|
||||
if type(mp.add_periodic_timer) == "function" then
|
||||
if resolve_osd_messages_enabled() then
|
||||
stop_overlay_loading_osd()
|
||||
show_osd(AUTO_PLAY_READY_LOADING_OSD)
|
||||
end
|
||||
if resolve_osd_messages_enabled() and type(mp.add_periodic_timer) == "function" then
|
||||
state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function()
|
||||
if state.auto_play_ready_gate_armed then
|
||||
show_osd(AUTO_PLAY_READY_LOADING_OSD)
|
||||
@@ -375,6 +428,9 @@ function M.create(ctx)
|
||||
table.insert(args, "--texthooker")
|
||||
end
|
||||
end
|
||||
if action == "playback-feedback" and type(overrides.message) == "string" and overrides.message ~= "" then
|
||||
table.insert(args, overrides.message)
|
||||
end
|
||||
|
||||
return args
|
||||
end
|
||||
@@ -462,6 +518,27 @@ function M.create(ctx)
|
||||
end)
|
||||
end
|
||||
|
||||
local function notify_playback_feedback(message, fallback)
|
||||
if type(message) ~= "string" or message == "" then
|
||||
return
|
||||
end
|
||||
if resolve_osd_messages_enabled() then
|
||||
show_osd(message)
|
||||
return
|
||||
end
|
||||
if not binary.ensure_binary_available() then
|
||||
if fallback then
|
||||
fallback()
|
||||
end
|
||||
return
|
||||
end
|
||||
run_control_command_async("playback-feedback", { message = message }, function(ok)
|
||||
if not ok and fallback then
|
||||
fallback()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt)
|
||||
attempt = attempt or 1
|
||||
run_control_command_async("app-ping", nil, function(_ok, result)
|
||||
@@ -543,6 +620,7 @@ function M.create(ctx)
|
||||
|
||||
if not binary.ensure_binary_available() then
|
||||
subminer_log("error", "binary", "SubMiner binary not found")
|
||||
stop_overlay_loading_osd()
|
||||
show_osd("Error: binary not found")
|
||||
return
|
||||
end
|
||||
@@ -627,6 +705,7 @@ function M.create(ctx)
|
||||
state.overlay_running = false
|
||||
state.auto_play_ready_signal_seen = false
|
||||
subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
|
||||
stop_overlay_loading_osd()
|
||||
show_osd("Overlay start failed")
|
||||
release_auto_play_ready_gate("overlay-start-failed")
|
||||
return
|
||||
@@ -679,6 +758,7 @@ function M.create(ctx)
|
||||
state.overlay_running = false
|
||||
state.texthooker_running = false
|
||||
state.auto_play_ready_signal_seen = false
|
||||
stop_overlay_loading_osd()
|
||||
disarm_auto_play_ready_gate()
|
||||
show_osd("Stopped")
|
||||
end
|
||||
@@ -690,6 +770,7 @@ function M.create(ctx)
|
||||
return
|
||||
end
|
||||
state.suppress_ready_overlay_restore = true
|
||||
stop_overlay_loading_osd()
|
||||
|
||||
run_control_command_async("hide-visible-overlay", nil, function(ok, result)
|
||||
if ok then
|
||||
@@ -794,14 +875,22 @@ function M.create(ctx)
|
||||
return
|
||||
end
|
||||
|
||||
local function show_restart_feedback(message)
|
||||
notify_playback_feedback(message, function()
|
||||
show_osd(message)
|
||||
end)
|
||||
end
|
||||
|
||||
start_overlay_loading_osd()
|
||||
subminer_log("info", "process", "Restarting overlay...")
|
||||
show_osd("Restarting...")
|
||||
show_restart_feedback("Restarting...")
|
||||
|
||||
run_control_command_async("stop", nil, function(ok, result)
|
||||
if not ok then
|
||||
local reason = result and result.stderr or "unknown error"
|
||||
subminer_log("warn", "process", "Restart stop command failed: " .. reason)
|
||||
show_osd("Restart failed")
|
||||
stop_overlay_loading_osd()
|
||||
show_restart_feedback("Restart failed")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -836,14 +925,25 @@ function M.create(ctx)
|
||||
"process",
|
||||
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
|
||||
)
|
||||
show_osd("Restart failed")
|
||||
stop_overlay_loading_osd()
|
||||
show_restart_feedback("Restart failed")
|
||||
else
|
||||
wait_for_app_ping_state(true, "own the single-instance lock", function()
|
||||
run_control_command_async("show-visible-overlay")
|
||||
show_osd("Restarted successfully")
|
||||
run_control_command_async("show-visible-overlay", nil, function(ok)
|
||||
if ok then
|
||||
show_restart_feedback("Restarted successfully")
|
||||
else
|
||||
show_restart_feedback("Restart failed")
|
||||
end
|
||||
end)
|
||||
end, function()
|
||||
run_control_command_async("show-visible-overlay")
|
||||
show_osd("Restarted successfully")
|
||||
run_control_command_async("show-visible-overlay", nil, function(ok)
|
||||
if ok then
|
||||
show_restart_feedback("Restarted successfully")
|
||||
else
|
||||
show_restart_feedback("Restart failed")
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end)
|
||||
@@ -852,7 +952,8 @@ function M.create(ctx)
|
||||
ensure_texthooker_running(function() end)
|
||||
end
|
||||
end, function()
|
||||
show_osd("Restart failed")
|
||||
stop_overlay_loading_osd()
|
||||
show_restart_feedback("Restart failed")
|
||||
end)
|
||||
end)
|
||||
end
|
||||
@@ -877,6 +978,7 @@ function M.create(ctx)
|
||||
describe_mpv_ipc_socket_match = describe_mpv_ipc_socket_match,
|
||||
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
|
||||
run_control_command_async = run_control_command_async,
|
||||
notify_playback_feedback = notify_playback_feedback,
|
||||
record_visible_overlay_visibility = record_visible_overlay_visibility,
|
||||
run_binary_command_async = run_binary_command_async,
|
||||
parse_start_script_message_overrides = parse_start_script_message_overrides,
|
||||
@@ -893,6 +995,8 @@ function M.create(ctx)
|
||||
check_binary_available = check_binary_available,
|
||||
notify_auto_play_ready = notify_auto_play_ready,
|
||||
disarm_auto_play_ready_gate = disarm_auto_play_ready_gate,
|
||||
start_overlay_loading_osd = start_overlay_loading_osd,
|
||||
stop_overlay_loading_osd = stop_overlay_loading_osd,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -244,6 +244,8 @@ function M.create(ctx)
|
||||
return { "--toggle-secondary-sub" }
|
||||
elseif action_id == "toggleSubtitleSidebar" then
|
||||
return { "--toggle-subtitle-sidebar" }
|
||||
elseif action_id == "toggleNotificationHistory" then
|
||||
return { "--session-action", '{"actionId":"toggleNotificationHistory"}' }
|
||||
elseif action_id == "markAudioCard" then
|
||||
return { "--mark-audio-card" }
|
||||
elseif action_id == "markWatched" then
|
||||
@@ -268,10 +270,6 @@ function M.create(ctx)
|
||||
return { "--replay-current-subtitle" }
|
||||
elseif action_id == "playNextSubtitle" then
|
||||
return { "--play-next-subtitle" }
|
||||
elseif action_id == "shiftSubDelayPrevLine" then
|
||||
return { "--shift-sub-delay-prev-line" }
|
||||
elseif action_id == "shiftSubDelayNextLine" then
|
||||
return { "--shift-sub-delay-next-line" }
|
||||
elseif action_id == "cycleRuntimeOption" then
|
||||
local runtime_option_id = payload and payload.runtimeOptionId or nil
|
||||
if type(runtime_option_id) ~= "string" or runtime_option_id == "" then
|
||||
@@ -348,6 +346,16 @@ function M.create(ctx)
|
||||
invoke_cli_action(binding.actionId, binding.payload, binding.cliArgs)
|
||||
end
|
||||
|
||||
local function is_supported_binding(binding)
|
||||
if binding.actionType == "mpv-command" then
|
||||
return type(binding.command) == "table" and binding.command[1] ~= nil
|
||||
end
|
||||
if binding.actionType == "session-action" then
|
||||
return build_cli_args(binding.actionId, binding.payload, binding.cliArgs) ~= nil
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function load_artifact()
|
||||
local artifact_path = environment.resolve_session_bindings_artifact_path()
|
||||
local raw = read_file(artifact_path)
|
||||
@@ -383,26 +391,34 @@ function M.create(ctx)
|
||||
local generation = state.session_binding_generation
|
||||
|
||||
for index, binding in ipairs(artifact.bindings) do
|
||||
local key_names = key_spec_to_mpv_bindings(binding.key)
|
||||
if key_names then
|
||||
for key_index, key_name in ipairs(key_names) do
|
||||
local name = "subminer-session-binding-"
|
||||
.. tostring(generation)
|
||||
.. "-"
|
||||
.. tostring(index)
|
||||
.. "-"
|
||||
.. tostring(key_index)
|
||||
next_binding_names[#next_binding_names + 1] = name
|
||||
mp.add_forced_key_binding(key_name, name, function()
|
||||
handle_binding(binding)
|
||||
end)
|
||||
end
|
||||
else
|
||||
if not is_supported_binding(binding) then
|
||||
subminer_log(
|
||||
"warn",
|
||||
"session-bindings",
|
||||
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown")
|
||||
"Skipped unsupported session binding from artifact"
|
||||
)
|
||||
else
|
||||
local key_names = key_spec_to_mpv_bindings(binding.key)
|
||||
if key_names then
|
||||
for key_index, key_name in ipairs(key_names) do
|
||||
local name = "subminer-session-binding-"
|
||||
.. tostring(generation)
|
||||
.. "-"
|
||||
.. tostring(index)
|
||||
.. "-"
|
||||
.. tostring(key_index)
|
||||
next_binding_names[#next_binding_names + 1] = name
|
||||
mp.add_forced_key_binding(key_name, name, function()
|
||||
handle_binding(binding)
|
||||
end)
|
||||
end
|
||||
else
|
||||
subminer_log(
|
||||
"warn",
|
||||
"session-bindings",
|
||||
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown")
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -18,23 +18,15 @@ function M.new()
|
||||
clear_timer = nil,
|
||||
last_hover_update_ts = 0,
|
||||
},
|
||||
aniskip = {
|
||||
mal_id = nil,
|
||||
title = nil,
|
||||
episode = nil,
|
||||
intro_start = nil,
|
||||
intro_end = nil,
|
||||
payload = nil,
|
||||
payload_source = nil,
|
||||
found = false,
|
||||
prompt_shown = false,
|
||||
},
|
||||
auto_play_ready_gate_armed = false,
|
||||
auto_play_ready_should_resume_playback = false,
|
||||
auto_play_ready_timeout = nil,
|
||||
auto_play_ready_osd_timer = nil,
|
||||
auto_play_ready_signal_seen = false,
|
||||
auto_play_ready_initial_pause_ownership_consumed = false,
|
||||
overlay_loading_osd_active = false,
|
||||
overlay_loading_osd_timer = nil,
|
||||
overlay_loading_osd_frame = 1,
|
||||
pending_visible_overlay_hide_timer = nil,
|
||||
pending_visible_overlay_hide_generation = 0,
|
||||
suppress_ready_overlay_restore = false,
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
local M = {}
|
||||
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
|
||||
local LEGACY_ANISKIP_BUTTON_KEY = "y-k"
|
||||
|
||||
function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
local input = ctx.input
|
||||
local opts = ctx.opts
|
||||
local process = ctx.process
|
||||
local aniskip = ctx.aniskip
|
||||
local subminer_log = ctx.log.subminer_log
|
||||
local show_osd = ctx.log.show_osd
|
||||
|
||||
@@ -99,19 +95,6 @@ function M.create(ctx)
|
||||
end
|
||||
process.run_control_command_async("open-session-help")
|
||||
end)
|
||||
if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then
|
||||
mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", function()
|
||||
aniskip.skip_intro_now()
|
||||
end)
|
||||
end
|
||||
if
|
||||
opts.aniskip_button_key ~= LEGACY_ANISKIP_BUTTON_KEY
|
||||
and opts.aniskip_button_key ~= DEFAULT_ANISKIP_BUTTON_KEY
|
||||
then
|
||||
mp.add_key_binding(LEGACY_ANISKIP_BUTTON_KEY, "subminer-skip-intro-fallback", function()
|
||||
aniskip.skip_intro_now()
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
## Highlights
|
||||
### Changed
|
||||
|
||||
- **Yomitan**: Updated the bundled Yomitan to the latest revision.
|
||||
- Picks up the newest lookup improvements and fixes from the SubMiner Yomitan fork.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Anki / Animated AVIF**: Clips with word audio no longer start or end early.
|
||||
- Clip boundaries are now snapped to the nearest AVIF frame edge, keeping audio lead-in and playback in sync.
|
||||
|
||||
- **macOS Overlay**: Resolved several interactivity and focus issues triggered by autoplay and modal windows.
|
||||
- After autoplay starts with "wait for overlay to be ready" enabled, subtitles are immediately hoverable and Yomitan lookups work - no longer require an extra click to activate.
|
||||
- After any modal closes (Settings, Stats, sidebar, etc.), the overlay and subtitles reappear automatically and mpv keyboard shortcuts (pause, seek, etc.) are restored to mpv right away, including in native fullscreen.
|
||||
|
||||
- **Hyprland Fullscreen Overlay**: Fixed overlay alignment when mpv is fullscreen on Hyprland.
|
||||
- Compositor client bounds are now verified before positioning, so the stats panel, modals, and subtitle sidebar no longer shift below the mpv window.
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -488,7 +488,7 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
||||
changedEntries: [{ path: 'src/main-entry.ts', status: 'M' }],
|
||||
changedLabels: [],
|
||||
}),
|
||||
/requires a changelog fragment/,
|
||||
/requires a reconciled changelog fragment/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
@@ -514,7 +514,7 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
||||
],
|
||||
changedLabels: [],
|
||||
}),
|
||||
/requires a changelog fragment/,
|
||||
/requires a reconciled changelog fragment/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
@@ -526,6 +526,27 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
||||
changedLabels: [],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
verifyPullRequestChangelog({
|
||||
changedEntries: [
|
||||
{ path: 'src/main-entry.ts', status: 'M' },
|
||||
{ path: 'changes/001.md', status: 'M' },
|
||||
],
|
||||
changedLabels: [],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
verifyPullRequestChangelog({
|
||||
changedEntries: [
|
||||
{ path: 'src/main-entry.ts', status: 'M' },
|
||||
{ path: 'changes/001.md', status: 'A' },
|
||||
{ path: 'changes/002.md', status: 'A' },
|
||||
],
|
||||
changedLabels: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => {
|
||||
@@ -1044,6 +1065,177 @@ test('writeChangelogArtifacts filters internal fragments from the release-notes
|
||||
}
|
||||
});
|
||||
|
||||
test('writeChangelogArtifacts appends contributor attribution and a new-contributors section to release notes', async () => {
|
||||
const { writeChangelogArtifacts } = await loadModule();
|
||||
const workspace = createWorkspace('release-notes-contributors');
|
||||
const projectRoot = path.join(workspace, 'SubMiner');
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'changes', '001.md'),
|
||||
['type: added', 'area: overlay', '', '- Added a feature.'].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'changes', '002.md'),
|
||||
['type: fixed', 'area: jellyfin', '', '- Fixed a bug.'].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
const stub = defaultStubClaude();
|
||||
const resolveContributionsCalls: string[][] = [];
|
||||
writeChangelogArtifacts({
|
||||
cwd: projectRoot,
|
||||
version: '0.6.0',
|
||||
date: '2026-05-06',
|
||||
deps: {
|
||||
runClaude: stub.runClaude,
|
||||
resolveContributions: (fragmentPaths) => {
|
||||
resolveContributionsCalls.push(fragmentPaths);
|
||||
return [
|
||||
{
|
||||
prNumber: 110,
|
||||
login: 'ksyasuda',
|
||||
title: 'feat(overlay): add a feature',
|
||||
isFirstContribution: false,
|
||||
},
|
||||
{
|
||||
prNumber: 112,
|
||||
login: 'bee-san',
|
||||
title: 'fix(jellyfin): restart remote session',
|
||||
isFirstContribution: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolveContributionsCalls.length, 1, 'resolves contributions once per release');
|
||||
assert.deepEqual(resolveContributionsCalls[0], [
|
||||
path.join(projectRoot, 'changes', '001.md'),
|
||||
path.join(projectRoot, 'changes', '002.md'),
|
||||
]);
|
||||
|
||||
const releaseNotes = fs.readFileSync(
|
||||
path.join(projectRoot, 'release', 'release-notes.md'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(releaseNotes, /## What's Changed\n\n/);
|
||||
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
|
||||
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
|
||||
assert.match(
|
||||
releaseNotes,
|
||||
/## New Contributors\n\n- @bee-san made their first contribution in #112/,
|
||||
);
|
||||
assert.ok(
|
||||
releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'),
|
||||
"What's Changed should follow Highlights",
|
||||
);
|
||||
assert.ok(
|
||||
releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'),
|
||||
'contributor attribution should appear before Installation',
|
||||
);
|
||||
assert.doesNotMatch(releaseNotes, /## What’s Changed/);
|
||||
assert.doesNotMatch(
|
||||
releaseNotes,
|
||||
/ksyasuda made their first contribution/,
|
||||
'returning contributors are not listed under New Contributors',
|
||||
);
|
||||
|
||||
// Attribution is a release-notes concern only; the CHANGELOG stays clean.
|
||||
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
|
||||
assert.doesNotMatch(changelog, /What's Changed|What’s Changed/);
|
||||
assert.doesNotMatch(changelog, /New Contributors/);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writeReleaseNotesForVersion preserves committed contributor attribution before installation', async () => {
|
||||
const { writeReleaseNotesForVersion } = await loadModule();
|
||||
const workspace = createWorkspace('release-notes-preserve-attribution');
|
||||
const projectRoot = path.join(workspace, 'SubMiner');
|
||||
const existingChangelog = [
|
||||
'# Changelog',
|
||||
'',
|
||||
'## v0.8.0 (2026-04-17)',
|
||||
'### Added',
|
||||
'- Polished: released feature.',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Internal changes</summary>',
|
||||
'',
|
||||
'### Internal',
|
||||
'- Polished: internal release note.',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
].join('\n');
|
||||
const committedReleaseNotes = [
|
||||
'## Highlights',
|
||||
'### Added',
|
||||
'- Old generated body.',
|
||||
'',
|
||||
'## Installation',
|
||||
'',
|
||||
'See the README and docs/installation guide for full setup steps.',
|
||||
'',
|
||||
'## Assets',
|
||||
'',
|
||||
'- Linux: `SubMiner.AppImage`',
|
||||
'',
|
||||
'## What’s Changed',
|
||||
'',
|
||||
'- feat(release): add contributor attribution by @ksyasuda in #114',
|
||||
'',
|
||||
'## New Contributors',
|
||||
'',
|
||||
'- @bee-san made their first contribution in #112',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'release', 'release-notes.md'),
|
||||
committedReleaseNotes,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
const outputPath = writeReleaseNotesForVersion({
|
||||
cwd: projectRoot,
|
||||
version: '0.8.0',
|
||||
});
|
||||
const releaseNotes = fs.readFileSync(outputPath, 'utf8');
|
||||
|
||||
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: released feature\./);
|
||||
assert.doesNotMatch(releaseNotes, /<details>/);
|
||||
assert.doesNotMatch(releaseNotes, /### Internal/);
|
||||
assert.match(
|
||||
releaseNotes,
|
||||
/## What's Changed\n\n- feat\(release\): add contributor attribution by @ksyasuda in #114/,
|
||||
);
|
||||
assert.match(
|
||||
releaseNotes,
|
||||
/## New Contributors\n\n- @bee-san made their first contribution in #112/,
|
||||
);
|
||||
assert.ok(
|
||||
releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'),
|
||||
"What's Changed should follow Highlights",
|
||||
);
|
||||
assert.ok(
|
||||
releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'),
|
||||
'New Contributors should appear before Installation',
|
||||
);
|
||||
assert.doesNotMatch(releaseNotes, /## What’s Changed/);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
|
||||
const { writeChangelogArtifacts } = await loadModule();
|
||||
const workspace = createWorkspace('reuse-existing-section');
|
||||
|
||||
+220
-4
@@ -4,6 +4,20 @@ import { execFileSync } from 'node:child_process';
|
||||
|
||||
type RunClaude = (input: string, args: string[]) => string;
|
||||
|
||||
// A single PR's contribution, resolved from the fragment files released in this
|
||||
// cycle. Used to append GitHub-style attribution to the release notes.
|
||||
type Contribution = {
|
||||
prNumber: number;
|
||||
login: string;
|
||||
title: string;
|
||||
isFirstContribution: boolean;
|
||||
};
|
||||
|
||||
// Resolves the contributions behind a set of changelog fragment paths. Injected
|
||||
// in tests so we never hit git/gh; the default implementation walks git history
|
||||
// and the GitHub API.
|
||||
type ResolveContributions = (fragmentPaths: string[], cwd: string) => Contribution[];
|
||||
|
||||
type ChangelogFsDeps = {
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
||||
@@ -13,6 +27,7 @@ type ChangelogFsDeps = {
|
||||
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
|
||||
log?: (message: string) => void;
|
||||
runClaude?: RunClaude;
|
||||
resolveContributions?: ResolveContributions;
|
||||
};
|
||||
|
||||
type PolishMode = 'changelog' | 'release-notes';
|
||||
@@ -296,6 +311,185 @@ function defaultRunClaude(input: string, args: string[]): string {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFragmentRelativePath(fragmentPath: string, cwd: string): string {
|
||||
return path.relative(cwd, fragmentPath).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
// Walks git history + the GitHub API to attribute each released fragment to the
|
||||
// PR (and author) that introduced it. One git call and one gh call per fragment,
|
||||
// plus one gh call per unique author for the first-contribution check. Best
|
||||
// effort: if gh is unavailable/unauthenticated or any lookup fails, we warn and
|
||||
// drop attribution rather than failing the release.
|
||||
function defaultResolveContributions(fragmentPaths: string[], cwd: string): Contribution[] {
|
||||
if (fragmentPaths.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const slug = execFileSync(
|
||||
'gh',
|
||||
['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'],
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
).trim();
|
||||
if (!slug) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const byPr = new Map<number, Contribution>();
|
||||
for (const fragmentPath of fragmentPaths) {
|
||||
const relativePath = resolveFragmentRelativePath(fragmentPath, cwd);
|
||||
// git log lists newest first, so the commit that *added* the file is the
|
||||
// last line of the --diff-filter=A history.
|
||||
const addingSha = execFileSync(
|
||||
'git',
|
||||
['log', '--diff-filter=A', '--follow', '--format=%H', '--', relativePath],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
)
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
if (!addingSha) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prRaw = execFileSync(
|
||||
'gh',
|
||||
[
|
||||
'api',
|
||||
`repos/${slug}/commits/${addingSha}/pulls`,
|
||||
'--jq',
|
||||
'.[0] // empty | {number, login: .user.login, title}',
|
||||
],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
).trim();
|
||||
if (!prRaw) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pr = JSON.parse(prRaw) as { number?: number; login?: string; title?: string };
|
||||
if (typeof pr.number !== 'number' || !pr.login || !pr.title) {
|
||||
continue;
|
||||
}
|
||||
if (!byPr.has(pr.number)) {
|
||||
byPr.set(pr.number, {
|
||||
prNumber: pr.number,
|
||||
login: pr.login,
|
||||
title: pr.title,
|
||||
isFirstContribution: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const firstPrByAuthor = new Map<string, number | null>();
|
||||
for (const contribution of byPr.values()) {
|
||||
if (!firstPrByAuthor.has(contribution.login)) {
|
||||
const firstRaw = execFileSync(
|
||||
'gh',
|
||||
[
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
'search/issues',
|
||||
'-f',
|
||||
`q=repo:${slug} is:pr is:merged author:${contribution.login}`,
|
||||
'-f',
|
||||
'sort=created',
|
||||
'-f',
|
||||
'order=asc',
|
||||
'-f',
|
||||
'per_page=1',
|
||||
'--jq',
|
||||
'.items[0].number // empty',
|
||||
],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
).trim();
|
||||
firstPrByAuthor.set(contribution.login, firstRaw ? Number.parseInt(firstRaw, 10) : null);
|
||||
}
|
||||
const firstPr = firstPrByAuthor.get(contribution.login) ?? null;
|
||||
contribution.isFirstContribution = firstPr !== null && firstPr === contribution.prNumber;
|
||||
}
|
||||
|
||||
return [...byPr.values()].sort((a, b) => a.prNumber - b.prNumber);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Skipping contributor attribution: ${message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolveContributionsForFragments(
|
||||
fragments: ChangeFragment[],
|
||||
cwd: string,
|
||||
deps?: ChangelogFsDeps,
|
||||
): Contribution[] {
|
||||
const resolve = deps?.resolveContributions ?? defaultResolveContributions;
|
||||
return resolve(
|
||||
fragments.filter((fragment) => fragment.type !== 'internal').map((fragment) => fragment.path),
|
||||
cwd,
|
||||
);
|
||||
}
|
||||
|
||||
function isWhatsChangedHeading(line: string): boolean {
|
||||
return line === "## What's Changed" || line === '## What’s Changed';
|
||||
}
|
||||
|
||||
function extractContributorSections(releaseNotes: string): string[] {
|
||||
const lines = releaseNotes.split(/\r?\n/);
|
||||
const start = lines.findIndex(isWhatsChangedHeading);
|
||||
if (start === -1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let end = lines.length;
|
||||
for (let index = start + 1; index < lines.length; index += 1) {
|
||||
const line = lines[index]!;
|
||||
if (line.startsWith('## ') && !isWhatsChangedHeading(line) && line !== '## New Contributors') {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const block = lines.slice(start, end);
|
||||
while (block.length > 0 && block[block.length - 1] === '') {
|
||||
block.pop();
|
||||
}
|
||||
if (block.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
block[0] = "## What's Changed";
|
||||
block.push('');
|
||||
return block;
|
||||
}
|
||||
|
||||
function renderContributorsSections(contributions: Contribution[]): string[] {
|
||||
if (contributions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines: string[] = ["## What's Changed", ''];
|
||||
for (const contribution of contributions) {
|
||||
lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`);
|
||||
}
|
||||
|
||||
const firstTimers = contributions.filter((contribution) => contribution.isFirstContribution);
|
||||
if (firstTimers.length > 0) {
|
||||
lines.push('', '## New Contributors', '');
|
||||
for (const contribution of firstTimers) {
|
||||
lines.push(
|
||||
`- @${contribution.login} made their first contribution in #${contribution.prNumber}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines;
|
||||
}
|
||||
|
||||
function serializeFragmentsForPrompt(
|
||||
fragments: ChangeFragment[],
|
||||
mode: PolishMode,
|
||||
@@ -473,14 +667,19 @@ function renderReleaseNotes(
|
||||
changes: string,
|
||||
options?: {
|
||||
disclaimer?: string;
|
||||
contributions?: Contribution[];
|
||||
contributorSections?: string[];
|
||||
},
|
||||
): string {
|
||||
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
||||
const contributorSections =
|
||||
options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []);
|
||||
return [
|
||||
...prefix,
|
||||
'## Highlights',
|
||||
changes,
|
||||
'',
|
||||
...contributorSections,
|
||||
'## Installation',
|
||||
'',
|
||||
'See the README and docs/installation guide for full setup steps.',
|
||||
@@ -504,6 +703,8 @@ function writeReleaseNotesFile(
|
||||
options?: {
|
||||
disclaimer?: string;
|
||||
outputPath?: string;
|
||||
contributions?: Contribution[];
|
||||
contributorSections?: string[];
|
||||
},
|
||||
): string {
|
||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||
@@ -530,6 +731,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
||||
const version = resolveVersion(options ?? {});
|
||||
const date = resolveDate(options?.date);
|
||||
const fragments = readChangeFragments(cwd, options?.deps);
|
||||
const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps);
|
||||
const existingChangelogPath = path.join(cwd, 'CHANGELOG.md');
|
||||
const existingChangelog = existsSync(existingChangelogPath)
|
||||
? readFileSync(existingChangelogPath, 'utf8')
|
||||
@@ -547,6 +749,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
||||
cwd,
|
||||
stripDetailsBlocks(existingReleaseSection),
|
||||
options?.deps,
|
||||
{ contributions },
|
||||
);
|
||||
log(`Generated ${releaseNotesPath}`);
|
||||
|
||||
@@ -572,7 +775,9 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
||||
date,
|
||||
deps: options?.deps,
|
||||
});
|
||||
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps);
|
||||
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps, {
|
||||
contributions,
|
||||
});
|
||||
log(`Generated ${releaseNotesPath}`);
|
||||
|
||||
for (const fragment of fragments) {
|
||||
@@ -661,14 +866,15 @@ export function verifyPullRequestChangelog(options: PullRequestChangelogOptions)
|
||||
return;
|
||||
}
|
||||
|
||||
const hasFragment = normalizedEntries.some(
|
||||
const fragmentEntries = normalizedEntries.filter(
|
||||
(entry) => entry.status !== 'D' && isFragmentPath(entry.path),
|
||||
);
|
||||
const hasFragment = fragmentEntries.length > 0;
|
||||
const requiresFragment = normalizedEntries.some((entry) => !isIgnoredPullRequestPath(entry.path));
|
||||
|
||||
if (requiresFragment && !hasFragment) {
|
||||
throw new Error(
|
||||
`This pull request changes release-relevant files and requires a changelog fragment under changes/ or the ${SKIP_CHANGELOG_LABEL} label.`,
|
||||
`This pull request changes release-relevant files and requires a reconciled changelog fragment under changes/ or the ${SKIP_CHANGELOG_LABEL} label. Before adding a new fragment, update the existing PR fragment when the new work modifies, fixes, or supersedes behavior already described there.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -791,6 +997,7 @@ export function generateDocsChangelog(options?: Pick<ChangelogOptions, 'cwd' | '
|
||||
|
||||
export function writeReleaseNotesForVersion(options?: ChangelogOptions): string {
|
||||
const cwd = options?.cwd ?? process.cwd();
|
||||
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
|
||||
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
|
||||
const version = resolveVersion(options ?? {});
|
||||
const changelogPath = path.join(cwd, 'CHANGELOG.md');
|
||||
@@ -801,7 +1008,14 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
|
||||
throw new Error(`Missing CHANGELOG section for v${version}.`);
|
||||
}
|
||||
|
||||
return writeReleaseNotesFile(cwd, stripDetailsBlocks(changes), options?.deps);
|
||||
const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH);
|
||||
const contributorSections = existsSync(releaseNotesPath)
|
||||
? extractContributorSections(readFileSync(releaseNotesPath, 'utf8'))
|
||||
: [];
|
||||
|
||||
return writeReleaseNotesFile(cwd, stripDetailsBlocks(changes), options?.deps, {
|
||||
contributorSections,
|
||||
});
|
||||
}
|
||||
|
||||
export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {
|
||||
@@ -832,10 +1046,12 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
|
||||
existingReleaseNotes,
|
||||
deps: options?.deps,
|
||||
});
|
||||
const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps);
|
||||
return writeReleaseNotesFile(cwd, changes, options?.deps, {
|
||||
disclaimer:
|
||||
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||
outputPath: PRERELEASE_NOTES_PATH,
|
||||
contributions,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package.path = "plugin/subminer/?.lua;" .. package.path
|
||||
|
||||
local process_module = require("process")
|
||||
local options_helper = require("options")
|
||||
|
||||
local function assert_true(condition, message)
|
||||
if condition then
|
||||
return
|
||||
end
|
||||
error(message or "assert_true failed")
|
||||
end
|
||||
|
||||
local function has_arg(args, target)
|
||||
for _, value in ipairs(args or {}) do
|
||||
if value == target then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function count_feedback(recorded, target)
|
||||
local count = 0
|
||||
for _, message in ipairs(recorded.feedback) do
|
||||
if message == target then
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
local function create_restart_runtime(config)
|
||||
config = config or {}
|
||||
local recorded = {
|
||||
async_calls = {},
|
||||
feedback = {},
|
||||
osd = {},
|
||||
periodic_timers = {},
|
||||
}
|
||||
local app_ping_index = 0
|
||||
local opts = {
|
||||
binary_path = "/tmp/SubMiner",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
backend = "x11",
|
||||
osd_messages = config.osd_messages == true,
|
||||
texthooker_enabled = false,
|
||||
log_level = "info",
|
||||
}
|
||||
local state = {
|
||||
binary_path = opts.binary_path,
|
||||
overlay_running = true,
|
||||
texthooker_running = false,
|
||||
}
|
||||
|
||||
local mp = {}
|
||||
|
||||
function mp.command_native_async(command, callback)
|
||||
recorded.async_calls[#recorded.async_calls + 1] = command
|
||||
local args = command.args or {}
|
||||
if has_arg(args, "--playback-feedback") then
|
||||
recorded.feedback[#recorded.feedback + 1] = args[#args]
|
||||
callback(true, { status = 0, stdout = "", stderr = "" }, nil)
|
||||
return
|
||||
end
|
||||
if has_arg(args, "--app-ping") then
|
||||
app_ping_index = app_ping_index + 1
|
||||
local statuses = config.app_ping_statuses or { 1, 0 }
|
||||
local status = statuses[app_ping_index] or statuses[#statuses]
|
||||
callback(status == 0, { status = status, stdout = "", stderr = "" }, nil)
|
||||
return
|
||||
end
|
||||
if has_arg(args, "--show-visible-overlay") and not has_arg(args, "--start") then
|
||||
local status = config.show_visible_overlay_status or 0
|
||||
callback(status == 0, { status = status, stdout = "", stderr = "" }, nil)
|
||||
return
|
||||
end
|
||||
callback(true, { status = 0, stdout = "", stderr = "" }, nil)
|
||||
end
|
||||
|
||||
function mp.add_timeout(_, callback)
|
||||
if config.run_timeouts_immediately and callback then
|
||||
callback()
|
||||
end
|
||||
return {
|
||||
killed = false,
|
||||
kill = function(self)
|
||||
self.killed = true
|
||||
end,
|
||||
callback = callback,
|
||||
}
|
||||
end
|
||||
|
||||
function mp.add_periodic_timer()
|
||||
local timer = {
|
||||
killed = false,
|
||||
kill = function(self)
|
||||
self.killed = true
|
||||
end,
|
||||
}
|
||||
recorded.periodic_timers[#recorded.periodic_timers + 1] = timer
|
||||
return timer
|
||||
end
|
||||
|
||||
function mp.get_property(name)
|
||||
if name == "input-ipc-server" then
|
||||
return opts.socket_path
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
function mp.get_time()
|
||||
return 1
|
||||
end
|
||||
|
||||
function mp.set_property_native() end
|
||||
|
||||
local process = process_module.create({
|
||||
mp = mp,
|
||||
utils = {},
|
||||
opts = opts,
|
||||
state = state,
|
||||
binary = {
|
||||
ensure_binary_available = function()
|
||||
return true
|
||||
end,
|
||||
},
|
||||
environment = {
|
||||
is_linux = function()
|
||||
return false
|
||||
end,
|
||||
detect_backend = function()
|
||||
return "x11"
|
||||
end,
|
||||
resolve_subminer_config_dir = function()
|
||||
return "/tmp"
|
||||
end,
|
||||
join_path = function(...)
|
||||
return table.concat({ ... }, "/")
|
||||
end,
|
||||
},
|
||||
options_helper = options_helper,
|
||||
log = {
|
||||
normalize_log_level = function(level)
|
||||
return level or "info"
|
||||
end,
|
||||
subminer_log = function() end,
|
||||
show_osd = function(message, options)
|
||||
if opts.osd_messages or (options and options.force == true) then
|
||||
recorded.osd[#recorded.osd + 1] = message
|
||||
end
|
||||
end,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
process = process,
|
||||
recorded = recorded,
|
||||
}
|
||||
end
|
||||
|
||||
do
|
||||
local runtime = create_restart_runtime({ osd_messages = false })
|
||||
|
||||
runtime.process.restart_overlay()
|
||||
|
||||
assert_true(
|
||||
runtime.recorded.osd[1] == "Overlay loading |",
|
||||
"restart should show the forced overlay loading OSD while the overlay reloads"
|
||||
)
|
||||
assert_true(
|
||||
#runtime.recorded.periodic_timers == 1,
|
||||
"restart should refresh the forced overlay loading OSD while the overlay reloads"
|
||||
)
|
||||
assert_true(
|
||||
runtime.recorded.feedback[1] == "Restarting...",
|
||||
"restart should route progress through playback feedback"
|
||||
)
|
||||
assert_true(
|
||||
runtime.recorded.feedback[#runtime.recorded.feedback] == "Restarted successfully",
|
||||
"restart should route success through playback feedback"
|
||||
)
|
||||
assert_true(
|
||||
runtime.recorded.periodic_timers[1].killed ~= true,
|
||||
"restart should keep the loading OSD alive until the overlay reports ready"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local runtime = create_restart_runtime({
|
||||
osd_messages = false,
|
||||
show_visible_overlay_status = 1,
|
||||
})
|
||||
|
||||
runtime.process.restart_overlay()
|
||||
|
||||
assert_true(
|
||||
count_feedback(runtime.recorded, "Restarted successfully") == 0,
|
||||
"restart should not show success feedback when show-visible-overlay fails after ready ping"
|
||||
)
|
||||
assert_true(
|
||||
runtime.recorded.feedback[#runtime.recorded.feedback] == "Restart failed",
|
||||
"restart should show failure feedback when show-visible-overlay fails after ready ping"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local statuses = { 1 }
|
||||
for _ = 1, 20 do
|
||||
statuses[#statuses + 1] = 1
|
||||
end
|
||||
local runtime = create_restart_runtime({
|
||||
app_ping_statuses = statuses,
|
||||
osd_messages = false,
|
||||
run_timeouts_immediately = true,
|
||||
show_visible_overlay_status = 1,
|
||||
})
|
||||
|
||||
runtime.process.restart_overlay()
|
||||
|
||||
assert_true(
|
||||
count_feedback(runtime.recorded, "Restarted successfully") == 0,
|
||||
"restart should not show success feedback when fallback show-visible-overlay fails after ping timeout"
|
||||
)
|
||||
assert_true(
|
||||
runtime.recorded.feedback[#runtime.recorded.feedback] == "Restart failed",
|
||||
"restart should show failure feedback when fallback show-visible-overlay fails after ping timeout"
|
||||
)
|
||||
end
|
||||
|
||||
print("plugin restart feedback tests: OK")
|
||||
@@ -165,6 +165,46 @@ local ctx = {
|
||||
actionType = "mpv-command",
|
||||
command = { "sub-seek", 1 },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "ArrowLeft",
|
||||
modifiers = { "ctrl", "shift" },
|
||||
},
|
||||
actionType = "mpv-command",
|
||||
command = { "sub-step", -1 },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "ArrowRight",
|
||||
modifiers = { "ctrl", "shift" },
|
||||
},
|
||||
actionType = "mpv-command",
|
||||
command = { "sub-step", 1 },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "KeyZ",
|
||||
modifiers = {},
|
||||
},
|
||||
actionType = "mpv-command",
|
||||
command = { "add", "sub-delay", -0.1 },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "KeyZ",
|
||||
modifiers = { "shift" },
|
||||
},
|
||||
actionType = "mpv-command",
|
||||
command = { "add", "sub-delay", 0.1 },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "KeyX",
|
||||
modifiers = {},
|
||||
},
|
||||
actionType = "mpv-command",
|
||||
command = { "add", "sub-delay", 0.1 },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "BracketRight",
|
||||
@@ -323,6 +363,11 @@ local expected_mpv_bindings = {
|
||||
{ keys = "DOWN", command = { "seek", -60 } },
|
||||
{ keys = "H", command = { "sub-seek", -1 } },
|
||||
{ keys = "L", command = { "sub-seek", 1 } },
|
||||
{ keys = "Ctrl+Shift+LEFT", command = { "sub-step", -1 } },
|
||||
{ keys = "Ctrl+Shift+RIGHT", command = { "sub-step", 1 } },
|
||||
{ keys = "z", command = { "add", "sub-delay", -0.1 } },
|
||||
{ keys = "Z", command = { "add", "sub-delay", 0.1 } },
|
||||
{ keys = "x", command = { "add", "sub-delay", 0.1 } },
|
||||
{ keys = "q", command = { "quit" } },
|
||||
{ keys = "Ctrl+w", command = { "quit" } },
|
||||
{ keys = "MBTN_BACK", command = { "sub-seek", -1 } },
|
||||
@@ -340,10 +385,6 @@ for _, expected in ipairs(expected_mpv_bindings) do
|
||||
end
|
||||
|
||||
local expected_cli_bindings = {
|
||||
{ keys = "Shift+]", flag = "--shift-sub-delay-next-line" },
|
||||
{ keys = "}", flag = "--shift-sub-delay-next-line" },
|
||||
{ keys = "Shift+[", flag = "--shift-sub-delay-prev-line" },
|
||||
{ keys = "{", flag = "--shift-sub-delay-prev-line" },
|
||||
{ keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" },
|
||||
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
|
||||
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
|
||||
@@ -365,6 +406,9 @@ for _, expected in ipairs(expected_cli_bindings) do
|
||||
assert_true(cli_call[2] == expected.flag, "default session action should pass " .. expected.flag)
|
||||
end
|
||||
|
||||
assert_true(find_binding("Shift+]") == nil, "retired subtitle delay action should not register Shift+]")
|
||||
assert_true(find_binding("Shift+[") == nil, "retired subtitle delay action should not register Shift+[")
|
||||
|
||||
local play_next = find_binding("Ctrl+L")
|
||||
assert_true(play_next ~= nil, "play-next subtitle binding should use mpv shifted-letter form")
|
||||
|
||||
|
||||
+144
-164
@@ -87,13 +87,6 @@ local function run_plugin_scenario(config)
|
||||
}
|
||||
end
|
||||
if args[1] == "curl" then
|
||||
local url = args[#args] or ""
|
||||
if type(url) == "string" and url:find("myanimelist", 1, true) then
|
||||
return { status = 0, stdout = config.mal_lookup_stdout or "{}", stderr = "" }
|
||||
end
|
||||
if type(url) == "string" and url:find("api.aniskip.com", 1, true) then
|
||||
return { status = 0, stdout = config.aniskip_stdout or "{}", stderr = "" }
|
||||
end
|
||||
return { status = 0, stdout = "{}", stderr = "" }
|
||||
end
|
||||
return { status = 0, stdout = "", stderr = "" }
|
||||
@@ -108,15 +101,8 @@ local function run_plugin_scenario(config)
|
||||
return
|
||||
end
|
||||
if args[1] == "curl" then
|
||||
local url = args[#args] or ""
|
||||
if type(url) == "string" and url:find("myanimelist", 1, true) then
|
||||
callback(true, { status = 0, stdout = config.mal_lookup_stdout or "{}", stderr = "" }, nil)
|
||||
return
|
||||
end
|
||||
if type(url) == "string" and url:find("api.aniskip.com", 1, true) then
|
||||
callback(true, { status = 0, stdout = config.aniskip_stdout or "{}", stderr = "" }, nil)
|
||||
return
|
||||
end
|
||||
callback(true, { status = 0, stdout = "{}", stderr = "" }, nil)
|
||||
return
|
||||
end
|
||||
for _, value in ipairs(args) do
|
||||
if value == "--app-ping" then
|
||||
@@ -263,34 +249,6 @@ local function run_plugin_scenario(config)
|
||||
amount = 125,
|
||||
}, nil
|
||||
end
|
||||
if json == "__MAL_FOUND__" then
|
||||
return {
|
||||
categories = {
|
||||
{
|
||||
items = {
|
||||
{
|
||||
id = 99,
|
||||
name = "Sample Show",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
end
|
||||
if json == "__ANISKIP_FOUND__" then
|
||||
return {
|
||||
found = true,
|
||||
results = {
|
||||
{
|
||||
skip_type = "op",
|
||||
interval = {
|
||||
start_time = 12.3,
|
||||
end_time = 45.6,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
end
|
||||
return {}, nil
|
||||
end
|
||||
|
||||
@@ -311,7 +269,6 @@ local function run_plugin_scenario(config)
|
||||
package.loaded["process"] = nil
|
||||
package.loaded["state"] = nil
|
||||
package.loaded["ui"] = nil
|
||||
package.loaded["aniskip"] = nil
|
||||
_G.__subminer_plugin_bootstrapped = nil
|
||||
local original_package_config = package.config
|
||||
if config.platform == "windows" then
|
||||
@@ -505,33 +462,6 @@ local function has_async_command(async_calls, executable)
|
||||
return false
|
||||
end
|
||||
|
||||
local function has_async_curl_for(async_calls, needle)
|
||||
for _, call in ipairs(async_calls) do
|
||||
local args = call.args or {}
|
||||
if args[1] == "curl" then
|
||||
local url = args[#args] or ""
|
||||
if type(url) == "string" and url:find(needle, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function count_async_curl_for(async_calls, needle)
|
||||
local count = 0
|
||||
for _, call in ipairs(async_calls) do
|
||||
local args = call.args or {}
|
||||
if args[1] == "curl" then
|
||||
local url = args[#args] or ""
|
||||
if type(url) == "string" and url:find(needle, 1, true) then
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
local function has_property_set(property_sets, name, value)
|
||||
for _, call in ipairs(property_sets) do
|
||||
if call.name == name and call.value == value then
|
||||
@@ -631,15 +561,6 @@ local function fire_observer(recorded, name, value)
|
||||
end
|
||||
end
|
||||
|
||||
local function has_key_binding(recorded, keys, name)
|
||||
for _, binding in ipairs(recorded.key_bindings or {}) do
|
||||
if binding.keys == keys and binding.name == name then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local binary_path = "/tmp/subminer-binary"
|
||||
local appimage_path = "/tmp/SubMiner.AppImage"
|
||||
|
||||
@@ -979,6 +900,31 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
auto_start_visible_overlay = "yes",
|
||||
overlay_loading_osd = "yes",
|
||||
osd_messages = false,
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for explicit early overlay loading OSD scenario: " .. tostring(err))
|
||||
fire_event(recorded, "start-file")
|
||||
assert_true(
|
||||
has_osd_message(recorded.osd, "SubMiner: Overlay loading |"),
|
||||
"explicit overlay loading OSD option should show spinner even when plugin auto-start is disabled"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
@@ -1325,7 +1271,6 @@ do
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
aniskip_enabled = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
@@ -1367,7 +1312,6 @@ do
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
aniskip_enabled = "yes",
|
||||
},
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
@@ -1404,14 +1348,11 @@ do
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
aniskip_enabled = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
path = media_path,
|
||||
media_title = "Sample Show S01E01",
|
||||
mal_lookup_stdout = "__MAL_FOUND__",
|
||||
aniskip_stdout = "__ANISKIP_FOUND__",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
@@ -1429,10 +1370,6 @@ do
|
||||
count_property_set(recorded.property_sets, "pause", true) == 1,
|
||||
"same-media reload should not re-arm pause-until-ready"
|
||||
)
|
||||
assert_true(
|
||||
count_async_curl_for(recorded.async_calls, "api.aniskip.com") == 1,
|
||||
"same-media reload should not repeat AniSkip lookup"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
@@ -1535,7 +1472,6 @@ do
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
aniskip_enabled = "yes",
|
||||
},
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
@@ -1545,14 +1481,10 @@ do
|
||||
assert_true(recorded ~= nil, "plugin failed to load for non-subminer file-load scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(not has_sync_command(recorded.sync_calls, "ps"), "file-loaded should avoid synchronous process checks")
|
||||
assert_true(not has_sync_command(recorded.sync_calls, "curl"), "file-loaded should avoid synchronous AniSkip network calls")
|
||||
assert_true(not has_sync_command(recorded.sync_calls, "curl"), "file-loaded should not perform synchronous network calls")
|
||||
assert_true(
|
||||
not has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
|
||||
"file-loaded without SubMiner context should skip AniSkip MAL lookup"
|
||||
)
|
||||
assert_true(
|
||||
not has_async_curl_for(recorded.async_calls, "api.aniskip.com"),
|
||||
"file-loaded without SubMiner context should skip AniSkip API lookup"
|
||||
not has_async_command(recorded.async_calls, "curl"),
|
||||
"file-loaded should not perform plugin-side AniSkip lookups (AniSkip now lives in the app)"
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1574,75 +1506,12 @@ do
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for URL overlay-start AniSkip scenario: " .. tostring(err))
|
||||
assert_true(recorded ~= nil, "plugin failed to load for URL overlay-start scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(find_start_call(recorded.async_calls) ~= nil, "URL auto-start should still invoke --start command")
|
||||
assert_true(
|
||||
not has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
|
||||
"URL playback should skip AniSkip MAL lookup even after overlay-start"
|
||||
)
|
||||
assert_true(
|
||||
not has_async_curl_for(recorded.async_calls, "api.aniskip.com"),
|
||||
"URL playback should skip AniSkip API lookup even after overlay-start"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
aniskip_enabled = "yes",
|
||||
},
|
||||
media_title = "Sample Show S01E01",
|
||||
mal_lookup_stdout = "__MAL_FOUND__",
|
||||
aniskip_stdout = "__ANISKIP_FOUND__",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for script-message AniSkip scenario: " .. tostring(err))
|
||||
assert_true(recorded.script_messages["subminer-aniskip-refresh"] ~= nil, "subminer-aniskip-refresh script message not registered")
|
||||
recorded.script_messages["subminer-aniskip-refresh"]()
|
||||
assert_true(not has_sync_command(recorded.sync_calls, "curl"), "AniSkip refresh should not perform synchronous curl calls")
|
||||
assert_true(has_async_command(recorded.async_calls, "curl"), "AniSkip refresh should perform async curl calls")
|
||||
assert_true(
|
||||
has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
|
||||
"AniSkip refresh should perform MAL lookup even when app is not running"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
aniskip_enabled = "yes",
|
||||
},
|
||||
media_title = "Sample Show S01E01",
|
||||
time_pos = 13,
|
||||
mal_lookup_stdout = "__MAL_FOUND__",
|
||||
aniskip_stdout = "__ANISKIP_FOUND__",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for default AniSkip keybinding scenario: " .. tostring(err))
|
||||
assert_true(
|
||||
has_key_binding(recorded, "TAB", "subminer-skip-intro"),
|
||||
"default AniSkip keybinding should register TAB"
|
||||
)
|
||||
assert_true(
|
||||
not has_key_binding(recorded, "y-k", "subminer-skip-intro-fallback"),
|
||||
"default AniSkip keybinding should not also register legacy y-k fallback"
|
||||
)
|
||||
recorded.script_messages["subminer-aniskip-refresh"]()
|
||||
fire_observer(recorded, "time-pos", 13)
|
||||
assert_true(
|
||||
has_osd_message(recorded.osd, "You can skip by pressing TAB"),
|
||||
"default AniSkip prompt should mention TAB"
|
||||
not has_async_command(recorded.async_calls, "curl"),
|
||||
"URL playback should not trigger plugin-side network lookups"
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1695,6 +1564,91 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
osd_messages = false,
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for early overlay loading OSD scenario: " .. tostring(err))
|
||||
fire_event(recorded, "start-file")
|
||||
assert_true(
|
||||
has_osd_message(recorded.osd, "SubMiner: Overlay loading |"),
|
||||
"auto-start visible overlay should force overlay loading OSD spinner on start-file"
|
||||
)
|
||||
assert_true(
|
||||
#recorded.periodic_timers == 1,
|
||||
"auto-start visible overlay should refresh the early overlay loading OSD"
|
||||
)
|
||||
local overlay_loading_timer = recorded.periodic_timers[1]
|
||||
recorded.periodic_timers[1].callback()
|
||||
assert_true(
|
||||
has_osd_message(recorded.osd, "SubMiner: Overlay loading /"),
|
||||
"auto-start visible overlay should advance the early overlay loading OSD spinner"
|
||||
)
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(
|
||||
overlay_loading_timer.killed ~= true,
|
||||
"autoplay gate should keep forced overlay loading OSD alive while normal plugin OSD messages are disabled"
|
||||
)
|
||||
assert_true(
|
||||
#recorded.periodic_timers == 1,
|
||||
"autoplay gate should not replace forced overlay loading OSD with a suppressed tokenization OSD timer"
|
||||
)
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
assert_true(
|
||||
overlay_loading_timer.killed ~= true,
|
||||
"autoplay readiness should not stop forced overlay loading OSD before overlay content is ready"
|
||||
)
|
||||
overlay_loading_timer.callback()
|
||||
assert_true(
|
||||
has_osd_message(recorded.osd, "SubMiner: Overlay loading -"),
|
||||
"forced overlay loading OSD should keep spinning during the overlay startup gap"
|
||||
)
|
||||
assert_true(
|
||||
recorded.script_messages["subminer-overlay-loading-ready"] ~= nil,
|
||||
"overlay loading ready script message should be registered"
|
||||
)
|
||||
recorded.script_messages["subminer-overlay-loading-ready"]()
|
||||
assert_true(
|
||||
recorded.periodic_timers[1].killed == true,
|
||||
"overlay loading ready should stop the early overlay loading OSD refresher"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "no",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for hidden overlay loading OSD scenario: " .. tostring(err))
|
||||
fire_event(recorded, "start-file")
|
||||
assert_true(
|
||||
not has_osd_message(recorded.osd, "SubMiner: Overlay loading |"),
|
||||
"auto-start hidden visible overlay should not show early overlay loading OSD"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
@@ -1915,6 +1869,32 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
auto_start_pause_until_ready_owns_initial_pause = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
paused = true,
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for default pause timeout scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(
|
||||
has_timeout(recorded.timeouts, 30),
|
||||
"pause-until-ready default timeout should give cold app startup 30 seconds"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
|
||||
@@ -87,6 +87,25 @@ test('AnkiConnectClient lists decks and note type fields', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient opens a note in the Anki browser', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
};
|
||||
const calls: Array<{ action: string; params: unknown }> = [];
|
||||
client.client = {
|
||||
post: async (_url, body) => {
|
||||
calls.push({ action: body.action, params: body.params });
|
||||
return { data: { result: [], error: null } };
|
||||
},
|
||||
};
|
||||
|
||||
await (
|
||||
client as unknown as { openNoteInBrowser: (noteId: number) => Promise<void> }
|
||||
).openNoteInBrowser(12345);
|
||||
|
||||
assert.deepEqual(calls, [{ action: 'guiBrowse', params: { query: 'nid:12345' } }]);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient derives field names from sampled notes in a deck', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
|
||||
@@ -156,6 +156,22 @@ export class AnkiConnectClient {
|
||||
return (result as number[]) || [];
|
||||
}
|
||||
|
||||
async findCards(query: string, options?: { maxRetries?: number }): Promise<number[]> {
|
||||
const result = await this.invoke('findCards', { query }, options);
|
||||
return (result as number[]) || [];
|
||||
}
|
||||
|
||||
async changeDeck(cardIds: number[], deckName: string): Promise<void> {
|
||||
if (cardIds.length === 0 || !deckName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.invoke('changeDeck', {
|
||||
cards: cardIds,
|
||||
deck: deckName,
|
||||
});
|
||||
}
|
||||
|
||||
async deckNames(): Promise<string[]> {
|
||||
const result = await this.invoke('deckNames');
|
||||
return Array.isArray(result)
|
||||
@@ -231,6 +247,13 @@ export class AnkiConnectClient {
|
||||
return (result as Record<string, unknown>[]) || [];
|
||||
}
|
||||
|
||||
async openNoteInBrowser(noteId: number): Promise<void> {
|
||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||
throw new Error('Invalid Anki note id');
|
||||
}
|
||||
await this.invoke('guiBrowse', { query: `nid:${noteId}` });
|
||||
}
|
||||
|
||||
async updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void> {
|
||||
await this.invoke('updateNoteFields', {
|
||||
note: {
|
||||
|
||||
@@ -7,6 +7,14 @@ import { AnkiIntegration } from './anki-integration';
|
||||
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
||||
import { AnkiConnectConfig } from './types';
|
||||
|
||||
type TestOverlayNotificationPayload = {
|
||||
title: string;
|
||||
body?: string;
|
||||
image?: string;
|
||||
variant?: string;
|
||||
actions?: Array<{ id: string; label: string; noteId?: number }>;
|
||||
};
|
||||
|
||||
interface IntegrationTestContext {
|
||||
integration: AnkiIntegration;
|
||||
calls: {
|
||||
@@ -406,6 +414,188 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode
|
||||
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
|
||||
});
|
||||
|
||||
test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => {
|
||||
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
const generatedFrom: Array<{ videoPath: string; timestamp: number }> = [];
|
||||
const cleanupPaths: string[] = [];
|
||||
const notificationIconPath = path.join(os.tmpdir(), 'subminer-notification-icon.png');
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'both',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{
|
||||
currentVideoPath: '/tmp/show.mkv',
|
||||
currentTimePos: 123.45,
|
||||
} as never,
|
||||
undefined,
|
||||
(title, options) => {
|
||||
desktopNotifications.push({ title, body: options.body, icon: options.icon });
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayNotifications.push(payload as TestOverlayNotificationPayload);
|
||||
},
|
||||
);
|
||||
|
||||
(
|
||||
integration as unknown as {
|
||||
mediaGenerator: {
|
||||
generateNotificationIcon: (videoPath: string, timestamp: number) => Promise<Buffer>;
|
||||
writeNotificationIconToFile: (iconBuffer: Buffer, noteId: number) => string;
|
||||
scheduleNotificationIconCleanup: (filePath: string) => void;
|
||||
};
|
||||
}
|
||||
).mediaGenerator = {
|
||||
generateNotificationIcon: async (videoPath, timestamp) => {
|
||||
generatedFrom.push({ videoPath, timestamp });
|
||||
return Buffer.from('png');
|
||||
},
|
||||
writeNotificationIconToFile: (iconBuffer, noteId) => {
|
||||
assert.equal(iconBuffer.toString(), 'png');
|
||||
assert.equal(noteId, 42);
|
||||
return notificationIconPath;
|
||||
},
|
||||
scheduleNotificationIconCleanup: (filePath) => {
|
||||
cleanupPaths.push(filePath);
|
||||
},
|
||||
};
|
||||
|
||||
await (
|
||||
integration as unknown as {
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
}
|
||||
).showNotification(42, '食べる');
|
||||
|
||||
assert.deepEqual(generatedFrom, [{ videoPath: '/tmp/show.mkv', timestamp: 123.45 }]);
|
||||
assert.equal(overlayNotifications.length, 1);
|
||||
assert.equal(overlayNotifications[0]?.title, 'Anki Card Updated');
|
||||
assert.equal(overlayNotifications[0]?.body, 'Updated card: 食べる');
|
||||
assert.equal(
|
||||
overlayNotifications[0]?.image,
|
||||
`data:image/png;base64,${Buffer.from('png').toString('base64')}`,
|
||||
);
|
||||
assert.deepEqual(overlayNotifications[0]?.actions, [
|
||||
{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 },
|
||||
]);
|
||||
assert.deepEqual(desktopNotifications, [
|
||||
{
|
||||
title: 'Anki Card Updated',
|
||||
body: 'Updated card: 食べる',
|
||||
icon: notificationIconPath,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(cleanupPaths, [notificationIconPath]);
|
||||
});
|
||||
|
||||
test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => {
|
||||
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'both',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{
|
||||
currentVideoPath: '/tmp/show.mkv',
|
||||
currentTimePos: 123.45,
|
||||
} as never,
|
||||
undefined,
|
||||
(title, options) => {
|
||||
desktopNotifications.push({ title, body: options.body, icon: options.icon });
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayNotifications.push(payload as TestOverlayNotificationPayload);
|
||||
},
|
||||
);
|
||||
|
||||
(
|
||||
integration as unknown as {
|
||||
mediaGenerator: {
|
||||
generateNotificationIcon: () => Promise<Buffer>;
|
||||
writeNotificationIconToFile: () => string;
|
||||
scheduleNotificationIconCleanup: (filePath: string) => void;
|
||||
};
|
||||
}
|
||||
).mediaGenerator = {
|
||||
generateNotificationIcon: async () => Buffer.from('png'),
|
||||
writeNotificationIconToFile: () => {
|
||||
throw new Error('disk full');
|
||||
},
|
||||
scheduleNotificationIconCleanup: (filePath) => {
|
||||
cleanupPaths.push(filePath);
|
||||
},
|
||||
};
|
||||
|
||||
await (
|
||||
integration as unknown as {
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
}
|
||||
).showNotification(42, '食べる');
|
||||
|
||||
assert.equal(
|
||||
overlayNotifications[0]?.image,
|
||||
`data:image/png;base64,${Buffer.from('png').toString('base64')}`,
|
||||
);
|
||||
assert.deepEqual(desktopNotifications, [
|
||||
{
|
||||
title: 'Anki Card Updated',
|
||||
body: 'Updated card: 食べる',
|
||||
icon: undefined,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(cleanupPaths, []);
|
||||
});
|
||||
|
||||
test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => {
|
||||
const osdMessages: string[] = [];
|
||||
const desktopMessages: string[] = [];
|
||||
const overlayMessages: string[] = [];
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'both',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{} as never,
|
||||
(text) => {
|
||||
osdMessages.push(text);
|
||||
},
|
||||
(title, options) => {
|
||||
desktopMessages.push(`${title}:${options.body ?? ''}`);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayMessages.push(`${payload.title}:${payload.body ?? ''}:${payload.variant ?? ''}`);
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(await integration.createSentenceCard('食べる', 0, 1), false);
|
||||
|
||||
assert.deepEqual(osdMessages, []);
|
||||
assert.deepEqual(overlayMessages, ['SubMiner:No video loaded:info']);
|
||||
assert.deepEqual(desktopMessages, ['SubMiner:No video loaded']);
|
||||
});
|
||||
|
||||
test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => {
|
||||
const collaborator = createFieldGroupingMergeCollaborator();
|
||||
|
||||
|
||||
+145
-36
@@ -29,6 +29,8 @@ import {
|
||||
} from './types/anki';
|
||||
import { AiConfig } from './types/integrations';
|
||||
import { MpvClient } from './types/runtime';
|
||||
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
|
||||
import type { NotificationType, OverlayNotificationPayload } from './types/notification';
|
||||
import type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle';
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
|
||||
import {
|
||||
@@ -119,6 +121,15 @@ function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): b
|
||||
);
|
||||
}
|
||||
|
||||
function toOverlayNotificationImageSource(iconBuffer: Buffer): string {
|
||||
return `data:image/png;base64,${iconBuffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
interface NotificationIcon {
|
||||
filePath?: string;
|
||||
overlayImageSource: string;
|
||||
}
|
||||
|
||||
export class AnkiIntegration {
|
||||
private client: AnkiConnectClient;
|
||||
private mediaGenerator: MediaGenerator;
|
||||
@@ -130,6 +141,8 @@ export class AnkiIntegration {
|
||||
private osdCallback: ((text: string) => void) | null = null;
|
||||
private notificationCallback: ((title: string, options: NotificationOptions) => void) | null =
|
||||
null;
|
||||
private overlayNotificationCallback: ((payload: OverlayNotificationPayload) => void) | null =
|
||||
null;
|
||||
private updateInProgress = false;
|
||||
private uiFeedbackState: UiFeedbackState = createUiFeedbackState();
|
||||
private parseWarningKeys = new Set<string>();
|
||||
@@ -166,6 +179,7 @@ export class AnkiIntegration {
|
||||
knownWordCacheStatePath?: string,
|
||||
aiConfig: AiConfig = {},
|
||||
recordCardsMined?: (count: number, noteIds?: number[]) => void,
|
||||
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
|
||||
) {
|
||||
this.config = normalizeAnkiIntegrationConfig(config);
|
||||
this.aiConfig = { ...aiConfig };
|
||||
@@ -175,6 +189,7 @@ export class AnkiIntegration {
|
||||
this.mpvClient = mpvClient;
|
||||
this.osdCallback = osdCallback || null;
|
||||
this.notificationCallback = notificationCallback || null;
|
||||
this.overlayNotificationCallback = overlayNotificationCallback || null;
|
||||
this.fieldGroupingCallback = fieldGroupingCallback || null;
|
||||
this.recordCardsMinedCallback = recordCardsMined ?? null;
|
||||
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
|
||||
@@ -335,7 +350,7 @@ export class AnkiIntegration {
|
||||
options,
|
||||
),
|
||||
},
|
||||
showOsdNotification: (text: string) => this.showOsdNotification(text),
|
||||
showOsdNotification: (text: string) => this.showStatusNotification(text),
|
||||
showUpdateResult: (message: string, success: boolean) =>
|
||||
this.showUpdateResult(message, success),
|
||||
showStatusNotification: (message: string) => this.showStatusNotification(message),
|
||||
@@ -387,7 +402,7 @@ export class AnkiIntegration {
|
||||
getDeck: () => this.config.deck,
|
||||
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) =>
|
||||
this.withUpdateProgress(initialMessage, action),
|
||||
showOsdNotification: (text: string) => this.showOsdNotification(text),
|
||||
showOsdNotification: (text: string) => this.showStatusNotification(text),
|
||||
findNotes: async (query, options) =>
|
||||
(await this.client.findNotes(query, options)) as number[],
|
||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown as NoteInfo[],
|
||||
@@ -463,7 +478,7 @@ export class AnkiIntegration {
|
||||
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
|
||||
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
|
||||
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
||||
showOsdNotification: (message) => this.showOsdNotification(message),
|
||||
showOsdNotification: (message) => this.showStatusNotification(message),
|
||||
beginUpdateProgress: (initialMessage) => this.beginUpdateProgress(initialMessage),
|
||||
endUpdateProgress: () => this.endUpdateProgress(),
|
||||
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
|
||||
@@ -510,7 +525,7 @@ export class AnkiIntegration {
|
||||
},
|
||||
showStatusNotification: (message) => this.showStatusNotification(message),
|
||||
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
||||
showOsdNotification: (message) => this.showOsdNotification(message),
|
||||
showOsdNotification: (message) => this.showStatusNotification(message),
|
||||
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
|
||||
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
|
||||
truncateSentence: (sentence) => this.truncateSentence(sentence),
|
||||
@@ -525,6 +540,10 @@ export class AnkiIntegration {
|
||||
return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode;
|
||||
}
|
||||
|
||||
async openNoteInAnki(noteId: number): Promise<void> {
|
||||
await this.client.openNoteInBrowser(noteId);
|
||||
}
|
||||
|
||||
private isKnownWordCacheEnabled(): boolean {
|
||||
return (
|
||||
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true
|
||||
@@ -860,10 +879,13 @@ export class AnkiIntegration {
|
||||
|
||||
private showStatusNotification(message: string): void {
|
||||
showStatusNotification(message, {
|
||||
getNotificationType: () => this.config.behavior?.notificationType,
|
||||
getNotificationType: () => this.getNotificationType(),
|
||||
showOsd: (text: string) => {
|
||||
this.showOsdNotification(text);
|
||||
},
|
||||
showOverlayNotification: (payload) => {
|
||||
this.overlayNotificationCallback?.(payload);
|
||||
},
|
||||
showSystemNotification: (title: string, options: NotificationOptions) => {
|
||||
if (this.notificationCallback) {
|
||||
this.notificationCallback(title, options);
|
||||
@@ -872,19 +894,51 @@ export class AnkiIntegration {
|
||||
});
|
||||
}
|
||||
|
||||
private getNotificationType(): NotificationType {
|
||||
return this.config.behavior?.notificationType ?? 'osd';
|
||||
}
|
||||
|
||||
private shouldUseOsdNotifications(): boolean {
|
||||
const type = this.getNotificationType();
|
||||
return type === 'osd' || type === 'osd-system';
|
||||
}
|
||||
|
||||
private shouldUseOverlayNotifications(): boolean {
|
||||
const type = this.getNotificationType();
|
||||
return type === 'overlay' || type === 'both';
|
||||
}
|
||||
|
||||
private beginUpdateProgress(initialMessage: string): void {
|
||||
if (!this.shouldUseOsdNotifications()) {
|
||||
if (this.shouldUseOverlayNotifications()) {
|
||||
this.overlayNotificationCallback?.({
|
||||
id: 'anki-update-progress',
|
||||
title: 'Anki update',
|
||||
body: initialMessage,
|
||||
variant: 'progress',
|
||||
persistent: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
beginUpdateProgress(this.uiFeedbackState, initialMessage, (text: string) => {
|
||||
this.showOsdNotification(text);
|
||||
});
|
||||
}
|
||||
|
||||
private endUpdateProgress(): void {
|
||||
if (!this.shouldUseOsdNotifications()) {
|
||||
return;
|
||||
}
|
||||
endUpdateProgress(this.uiFeedbackState, (timer) => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
}
|
||||
|
||||
private clearUpdateProgress(): void {
|
||||
if (!this.shouldUseOsdNotifications()) {
|
||||
return;
|
||||
}
|
||||
clearUpdateProgress(this.uiFeedbackState, (timer) => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
@@ -894,6 +948,23 @@ export class AnkiIntegration {
|
||||
initialMessage: string,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!this.shouldUseOsdNotifications()) {
|
||||
this.updateInProgress = true;
|
||||
if (this.shouldUseOverlayNotifications()) {
|
||||
this.overlayNotificationCallback?.({
|
||||
id: 'anki-update-progress',
|
||||
title: 'Anki update',
|
||||
body: initialMessage,
|
||||
variant: 'progress',
|
||||
persistent: false,
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
this.updateInProgress = false;
|
||||
}
|
||||
}
|
||||
return withUpdateProgress(
|
||||
this.uiFeedbackState,
|
||||
{
|
||||
@@ -1017,51 +1088,89 @@ export class AnkiIntegration {
|
||||
? `Updated card: ${label} (${errorSuffix})`
|
||||
: `Updated card: ${label}`;
|
||||
|
||||
const type = this.config.behavior?.notificationType || 'osd';
|
||||
const type = this.getNotificationType();
|
||||
|
||||
if (type === 'osd' || type === 'both') {
|
||||
if (type === 'osd' || type === 'osd-system') {
|
||||
this.showUpdateResult(message, errorSuffix === undefined);
|
||||
} else {
|
||||
this.clearUpdateProgress();
|
||||
}
|
||||
|
||||
if ((type === 'system' || type === 'both') && this.notificationCallback) {
|
||||
let notificationIconPath: string | undefined;
|
||||
const shouldShowOverlayNotification =
|
||||
(type === 'overlay' || type === 'both') && this.overlayNotificationCallback !== null;
|
||||
const shouldShowSystemNotification =
|
||||
(type === 'system' || type === 'both' || type === 'osd-system') &&
|
||||
this.notificationCallback !== null;
|
||||
const notificationIcon =
|
||||
shouldShowOverlayNotification || shouldShowSystemNotification
|
||||
? await this.generateNotificationIcon(noteId, shouldShowSystemNotification)
|
||||
: undefined;
|
||||
|
||||
if (this.mpvClient && this.mpvClient.currentVideoPath) {
|
||||
try {
|
||||
const timestamp = this.mpvClient.currentTimePos || 0;
|
||||
const notificationIconSource = await resolveMediaGenerationInputPath(
|
||||
this.mpvClient,
|
||||
'video',
|
||||
);
|
||||
if (!notificationIconSource) {
|
||||
throw new Error('No media source available for notification icon');
|
||||
}
|
||||
const iconBuffer = await this.mediaGenerator.generateNotificationIcon(
|
||||
notificationIconSource,
|
||||
timestamp,
|
||||
);
|
||||
if (iconBuffer && iconBuffer.length > 0) {
|
||||
notificationIconPath = this.mediaGenerator.writeNotificationIconToFile(
|
||||
if (shouldShowOverlayNotification && this.overlayNotificationCallback) {
|
||||
this.overlayNotificationCallback({
|
||||
id: 'anki-update-progress',
|
||||
title: 'Anki Card Updated',
|
||||
body: message,
|
||||
...(notificationIcon ? { image: notificationIcon.overlayImageSource } : {}),
|
||||
variant: errorSuffix === undefined ? 'success' : 'error',
|
||||
persistent: false,
|
||||
actions: [{ id: OPEN_ANKI_CARD_ACTION_ID, label: 'Open in Anki', noteId }],
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldShowSystemNotification && this.notificationCallback) {
|
||||
this.notificationCallback('Anki Card Updated', {
|
||||
body: message,
|
||||
icon: notificationIcon?.filePath,
|
||||
});
|
||||
}
|
||||
|
||||
if (notificationIcon) {
|
||||
if (notificationIcon.filePath) {
|
||||
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIcon.filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async generateNotificationIcon(
|
||||
noteId: number,
|
||||
shouldWriteToFile: boolean,
|
||||
): Promise<NotificationIcon | undefined> {
|
||||
if (!this.mpvClient?.currentVideoPath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const timestamp = this.mpvClient.currentTimePos || 0;
|
||||
const notificationIconSource = await resolveMediaGenerationInputPath(this.mpvClient, 'video');
|
||||
if (!notificationIconSource) {
|
||||
throw new Error('No media source available for notification icon');
|
||||
}
|
||||
const iconBuffer = await this.mediaGenerator.generateNotificationIcon(
|
||||
notificationIconSource,
|
||||
timestamp,
|
||||
);
|
||||
if (iconBuffer && iconBuffer.length > 0) {
|
||||
const notificationIcon: NotificationIcon = {
|
||||
overlayImageSource: toOverlayNotificationImageSource(iconBuffer),
|
||||
};
|
||||
if (shouldWriteToFile) {
|
||||
try {
|
||||
notificationIcon.filePath = this.mediaGenerator.writeNotificationIconToFile(
|
||||
iconBuffer,
|
||||
noteId,
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn('Failed to write notification icon:', (err as Error).message);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Failed to generate notification icon:', (err as Error).message);
|
||||
}
|
||||
return notificationIcon;
|
||||
}
|
||||
|
||||
this.notificationCallback('Anki Card Updated', {
|
||||
body: message,
|
||||
icon: notificationIconPath,
|
||||
});
|
||||
|
||||
if (notificationIconPath) {
|
||||
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIconPath);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Failed to generate notification icon:', (err as Error).message);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private showUpdateResult(message: string, success: boolean): void {
|
||||
|
||||
@@ -271,3 +271,28 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
||||
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
|
||||
assert.match(updatedFields[0]?.Picture ?? '', /^<img src="image_\d+\.jpg">$/);
|
||||
});
|
||||
|
||||
test('createSentenceCard relies on Anki progress notification without standalone status toast', async () => {
|
||||
const statusMessages: string[] = [];
|
||||
const progressMessages: string[] = [];
|
||||
const { service } = createManualUpdateService({
|
||||
showOsdNotification: (message) => {
|
||||
statusMessages.push(message);
|
||||
},
|
||||
withUpdateProgress: async (message, action) => {
|
||||
progressMessages.push(message);
|
||||
return await action();
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
});
|
||||
|
||||
const created = await service.createSentenceCard('テスト', 0, 1);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(progressMessages, ['Creating sentence card']);
|
||||
assert.deepEqual(statusMessages, []);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||
|
||||
test('sentence card writes generated audio only to sentence audio field', async () => {
|
||||
const addedFields: Record<string, string>[] = [];
|
||||
const updatedFields: Record<string, string>[] = [];
|
||||
const storedMedia: string[] = [];
|
||||
|
||||
const deps: CardCreationDeps = {
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
audio: 'ExpressionAudio',
|
||||
translation: 'SelectionText',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () => ({}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: '/video.mp4',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 12,
|
||||
currentSubEnd: 14,
|
||||
currentTimePos: 13,
|
||||
currentAudioStreamIndex: 0,
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async (_deck, _modelName, fields) => {
|
||||
addedFields.push(fields);
|
||||
return 42;
|
||||
},
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: '字幕' },
|
||||
Sentence: { value: '字幕' },
|
||||
SelectionText: { value: 'Subtitle' },
|
||||
ExpressionAudio: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (_noteId, fields) => {
|
||||
updatedFields.push(fields);
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => Buffer.from('audio'),
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
|
||||
for (const preferredName of preferredNames) {
|
||||
if (preferredName && preferredName in noteInfo.fields) return preferredName;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
resolveNoteFieldName: (noteInfo, preferredName) =>
|
||||
preferredName && preferredName in noteInfo.fields ? preferredName : null,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: () => ({}),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
};
|
||||
|
||||
const created = await new CardCreationService(deps).createSentenceCard(
|
||||
'字幕',
|
||||
12,
|
||||
14,
|
||||
'Subtitle',
|
||||
);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(addedFields[0], {
|
||||
Sentence: '字幕',
|
||||
SelectionText: 'Subtitle',
|
||||
IsSentenceCard: 'x',
|
||||
Expression: '字幕',
|
||||
});
|
||||
assert.equal(storedMedia.length, 1);
|
||||
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
|
||||
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
||||
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
||||
});
|
||||
@@ -511,7 +511,6 @@ export class CardCreationService {
|
||||
endTime = startTime + maxMediaDuration;
|
||||
}
|
||||
|
||||
this.deps.showOsdNotification('Creating sentence card...');
|
||||
try {
|
||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
|
||||
@@ -529,7 +528,6 @@ export class CardCreationService {
|
||||
const translationField = this.deps.getConfig().fields?.translation || 'SelectionText';
|
||||
let resolvedMiscInfoField: string | null = null;
|
||||
let resolvedSentenceAudioField: string = audioFieldName;
|
||||
let resolvedExpressionAudioField: string | null = null;
|
||||
|
||||
fields[sentenceField] = sentence;
|
||||
|
||||
@@ -627,10 +625,6 @@ export class CardCreationService {
|
||||
this.deps.appendKnownWordsFromNoteInfo(createdNoteInfo);
|
||||
resolvedSentenceAudioField =
|
||||
this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName;
|
||||
resolvedExpressionAudioField = this.deps.resolveConfiguredFieldName(
|
||||
createdNoteInfo,
|
||||
this.deps.getConfig().fields?.audio || 'ExpressionAudio',
|
||||
);
|
||||
resolvedMiscInfoField = this.deps.resolveConfiguredFieldName(
|
||||
createdNoteInfo,
|
||||
this.deps.getConfig().fields?.miscInfo,
|
||||
@@ -663,12 +657,6 @@ export class CardCreationService {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
mediaFields[resolvedSentenceAudioField] = audioValue;
|
||||
if (
|
||||
resolvedExpressionAudioField &&
|
||||
resolvedExpressionAudioField !== resolvedSentenceAudioField
|
||||
) {
|
||||
mediaFields[resolvedExpressionAudioField] = audioValue;
|
||||
}
|
||||
miscInfoFilename = audioFilename;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
beginUpdateProgress,
|
||||
createUiFeedbackState,
|
||||
showProgressTick,
|
||||
showStatusNotification,
|
||||
showUpdateResult,
|
||||
} from './ui-feedback';
|
||||
|
||||
@@ -65,3 +66,57 @@ test('showUpdateResult renders failed updates with an x marker', () => {
|
||||
'x Sentence card failed: deck missing',
|
||||
]);
|
||||
});
|
||||
|
||||
test('showStatusNotification falls back to system when overlay delivery is unavailable', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
showStatusNotification('Waiting for card update', {
|
||||
getNotificationType: () => 'overlay',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showSystemNotification: (title, options) => {
|
||||
calls.push(`system:${title}:${options.body}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['system:SubMiner:Waiting for card update']);
|
||||
});
|
||||
|
||||
test('showStatusNotification defaults to mpv osd when notification type is unset', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
showStatusNotification('Card updated', {
|
||||
getNotificationType: () => undefined,
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showOverlayNotification: (payload) => {
|
||||
calls.push(`overlay:${payload.body}`);
|
||||
},
|
||||
showSystemNotification: (title, options) => {
|
||||
calls.push(`system:${title}:${options.body}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['osd:Card updated']);
|
||||
});
|
||||
|
||||
test('showStatusNotification does not duplicate system notifications for both', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
showStatusNotification('Card updated', {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showOverlayNotification: (payload) => {
|
||||
calls.push(`overlay:${payload.body}`);
|
||||
},
|
||||
showSystemNotification: (title, options) => {
|
||||
calls.push(`system:${title}:${options.body}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['overlay:Card updated', 'system:SubMiner:Card updated']);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NotificationOptions } from '../types/anki';
|
||||
import type { NotificationOptions } from '../types/anki';
|
||||
import type { NotificationType, OverlayNotificationPayload } from '../types/notification';
|
||||
|
||||
export interface UiFeedbackState {
|
||||
progressDepth: number;
|
||||
@@ -13,8 +14,9 @@ export interface UiFeedbackResult {
|
||||
}
|
||||
|
||||
export interface UiFeedbackNotificationContext {
|
||||
getNotificationType: () => string | undefined;
|
||||
getNotificationType: () => NotificationType | undefined;
|
||||
showOsd: (text: string) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
showSystemNotification: (title: string, options: NotificationOptions) => void;
|
||||
}
|
||||
|
||||
@@ -36,13 +38,29 @@ export function showStatusNotification(
|
||||
message: string,
|
||||
context: UiFeedbackNotificationContext,
|
||||
): void {
|
||||
const type = context.getNotificationType() || 'osd';
|
||||
const type = context.getNotificationType() ?? 'osd';
|
||||
|
||||
if (type === 'osd' || type === 'both') {
|
||||
if (type === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'overlay' || type === 'both') {
|
||||
if (context.showOverlayNotification) {
|
||||
context.showOverlayNotification({
|
||||
title: 'SubMiner',
|
||||
body: message,
|
||||
variant: 'info',
|
||||
});
|
||||
} else if (type === 'overlay') {
|
||||
context.showSystemNotification('SubMiner', { body: message });
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'osd' || type === 'osd-system') {
|
||||
context.showOsd(message);
|
||||
}
|
||||
|
||||
if (type === 'system' || type === 'both') {
|
||||
if (type === 'system' || type === 'both' || type === 'osd-system') {
|
||||
context.showSystemNotification('SubMiner', { body: message });
|
||||
}
|
||||
}
|
||||
|
||||
+16
-4
@@ -101,8 +101,6 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
'--toggle-primary-subtitle-bar',
|
||||
'--replay-current-subtitle',
|
||||
'--play-next-subtitle',
|
||||
'--shift-sub-delay-prev-line',
|
||||
'--shift-sub-delay-next-line',
|
||||
'--cycle-runtime-option',
|
||||
'anki.autoUpdateNewCards:prev',
|
||||
'--session-action',
|
||||
@@ -120,8 +118,6 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
assert.equal(args.togglePrimarySubtitleBar, true);
|
||||
assert.equal(args.replayCurrentSubtitle, true);
|
||||
assert.equal(args.playNextSubtitle, true);
|
||||
assert.equal(args.shiftSubDelayPrevLine, true);
|
||||
assert.equal(args.shiftSubDelayNextLine, true);
|
||||
assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
|
||||
assert.equal(args.cycleRuntimeOptionDirection, -1);
|
||||
assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' });
|
||||
@@ -131,6 +127,22 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs ignores retired subtitle delay shift flags', () => {
|
||||
const args = parseArgs(['--shift-sub-delay-prev-line', '--shift-sub-delay-next-line']);
|
||||
|
||||
assert.equal(hasExplicitCommand(args), false);
|
||||
assert.equal(shouldStartApp(args), false);
|
||||
});
|
||||
|
||||
test('parseArgs captures internal playback feedback command', () => {
|
||||
const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']);
|
||||
|
||||
assert.equal(args.playbackFeedback, 'You can skip by pressing TAB');
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
assert.equal(commandNeedsOverlayRuntime(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs ignores non-positive numeric session action counts', () => {
|
||||
const args = parseArgs(['--copy-subtitle-count=0', '--mine-sentence-count', '-1']);
|
||||
|
||||
|
||||
+14
-17
@@ -41,8 +41,7 @@ export interface CliArgs {
|
||||
openPlaylistBrowser: boolean;
|
||||
replayCurrentSubtitle: boolean;
|
||||
playNextSubtitle: boolean;
|
||||
shiftSubDelayPrevLine: boolean;
|
||||
shiftSubDelayNextLine: boolean;
|
||||
playbackFeedback?: string;
|
||||
cycleRuntimeOptionId?: string;
|
||||
cycleRuntimeOptionDirection?: 1 | -1;
|
||||
sessionAction?: SessionActionDispatchRequest;
|
||||
@@ -148,8 +147,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
playbackFeedback: undefined,
|
||||
anilistStatus: false,
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
@@ -294,9 +292,13 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
|
||||
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
|
||||
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
|
||||
else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true;
|
||||
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true;
|
||||
else if (arg.startsWith('--cycle-runtime-option=')) {
|
||||
else if (arg.startsWith('--playback-feedback=')) {
|
||||
const value = arg.slice('--playback-feedback='.length).trim();
|
||||
if (value) args.playbackFeedback = value;
|
||||
} else if (arg === '--playback-feedback') {
|
||||
const value = readValue(argv[i + 1])?.trim();
|
||||
if (value) args.playbackFeedback = value;
|
||||
} else if (arg.startsWith('--cycle-runtime-option=')) {
|
||||
const parsed = parseCycleRuntimeOption(arg.split('=', 2)[1]);
|
||||
if (parsed) {
|
||||
args.cycleRuntimeOptionId = parsed.id;
|
||||
@@ -554,8 +556,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
@@ -629,8 +630,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
!args.playNextSubtitle &&
|
||||
!args.shiftSubDelayPrevLine &&
|
||||
!args.shiftSubDelayNextLine &&
|
||||
args.playbackFeedback === undefined &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.sessionAction === undefined &&
|
||||
args.copySubtitleCount === undefined &&
|
||||
@@ -695,8 +695,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
@@ -755,8 +754,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
!args.playNextSubtitle &&
|
||||
!args.shiftSubDelayPrevLine &&
|
||||
!args.shiftSubDelayNextLine &&
|
||||
args.playbackFeedback === undefined &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.sessionAction === undefined &&
|
||||
args.copySubtitleCount === undefined &&
|
||||
@@ -820,8 +818,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
|
||||
@@ -98,6 +98,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
|
||||
assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D');
|
||||
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
|
||||
assert.equal(config.shortcuts.toggleNotificationHistory, 'CommandOrControl+N');
|
||||
assert.equal(config.discordPresence.enabled, true);
|
||||
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
||||
assert.equal(config.subtitleStyle.backgroundColor, 'transparent');
|
||||
@@ -152,7 +153,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.stats.autoOpenBrowser, false);
|
||||
assert.equal(config.updates.enabled, true);
|
||||
assert.equal(config.updates.checkIntervalHours, 24);
|
||||
assert.equal(config.updates.notificationType, 'system');
|
||||
assert.equal(config.updates.notificationType, 'overlay');
|
||||
assert.equal(config.updates.channel, 'stable');
|
||||
assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
|
||||
assert.equal(config.mpv.backend, 'auto');
|
||||
@@ -172,7 +173,7 @@ test('parses updates config and warns on invalid values', () => {
|
||||
"updates": {
|
||||
"enabled": false,
|
||||
"checkIntervalHours": 6,
|
||||
"notificationType": "both",
|
||||
"notificationType": "osd-system",
|
||||
"channel": "prerelease"
|
||||
}
|
||||
}`,
|
||||
@@ -182,7 +183,7 @@ test('parses updates config and warns on invalid values', () => {
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().updates.enabled, false);
|
||||
assert.equal(validService.getConfig().updates.checkIntervalHours, 6);
|
||||
assert.equal(validService.getConfig().updates.notificationType, 'both');
|
||||
assert.equal(validService.getConfig().updates.notificationType, 'osd-system');
|
||||
assert.equal(validService.getConfig().updates.channel, 'prerelease');
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
@@ -212,6 +213,69 @@ test('parses updates config and warns on invalid values', () => {
|
||||
assert.ok(warnings.some((warning) => warning.path === 'updates.channel'));
|
||||
});
|
||||
|
||||
test('accepts overlay notification config values', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"updates": {
|
||||
"notificationType": "overlay"
|
||||
},
|
||||
"ankiConnect": {
|
||||
"behavior": {
|
||||
"notificationType": "osd-system"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
|
||||
assert.equal(service.getConfig().updates.notificationType, 'overlay');
|
||||
assert.equal(service.getConfig().ankiConnect.behavior.notificationType, 'osd-system');
|
||||
assert.deepEqual(service.getWarnings(), []);
|
||||
});
|
||||
|
||||
test('parses overlay notification position config and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"notifications": {
|
||||
"overlayPosition": "top-left"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().notifications.overlayPosition, 'top-left');
|
||||
assert.deepEqual(validService.getWarnings(), []);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"notifications": {
|
||||
"overlayPosition": "bottom-right"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
invalidService.getConfig().notifications.overlayPosition,
|
||||
DEFAULT_CONFIG.notifications.overlayPosition,
|
||||
);
|
||||
assert.ok(
|
||||
invalidService
|
||||
.getWarnings()
|
||||
.some((warning) => warning.path === 'notifications.overlayPosition'),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws actionable startup parse error for malformed config at construction time', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
@@ -2750,7 +2814,7 @@ test('template generator includes known keys', () => {
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"notificationType": "system",? \/\/ How SubMiner announces available updates\. Values: system \| osd \| both \| none/,
|
||||
/"notificationType": "overlay",? \/\/ How SubMiner announces available updates\..*Values: overlay \| system \| both \| none \| osd \| osd-system/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
subsync,
|
||||
startupWarmups,
|
||||
updates,
|
||||
notifications,
|
||||
auto_start_overlay,
|
||||
} = CORE_DEFAULT_CONFIG;
|
||||
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||
@@ -57,6 +58,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subsync,
|
||||
startupWarmups,
|
||||
updates,
|
||||
notifications,
|
||||
subtitleStyle,
|
||||
subtitleSidebar,
|
||||
auto_start_overlay,
|
||||
|
||||
@@ -15,6 +15,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'subsync'
|
||||
| 'startupWarmups'
|
||||
| 'updates'
|
||||
| 'notifications'
|
||||
| 'auto_start_overlay'
|
||||
> = {
|
||||
subtitlePosition: { yPercent: 10 },
|
||||
@@ -101,6 +102,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
toggleSubtitleSidebar: 'Backslash',
|
||||
toggleNotificationHistory: 'CommandOrControl+N',
|
||||
},
|
||||
secondarySub: {
|
||||
secondarySubLanguages: [],
|
||||
@@ -126,8 +128,11 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
updates: {
|
||||
enabled: true,
|
||||
checkIntervalHours: 24,
|
||||
notificationType: 'system',
|
||||
notificationType: 'overlay',
|
||||
channel: 'stable',
|
||||
},
|
||||
notifications: {
|
||||
overlayPosition: 'top-right',
|
||||
},
|
||||
auto_start_overlay: true,
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
overwriteImage: true,
|
||||
mediaInsertMode: 'append',
|
||||
highlightWord: true,
|
||||
notificationType: 'osd',
|
||||
notificationType: 'overlay',
|
||||
autoUpdateNewCards: true,
|
||||
},
|
||||
nPlusOne: {
|
||||
|
||||
@@ -234,3 +234,16 @@ test('default keybindings include replay and next subtitle controls', () => {
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyH'), ['__replay-subtitle']);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-subtitle']);
|
||||
});
|
||||
|
||||
test('default keybindings mirror mpv subtitle delay and sub-step keys', () => {
|
||||
const keybindingMap = new Map(
|
||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('KeyZ'), ['add', 'sub-delay', -0.1]);
|
||||
assert.deepEqual(keybindingMap.get('Shift+KeyZ'), ['add', 'sub-delay', 0.1]);
|
||||
assert.deepEqual(keybindingMap.get('KeyX'), ['add', 'sub-delay', 0.1]);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowLeft'), ['sub-step', -1]);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowRight'), ['sub-step', 1]);
|
||||
assert.equal(keybindingMap.has('Shift+BracketLeft'), false);
|
||||
assert.equal(keybindingMap.has('Shift+BracketRight'), false);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import {
|
||||
NOTIFICATION_TYPE_VALUES,
|
||||
OVERLAY_NOTIFICATION_POSITION_VALUES,
|
||||
SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||
} from '../../types/notification';
|
||||
import { ConfigOptionRegistryEntry } from './shared';
|
||||
|
||||
export function buildCoreConfigOptionRegistry(
|
||||
@@ -484,9 +489,11 @@ export function buildCoreConfigOptionRegistry(
|
||||
{
|
||||
path: 'updates.notificationType',
|
||||
kind: 'enum',
|
||||
enumValues: ['system', 'osd', 'both', 'none'],
|
||||
enumValues: NOTIFICATION_TYPE_VALUES,
|
||||
settingsEnumValues: SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||
defaultValue: defaultConfig.updates.notificationType,
|
||||
description: 'How SubMiner announces available updates.',
|
||||
description:
|
||||
'How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values.',
|
||||
},
|
||||
{
|
||||
path: 'updates.channel',
|
||||
@@ -495,6 +502,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.updates.channel,
|
||||
description: 'Release channel used for update checks.',
|
||||
},
|
||||
{
|
||||
path: 'notifications.overlayPosition',
|
||||
kind: 'enum',
|
||||
enumValues: OVERLAY_NOTIFICATION_POSITION_VALUES,
|
||||
defaultValue: defaultConfig.notifications.overlayPosition,
|
||||
description: 'Position for in-overlay notification cards.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.multiCopyTimeoutMs',
|
||||
kind: 'number',
|
||||
@@ -608,5 +622,11 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.shortcuts.toggleSubtitleSidebar,
|
||||
description: 'Accelerator that toggles the subtitle sidebar visibility.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.toggleNotificationHistory',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.toggleNotificationHistory,
|
||||
description: 'Accelerator that toggles the overlay notification history panel.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
|
||||
import {
|
||||
NOTIFICATION_TYPE_VALUES,
|
||||
SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||
} from '../../types/notification';
|
||||
import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared';
|
||||
|
||||
export function buildIntegrationConfigOptionRegistry(
|
||||
@@ -63,7 +67,7 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.deck,
|
||||
description:
|
||||
'Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.',
|
||||
'Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.word',
|
||||
@@ -158,9 +162,11 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
{
|
||||
path: 'ankiConnect.behavior.notificationType',
|
||||
kind: 'enum',
|
||||
enumValues: ['osd', 'system', 'both', 'none'],
|
||||
enumValues: NOTIFICATION_TYPE_VALUES,
|
||||
settingsEnumValues: SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.notificationType,
|
||||
description: 'Notification surface used to announce mining and update outcomes.',
|
||||
description:
|
||||
'Notification surface used to announce mining and update outcomes. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.syncAnimatedImageToWordAudio',
|
||||
@@ -496,13 +502,13 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
path: 'mpv.aniskipEnabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.mpv.aniskipEnabled,
|
||||
description: 'Enable AniSkip intro detection and skip markers in the bundled mpv plugin.',
|
||||
description: 'Enable AniSkip intro detection, chapter markers, and the skip-intro key.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.aniskipButtonKey',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.mpv.aniskipButtonKey,
|
||||
description: 'mpv key used to trigger the AniSkip button while the skip marker is visible.',
|
||||
description: 'mpv key used to skip the detected intro while the skip prompt is visible.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.enabled',
|
||||
|
||||
@@ -27,7 +27,17 @@ export interface ConfigOptionRegistryEntry {
|
||||
kind: ConfigValueKind;
|
||||
defaultValue: unknown;
|
||||
description: string;
|
||||
/**
|
||||
* Complete runtime-valid enum options, including legacy file-config values such as
|
||||
* `osd` and `osd-system` in NOTIFICATION_TYPE_VALUES.
|
||||
*/
|
||||
enumValues?: readonly string[];
|
||||
/**
|
||||
* Optional settings UI subset when legacy/runtime-valid enum options should remain
|
||||
* editable in config files but hidden from new UI choices, for example
|
||||
* SETTINGS_NOTIFICATION_TYPE_VALUES.
|
||||
*/
|
||||
settingsEnumValues?: readonly string[];
|
||||
runtime?: RuntimeOptionRegistryEntry;
|
||||
}
|
||||
|
||||
@@ -45,8 +55,6 @@ export const SPECIAL_COMMANDS = {
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
|
||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
||||
} as const;
|
||||
@@ -62,11 +70,11 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||
{ key: 'ArrowDown', command: ['seek', -60] },
|
||||
{ key: 'Shift+KeyH', command: ['sub-seek', -1] },
|
||||
{ key: 'Shift+KeyL', command: ['sub-seek', 1] },
|
||||
{ key: 'Shift+BracketRight', command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START] },
|
||||
{
|
||||
key: 'Shift+BracketLeft',
|
||||
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
|
||||
},
|
||||
{ key: 'Ctrl+Shift+ArrowLeft', command: ['sub-step', -1] },
|
||||
{ key: 'Ctrl+Shift+ArrowRight', command: ['sub-step', 1] },
|
||||
{ key: 'KeyZ', command: ['add', 'sub-delay', -0.1] },
|
||||
{ key: 'Shift+KeyZ', command: ['add', 'sub-delay', 0.1] },
|
||||
{ key: 'KeyX', command: ['add', 'sub-delay', 0.1] },
|
||||
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
|
||||
{ key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] },
|
||||
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
|
||||
|
||||
@@ -63,6 +63,12 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
],
|
||||
key: 'updates',
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
description: ['Overlay notification display behavior.'],
|
||||
notes: ['Hot-reload: position changes apply to the next overlay notification.'],
|
||||
key: 'notifications',
|
||||
},
|
||||
{
|
||||
title: 'Keyboard Shortcuts',
|
||||
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { DEFAULT_CONFIG } from '../definitions';
|
||||
import type { ResolveContext } from './context';
|
||||
import { isNotificationType, type NotificationType } from '../../types/notification';
|
||||
import { asBoolean, asColor, asNumber, asString, isObject } from './shared';
|
||||
|
||||
function asNotificationType(value: unknown): NotificationType | undefined {
|
||||
return isNotificationType(value) ? value : undefined;
|
||||
}
|
||||
|
||||
export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
if (!isObject(context.src.ankiConnect)) {
|
||||
return;
|
||||
@@ -42,6 +47,8 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
'notificationType',
|
||||
'autoUpdateNewCards',
|
||||
]);
|
||||
const hasOwn = (obj: Record<string, unknown>, key: string): boolean =>
|
||||
Object.prototype.hasOwnProperty.call(obj, key);
|
||||
|
||||
const {
|
||||
knownWords: _knownWordsConfigFromAnkiConnect,
|
||||
@@ -99,6 +106,22 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
},
|
||||
};
|
||||
|
||||
if (hasOwn(behavior, 'notificationType')) {
|
||||
const parsed = asNotificationType(behavior.notificationType);
|
||||
if (parsed === undefined) {
|
||||
context.resolved.ankiConnect.behavior.notificationType =
|
||||
DEFAULT_CONFIG.ankiConnect.behavior.notificationType;
|
||||
context.warn(
|
||||
'ankiConnect.behavior.notificationType',
|
||||
behavior.notificationType,
|
||||
context.resolved.ankiConnect.behavior.notificationType,
|
||||
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
|
||||
);
|
||||
} else {
|
||||
context.resolved.ankiConnect.behavior.notificationType = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(ac.isLapis)) {
|
||||
const lapisEnabled = asBoolean(ac.isLapis.enabled);
|
||||
if (lapisEnabled !== undefined) {
|
||||
@@ -289,8 +312,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
const legacy = ac as Record<string, unknown>;
|
||||
const hasOwn = (obj: Record<string, unknown>, key: string): boolean =>
|
||||
Object.prototype.hasOwnProperty.call(obj, key);
|
||||
const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => {
|
||||
const parsed = asNumber(value);
|
||||
if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
@@ -328,11 +349,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => {
|
||||
return value === 'append' || value === 'prepend' ? value : undefined;
|
||||
};
|
||||
const asNotificationType = (value: unknown): 'osd' | 'system' | 'both' | 'none' | undefined => {
|
||||
return value === 'osd' || value === 'system' || value === 'both' || value === 'none'
|
||||
? value
|
||||
: undefined;
|
||||
};
|
||||
const mapLegacy = <T>(
|
||||
key: string,
|
||||
parse: (value: unknown) => T | undefined,
|
||||
@@ -633,7 +649,7 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
context.resolved.ankiConnect.behavior.notificationType = value;
|
||||
},
|
||||
context.resolved.ankiConnect.behavior.notificationType,
|
||||
"Expected 'osd', 'system', 'both', or 'none'.",
|
||||
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
|
||||
);
|
||||
}
|
||||
if (!hasOwn(behavior, 'autoUpdateNewCards')) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ResolveContext } from './context';
|
||||
import { applyControllerConfig } from './controller';
|
||||
import { isNotificationType, isOverlayNotificationPosition } from '../../types/notification';
|
||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
|
||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
@@ -194,19 +195,14 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
const notificationType = asString(src.updates.notificationType);
|
||||
if (
|
||||
notificationType === 'system' ||
|
||||
notificationType === 'osd' ||
|
||||
notificationType === 'both' ||
|
||||
notificationType === 'none'
|
||||
) {
|
||||
if (isNotificationType(notificationType)) {
|
||||
resolved.updates.notificationType = notificationType;
|
||||
} else if (src.updates.notificationType !== undefined) {
|
||||
warn(
|
||||
'updates.notificationType',
|
||||
src.updates.notificationType,
|
||||
resolved.updates.notificationType,
|
||||
'Expected system, osd, both, or none.',
|
||||
'Expected overlay, system, both, none, osd, or osd-system.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -240,6 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'openCharacterDictionaryManager',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'toggleNotificationHistory',
|
||||
] as const;
|
||||
|
||||
for (const key of shortcutKeys) {
|
||||
@@ -323,4 +320,18 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
resolved.subtitlePosition.yPercent = y;
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.notifications)) {
|
||||
const overlayPosition = asString(src.notifications.overlayPosition);
|
||||
if (isOverlayNotificationPosition(overlayPosition)) {
|
||||
resolved.notifications.overlayPosition = overlayPosition;
|
||||
} else if (src.notifications.overlayPosition !== undefined) {
|
||||
warn(
|
||||
'notifications.overlayPosition',
|
||||
src.notifications.overlayPosition,
|
||||
resolved.notifications.overlayPosition,
|
||||
'Expected top-left, top, or top-right.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'Startup warmups',
|
||||
'Logging',
|
||||
'Updates',
|
||||
'Notifications',
|
||||
'Immersion tracking',
|
||||
].map((section, index) => [section, index]),
|
||||
);
|
||||
@@ -411,6 +412,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
) {
|
||||
return { category: 'behavior', section: 'Playback Behavior' };
|
||||
}
|
||||
if (path.startsWith('notifications.')) {
|
||||
return { category: 'behavior', section: 'Notifications' };
|
||||
}
|
||||
if (path === 'mpv.aniskipButtonKey') {
|
||||
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||
}
|
||||
@@ -478,6 +482,7 @@ function topSection(path: string): string {
|
||||
mpv: 'mpv Playback',
|
||||
stats: 'Stats dashboard',
|
||||
startupWarmups: 'Startup warmups',
|
||||
notifications: 'Notifications',
|
||||
subsync: 'Subtitle Sync',
|
||||
texthooker: 'Texthooker',
|
||||
updates: 'Updates',
|
||||
@@ -577,6 +582,7 @@ function subsectionForPath(path: string): string | undefined {
|
||||
if (
|
||||
leaf === 'toggleVisibleOverlayGlobal' ||
|
||||
leaf === 'toggleSubtitleSidebar' ||
|
||||
leaf === 'toggleNotificationHistory' ||
|
||||
leaf === 'toggleSecondarySub' ||
|
||||
leaf === 'toggleStatsOverlay' ||
|
||||
leaf === 'markWatched'
|
||||
@@ -680,12 +686,14 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
path === 'ankiConnect.fields.miscInfo' ||
|
||||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
||||
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
||||
path === 'mpv.aniskipEnabled' ||
|
||||
path === 'mpv.aniskipButtonKey' ||
|
||||
path === 'stats.toggleKey' ||
|
||||
path === 'stats.markWatchedKey' ||
|
||||
path === 'logging.level' ||
|
||||
path === 'logging.rotation' ||
|
||||
pathStartsWith(path, 'logging.files') ||
|
||||
pathStartsWith(path, 'notifications') ||
|
||||
path === 'youtube.primarySubLanguages' ||
|
||||
pathStartsWith(path, 'jimaku') ||
|
||||
pathStartsWith(path, 'subsync')
|
||||
@@ -709,7 +717,9 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
||||
...(subsectionForPath(leaf.path) ? { subsection: subsectionForPath(leaf.path) } : {}),
|
||||
control: controlForPath(leaf.path, leaf.value),
|
||||
defaultValue: leaf.value,
|
||||
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
|
||||
...(option?.settingsEnumValues || option?.enumValues
|
||||
? { enumValues: option.settingsEnumValues ?? option.enumValues }
|
||||
: {}),
|
||||
restartBehavior: restartBehaviorForPath(leaf.path),
|
||||
advanced:
|
||||
leaf.path.startsWith('controller.') ||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,13 +34,13 @@ test('guessAnilistMediaInfo fills missing guessit episode from filename parser',
|
||||
});
|
||||
});
|
||||
|
||||
test('guessAnilistMediaInfo ignores low-confidence parser details when guessit omits them', async () => {
|
||||
test('guessAnilistMediaInfo keeps season directory scope when guessit omits details', async () => {
|
||||
const result = await guessAnilistMediaInfo('/tmp/Season 2/Guessit Title.mkv', null, {
|
||||
runGuessit: async () => JSON.stringify({ title: 'Guessit Title' }),
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
title: 'Guessit Title',
|
||||
season: null,
|
||||
season: 2,
|
||||
episode: null,
|
||||
source: 'guessit',
|
||||
});
|
||||
@@ -235,6 +235,86 @@ test('updateAnilistPostWatchProgress uses the configured AniList rate limiter',
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress marks the final season episode completed', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
call += 1;
|
||||
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
|
||||
if (call === 1) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Page: {
|
||||
media: [{ id: 12, episodes: 12, title: { english: 'Final Show' } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (call === 2) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: { id: 12, mediaListEntry: { progress: 11, status: 'CURRENT' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
assert.equal(body.variables?.progress, 12);
|
||||
assert.equal(body.variables?.status, 'COMPLETED');
|
||||
return createJsonResponse({
|
||||
data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Final Show', 12);
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.match(result.message, /completed/i);
|
||||
assert.equal(call, 3);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress marks an already watched final season episode completed', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
call += 1;
|
||||
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
|
||||
if (call === 1) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Page: {
|
||||
media: [{ id: 12, episodes: 12, title: { english: 'Final Show' } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (call === 2) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: { id: 12, mediaListEntry: { progress: 12, status: 'CURRENT' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
assert.equal(body.variables?.progress, 12);
|
||||
assert.equal(body.variables?.status, 'COMPLETED');
|
||||
return createJsonResponse({
|
||||
data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Final Show', 12);
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.match(result.message, /completed/i);
|
||||
assert.equal(call, 3);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress skips when progress already reached', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
|
||||
@@ -228,7 +228,7 @@ function pickBestSearchResult(
|
||||
native?: string | null;
|
||||
};
|
||||
}>,
|
||||
): { id: number; title: string } | null {
|
||||
): { id: number; title: string; episodes: number | null } | null {
|
||||
const filtered = media.filter((item) => {
|
||||
const totalEpisodes = item.episodes;
|
||||
return totalEpisodes === null || totalEpisodes >= episode;
|
||||
@@ -247,7 +247,7 @@ function pickBestSearchResult(
|
||||
const selected = exact ?? candidates[0]!;
|
||||
const selectedTitle =
|
||||
selected.title?.english || selected.title?.romaji || selected.title?.native || title;
|
||||
return { id: selected.id, title: selectedTitle };
|
||||
return { id: selected.id, title: selectedTitle, episodes: selected.episodes };
|
||||
}
|
||||
|
||||
function isUpdateableListStatus(status: string | null | undefined): boolean {
|
||||
@@ -259,6 +259,15 @@ function formatListStatus(status: string | null | undefined): string {
|
||||
return `marked ${status.toLowerCase().replace(/_/g, ' ')} on AniList`;
|
||||
}
|
||||
|
||||
function isKnownFinalEpisode(totalEpisodes: number | null, episode: number): boolean {
|
||||
return (
|
||||
typeof totalEpisodes === 'number' &&
|
||||
Number.isInteger(totalEpisodes) &&
|
||||
totalEpisodes > 0 &&
|
||||
episode === totalEpisodes
|
||||
);
|
||||
}
|
||||
|
||||
export async function guessAnilistMediaInfo(
|
||||
mediaPath: string | null,
|
||||
mediaTitle: string | null,
|
||||
@@ -283,7 +292,7 @@ export async function guessAnilistMediaInfo(
|
||||
title: buildGuessitTitle(title, alternativeTitle),
|
||||
...(alternativeTitle ? { alternativeTitle } : {}),
|
||||
...(year ? { year } : {}),
|
||||
season: season ?? (canUseFallbackDetails ? fallback.season : null),
|
||||
season: season ?? fallback.season,
|
||||
episode: episode ?? (canUseFallbackDetails ? fallback.episode : null),
|
||||
source: 'guessit',
|
||||
};
|
||||
@@ -394,7 +403,8 @@ export async function updateAnilistPostWatchProgress(
|
||||
}
|
||||
|
||||
const currentProgress = entry.progress ?? 0;
|
||||
if (typeof currentProgress === 'number' && currentProgress >= episode) {
|
||||
const shouldMarkCompleted = isKnownFinalEpisode(picked.episodes, episode);
|
||||
if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
message: `AniList already at episode ${currentProgress} (${picked.title}).`,
|
||||
@@ -404,14 +414,18 @@ export async function updateAnilistPostWatchProgress(
|
||||
const saveResponse = await anilistGraphQl<AnilistSaveEntryData>(
|
||||
accessToken,
|
||||
`
|
||||
mutation ($mediaId: Int!, $progress: Int!) {
|
||||
SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: CURRENT) {
|
||||
mutation ($mediaId: Int!, $progress: Int!, $status: MediaListStatus!) {
|
||||
SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status) {
|
||||
progress
|
||||
status
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ mediaId: picked.id, progress: episode },
|
||||
{
|
||||
mediaId: picked.id,
|
||||
progress: episode,
|
||||
status: shouldMarkCompleted ? 'COMPLETED' : 'CURRENT',
|
||||
},
|
||||
options,
|
||||
);
|
||||
const saveError = firstErrorMessage(saveResponse);
|
||||
@@ -421,6 +435,8 @@ export async function updateAnilistPostWatchProgress(
|
||||
|
||||
return {
|
||||
status: 'updated',
|
||||
message: `AniList updated "${picked.title}" to episode ${episode}.`,
|
||||
message: shouldMarkCompleted
|
||||
? `AniList updated "${picked.title}" to episode ${episode} and marked it completed.`
|
||||
: `AniList updated "${picked.title}" to episode ${episode}.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
JimakuMediaInfo,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuFieldGroupingRequestData,
|
||||
OverlayNotificationPayload,
|
||||
} from '../../types';
|
||||
import { sortJimakuFiles } from '../../jimaku/utils';
|
||||
import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc';
|
||||
@@ -40,6 +41,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -103,6 +105,8 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
options.createFieldGroupingCallback(),
|
||||
options.getKnownWordCacheStatePath(),
|
||||
mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig,
|
||||
undefined,
|
||||
options.showOverlayNotification,
|
||||
);
|
||||
integration.start();
|
||||
options.setAnkiIntegration(integration);
|
||||
|
||||
@@ -43,8 +43,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
|
||||
@@ -2,6 +2,10 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup';
|
||||
|
||||
function waitTurn(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
const calls: string[] = [];
|
||||
const deps = {
|
||||
@@ -277,20 +281,80 @@ test('runAppReadyRuntime does not await background warmups', async () => {
|
||||
releaseWarmup();
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime starts background warmups before core runtime services', async () => {
|
||||
test('runAppReadyRuntime handles managed background initial args before deferred Yomitan wait', async () => {
|
||||
const calls: string[] = [];
|
||||
const { deps } = makeDeps({
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('startBackgroundWarmups');
|
||||
},
|
||||
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
||||
createMpvClient: () => calls.push('createMpvClient'),
|
||||
let releaseYomitan!: () => void;
|
||||
const yomitanGate = new Promise<void>((resolve) => {
|
||||
releaseYomitan = resolve;
|
||||
});
|
||||
const { deps } = makeDeps({
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true,
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('loadYomitanExtension:start');
|
||||
await yomitanGate;
|
||||
calls.push('loadYomitanExtension:done');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('handleFirstRunSetup');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handleInitialArgs');
|
||||
},
|
||||
} as Partial<AppReadyRuntimeDeps>);
|
||||
|
||||
const readyPromise = runAppReadyRuntime(deps);
|
||||
await waitTurn();
|
||||
|
||||
try {
|
||||
assert.ok(calls.includes('handleFirstRunSetup'));
|
||||
assert.ok(calls.includes('handleInitialArgs'));
|
||||
assert.equal(calls.includes('loadYomitanExtension:done'), false);
|
||||
} finally {
|
||||
releaseYomitan();
|
||||
await readyPromise;
|
||||
}
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime keeps non-managed deferred overlay startup behind Yomitan readiness', async () => {
|
||||
const calls: string[] = [];
|
||||
let releaseYomitan!: () => void;
|
||||
const yomitanGate = new Promise<void>((resolve) => {
|
||||
releaseYomitan = resolve;
|
||||
});
|
||||
const { deps } = makeDeps({
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => false,
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('loadYomitanExtension:start');
|
||||
await yomitanGate;
|
||||
calls.push('loadYomitanExtension:done');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handleInitialArgs');
|
||||
},
|
||||
} as Partial<AppReadyRuntimeDeps>);
|
||||
|
||||
const readyPromise = runAppReadyRuntime(deps);
|
||||
await waitTurn();
|
||||
|
||||
assert.equal(calls.includes('handleInitialArgs'), false);
|
||||
|
||||
releaseYomitan();
|
||||
await readyPromise;
|
||||
|
||||
assert.ok(calls.indexOf('loadYomitanExtension:done') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime starts background warmups after overlay startup', async () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('loadSubtitlePosition'));
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('createMpvClient'));
|
||||
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => {
|
||||
|
||||
@@ -49,8 +49,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
togglePrimarySubtitleBar: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
playbackFeedback: undefined,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
@@ -252,6 +251,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
showPlaybackFeedback: (text) => {
|
||||
calls.push(`feedback:${text}`);
|
||||
},
|
||||
log: (message) => {
|
||||
calls.push(`log:${message}`);
|
||||
},
|
||||
@@ -493,6 +495,15 @@ test('handleCliCommand reports async mine errors to OSD', async () => {
|
||||
assert.ok(osd.some((value) => value.includes('Mine sentence failed: boom')));
|
||||
});
|
||||
|
||||
test('handleCliCommand routes playback feedback through configured feedback surface', () => {
|
||||
const { deps, calls, osd } = createDeps();
|
||||
|
||||
handleCliCommand(makeArgs({ playbackFeedback: 'You can skip by pressing TAB' }), 'initial', deps);
|
||||
|
||||
assert.deepEqual(calls, ['initializeOverlayRuntime', 'feedback:You can skip by pressing TAB']);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleCliCommand applies socket path and connects on start', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface CliCommandServiceDeps {
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
showMpvOsd: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
log: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
@@ -128,6 +129,7 @@ interface MpvCliRuntime {
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
getClient: () => MpvClientLike | null;
|
||||
showOsd: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
}
|
||||
|
||||
interface TexthookerCliRuntime {
|
||||
@@ -295,6 +297,7 @@ export function createCliCommandDepsRuntime(
|
||||
hasMainWindow: options.app.hasMainWindow,
|
||||
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
||||
showMpvOsd: options.mpv.showOsd,
|
||||
showPlaybackFeedback: options.mpv.showPlaybackFeedback,
|
||||
log: options.log,
|
||||
logDebug: options.logDebug,
|
||||
warn: options.warn,
|
||||
@@ -534,18 +537,9 @@ export function handleCliCommand(
|
||||
'playNextSubtitle',
|
||||
'Play next subtitle failed',
|
||||
);
|
||||
} else if (args.shiftSubDelayPrevLine) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'shiftSubDelayPrevLine' },
|
||||
'shiftSubDelayPrevLine',
|
||||
'Shift subtitle delay failed',
|
||||
);
|
||||
} else if (args.shiftSubDelayNextLine) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'shiftSubDelayNextLine' },
|
||||
'shiftSubDelayNextLine',
|
||||
'Shift subtitle delay failed',
|
||||
);
|
||||
} else if (args.playbackFeedback) {
|
||||
const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd;
|
||||
showFeedback(args.playbackFeedback);
|
||||
} else if (args.cycleRuntimeOptionId !== undefined) {
|
||||
dispatchCliSessionAction(
|
||||
{
|
||||
|
||||
@@ -56,6 +56,7 @@ const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitle
|
||||
|
||||
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'secondarySub.defaultMode',
|
||||
'mpv.aniskipEnabled',
|
||||
'mpv.aniskipButtonKey',
|
||||
'ankiConnect.ai.enabled',
|
||||
'stats.toggleKey',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user