Compare commits

..

46 Commits

Author SHA1 Message Date
sudacode 6d56f72755 feat(plugin): route restart feedback through playback feedback surface
- Show overlay loading OSD spinner during restart and keep it alive until overlay reports ready
- Route restart progress/completion through notify_playback_feedback so overlay/both modes display in overlay
- Delay "Restarted successfully" until show-visible-overlay completes
- Add test-plugin-restart-feedback.lua covering the restart feedback flow
2026-06-10 01:55:16 -07:00
sudacode a26f10fff0 docs: document --playback-feedback CLI flag
- Add flag to usage.md CLI reference and notes section
- Add cross-reference in configuration.md notification surface section
2026-06-10 01:09:49 -07:00
sudacode 50b6226a7b feat(aniskip): route skip prompts and results through playback feedback
- Add --playback-feedback CLI flag; overlay/both modes show AniSkip hint and skip result on overlay instead of raw OSD
- Wire notify_playback_feedback through process.lua and the full deps chain to showPlaybackFeedback
- Fallback to OSD when binary unavailable or osd_messages opt enabled
2026-06-10 00:12:02 -07:00
sudacode 2a4fdb74e4 fix(notifications): stabilize overlay startup and macOS hover passthroug
- Skip re-append for cards already in stack to avoid replaying enter animation
- Track enter animation end to remove `entering` class after first render
- Gate mouseenter interactive state on cards with explicit actions only
- Bind interactive hover only to action buttons and their close buttons
- Add regression tests for passive vs action-bearing notification hover
2026-06-10 00:09:06 -07:00
sudacode 5cfb2c75ba fix(anki): honor runtime AnkiConnect URL override in Open in Anki fallba
- Use getEffectiveAnkiConnectConfig for fallback client URL resolution
- Fall back to DEFAULT_CONFIG.ankiConnect.url when effective URL is unset
2026-06-10 00:09:06 -07:00
sudacode a15fb39847 feat(notifications): add Open in Anki action and in-place progress updat
- Add openNoteInBrowser to AnkiConnectClient via guiBrowse IPC
- Add Open in Anki action button to mined-card overlay notifications and history entries
- Fall back to a direct AnkiConnectClient when the live integration is unavailable
- Embed notification images as base64 data URIs so history panel shows thumbnails
- Update same-id progress notifications in place to avoid spinner flicker
- Thread noteId through IPC overlay notification action payload
2026-06-10 00:09:06 -07:00
sudacode ce52973765 fix(shortcuts): use CommandOrControl+N for notification history toggle
- Replaces Ctrl+N default with CommandOrControl+N so the binding works on macOS (Cmd+N) and Windows/Linux (Ctrl+N)
- Updates config example, docs, changelog, and default config to match
- Adds !important to notification stack z-index to prevent stacking-context overrides
2026-06-10 00:09:05 -07:00
sudacode 361c0f1334 fix(notifications): gate overlay delivery on visible overlay; default to
- Default notificationType fallback changed from 'overlay' to 'osd'
- isVisibleOverlayContentReady guards on overlay visible + window ready
- All overlay hide paths dismiss loading OSD notification
- notifyConfiguredStatus falls back to desktop when overlay not ready
- anilist deps builder preserves undefined optional callbacks as undefined
- settingsEnumValues field added to ConfigOptionRegistryEntry
- Drop !important from z-index; lower yomitan popup z-index below notification stack
2026-06-10 00:09:05 -07:00
sudacode 740a5b07cb fix(update): separate in-flight dedup keys for manual check vs install
- manual:check and manual:install now tracked independently; install no longer reuses a check's in-flight promise
- add toggleNotificationHistory (Ctrl+N) shortcut to config example and docs
2026-06-10 00:09:05 -07:00
sudacode cf16587547 feat(overlay): add loading OSD spinner and queue notifications until ren
- Show mpv OSD spinner from start-file until subminer-overlay-loading-ready; force-shown for visible-overlay startup regardless of osd_messages setting
- Gate non-macOS overlay visibility on content-ready so first subtitle line is immediately hoverable and clickable
- Queue startup notifications in main process until overlay window finishes loading; upsert progress cards by id to avoid cold-start floods
- Defer background warmups until after overlay runtime init so queued notifications can deliver promptly
- Preserve character dictionary checking/building/importing/ready phases as distinct history entries; route building and importing to system notifications when notificationType is both
2026-06-10 00:09:05 -07:00
sudacode 8111deac44 feat(notifications): add notification history panel and overlay UX fixes
- New toggleNotificationHistory (Ctrl+N) session-scoped history panel; slides in from same edge as notification stack
- Overlay error/recovery toast follows notifications.overlayPosition; stack and history side seeded at startup
- Cold managed background startup initializes tray and visible overlay shell before tokenization warmups finish
- Add Update button to overlay update-available notifications
- Fix Ctrl+S sentence-card flow: only Anki progress notification, no duplicate status toast
- Fix overlay notification close/actions clickability above subtitle bars on Linux
- Increase pause-until-ready default timeout from 15s to 30s
2026-06-10 00:07:57 -07:00
sudacode a34ec049a2 fix(startup): release autoplay gate before first subtitle line
- Send synthetic `__warm__` payload when no current subtitle exists so the gate can release without waiting for a subtitle event that can't fire while paused
- Visible-overlay readiness accepts `__warm__` once the overlay is content-ready, rejects it otherwise
- Autoplay gate self-retries via scheduled polling when signal target isn't ready, removing reliance on an external flush event
- Skip duplicate desktop notification when overlay or startup sequencer already delivered it
2026-06-10 00:06:24 -07:00
sudacode 1fe9bdc198 fix(startup): signal autoplay gate from subtitle resolve; dedupe dict sy
- Add onResolvedSubtitle callback to resolveCurrentSubtitleForRenderer so the startup overlay-ready gate fires after the initial subtitle resolves
- Guard scheduleCharacterDictionarySync behind a last-path check so duplicate MPV media-path events don't re-trigger sync for the same video
2026-06-10 00:06:24 -07:00
sudacode f675ef5b02 feat(notifications): auto-dismiss loading OSD on overlay visibility chan
- add dismissOverlayLoadingOsd dep and call it on hide/show paths (macOS only)
- simplify notification card styles: remove accent bar, flatten gradient bg, tweak spacing
- fix test CSS path to use __dirname instead of process.cwd()
2026-06-10 00:06:24 -07:00
sudacode 486f682563 fix(notifications): reserve grid space for overlay thumbnail so it can't overlap text
The thumbnail was 100px wide but its grid column only reserved 56px, so on
macOS the image spilled ~44px into the content column and overlapped the
title/body. Reserve a minmax(0, 100px) track for the image and make the
image fluid (width: 100%; max-width: 100px; aspect-ratio) so it shrinks to
fit on narrow notifications instead of overlapping the text.
2026-06-10 00:06:24 -07:00
sudacode b177a2fb4d feat(notifications): add enter/leave animations and DOM-reuse for overla
- Direction-aware slide animations (right/left/top) on enter and leave
- Cards keyed by id so re-renders reuse elements; enter animation only plays once
- Exit animates via .leaving class then removes; fallback timer guards missing animationend
- Respects prefers-reduced-motion
2026-06-10 00:06:24 -07:00
sudacode d07a3b7d55 fix(notifications): widen overlay notification thumbnail to 100px 2026-06-10 00:06:24 -07:00
sudacode e84825754b fix(notifications): show thumbnail image in overlay mined-card notificat
- Share generated notification icon between overlay and system notification paths
- Add `image` field to OverlayNotificationPayload; render as IMG with has-image layout
- Widen overlay stack to 420px; enlarge card padding and min-height for image variant
- Show OSD message after successful anilist retry when attempt key already handled
2026-06-10 00:06:24 -07:00
sudacode 88fa9ba8b5 refactor(notifications): extract routing predicates and fix pre-overlay
- Extract shouldShowOsd/Overlay/Desktop into notification-routing.ts (was duplicated in 3 files)
- Add resolveOverlayReadinessNotificationType: preserves system channel when overlay not ready (both→osd-system, system→system, overlay→osd)
- Route overlay loading status through showConfiguredStatusNotification instead of raw OSD
2026-06-10 00:06:24 -07:00
sudacode 144373db52 feat(notifications): add overlay notifications with position config
- Add Catppuccin Macchiato overlay notification stack with 3s transient timeout
- Add `notifications.overlayPosition` config (top-left | top | top-right)
- Route startup tokenization and subtitle annotation status through configured surfaces
- Deduplicate rapid subtitle mode toggle notifications
- Change `both` to mean overlay + system; add `osd-system` as legacy alias for old behavior
- Keep `osd`/`osd-system` as config-file-only legacy values; Settings UI offers overlay/system/both/none
2026-06-10 00:06:24 -07:00
sudacode c09d009a3e docs: add ani-skip to credits table in README 2026-06-10 00:01:51 -07:00
sudacode 2007e28be8 feat(aniskip): move intro detection from mpv plugin to app runtime (#117) 2026-06-09 23:55:43 -07:00
sudacode d5bfdcae7b fix(stats): repair legacy combined-season anime rows on startup (#116) 2026-06-09 12:41:07 -07:00
sudacode 311f1e8ee5 feat(stats): speed up session maintenance and improve stats UI (#111) 2026-06-08 02:20:52 -07:00
sudacode e6a16a069b fix(anilist): mark entry completed when final episode is reached (#115) 2026-06-07 23:45:09 -07:00
Autumn (Bee) af67c53dd6 [codex] Restart Jellyfin remote session after setup login (#112) 2026-06-06 11:52:16 -07:00
sudacode ea79e331fa build: make deps initializes submodules before installing JS deps
- Add `submodules` target that runs `git submodule update --init --recursive`
- `deps` now depends on `submodules` so fresh checkouts work out of the box
- Update docs to replace manual install steps with `make deps`
- Fix Windows build-from-source steps to include stats and submodule init
2026-06-06 01:25:01 -07:00
sudacode ee89b0c8a9 feat(release): add contributor attribution to release notes (#114) 2026-06-06 01:07:47 -07:00
sudacode f2fd58cd2b docs(changelog): require reconciled fragments, not just new ones (#113) 2026-06-06 00:55:34 -07:00
sudacode 1280a30216 chore(release): prepare 0.15.2 2026-06-02 23:45:03 -07:00
sudacode a80ed72b2d docs: replace em-dashes with hyphens across docs-site 2026-06-02 23:36:44 -07:00
sudacode 4cc6c12dc7 chore(vendor): update subminer-yomitan submodule (#109) 2026-06-02 00:37:45 -07:00
sudacode 425004879a fix(anki): align animated AVIF clip bounds to frame boundaries (#108) 2026-06-01 15:20:06 -07:00
sudacode 76f99e6518 fix(overlay): correct Hyprland fullscreen overlay alignment on Linux (#107) 2026-06-01 02:12:16 -07:00
sudacode f1e260e996 fix(overlay): fix macOS overlay interactivity and focus after autoplay (#106) 2026-06-01 01:34:27 -07:00
sudacode 54e90754ef chore: release 0.15.1 2026-05-31 22:40:40 -07:00
sudacode 487143802a feat(keybindings): add mouse button support for mpv keybindings (#103) 2026-05-31 22:22:38 -07:00
sudacode e6a004ab8b Fix Windows mpv shortcut attachment to background app (#105) 2026-05-31 21:46:00 -07:00
sudacode b510c54875 fix(overlay): restore mpv focus and pointer state on macOS (#104) 2026-05-31 21:25:04 -07:00
sudacode e1ea464bc9 fix(overlay): Linux X11/XWayland stacking, stale pause state, multi-copy selector (#101) 2026-05-31 20:59:18 -07:00
sudacode b46b8dfa41 chore: add issue forms and expand PR template (#100) 2026-05-30 23:50:00 -07:00
sudacode ca067a6ccf Add FUNDING.yml 2026-05-30 20:15:58 -07:00
sudacode d719b346e0 fix(overlay): use Lua dispatch syntax for Hyprland 0.55+ Lua configs (#99) 2026-05-29 00:13:31 -07:00
sudacode a1da3dcdc8 docs(troubleshooting): fix Hyprland rules, add character dictionary + see also
Rewrite Hyprland overlay window-rule guidance with current Lua (hl.window_rule)
config and legacy hyprland.conf syntax, and note SubMiner's automatic hyprctl
placement. Add a Character Dictionary troubleshooting section (no AniList auth
required) and a See Also index linking each feature's own troubleshooting page.
2026-05-28 23:53:07 -07:00
sudacode 9927ef1581 docs(character-dictionary): correct auth requirement and add portrait do
- AniList auth not required for character dictionary; uses public GraphQL
- Document nameMatchImagesEnabled and inline portrait behavior
- Clarify AniList auth is only for watch-progress sync
- Delete stale release/release-notes.md
2026-05-28 23:30:19 -07:00
sudacode 791c993870 docs: reformat changelog entries as nested bullet lists
- Convert flat prose entries in CHANGELOG.md and docs-site/changelog.md to bold headers + sub-bullets
- Scope artifact uploads in release/prerelease workflows to `latest*.yml` instead of `*.yml`
- Update release-notes and RELEASING docs to match
- Adjust workflow tests for new nested bullet format
2026-05-28 22:53:22 -07:00
411 changed files with 26013 additions and 3307 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [ksyasuda]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: sudacode
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+105
View File
@@ -0,0 +1,105 @@
name: Bug Report
description: Report something that is broken or behaving incorrectly
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a bug report! Please search [existing issues](https://github.com/ksyasuda/SubMiner/issues?q=is%3Aissue) first to avoid duplicates.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: A clear description of the bug, including what you expected to happen instead.
placeholder: When I open the Yomitan popup, the overlay freezes...
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Minimal, ordered steps that reliably trigger the bug.
placeholder: |
1. Launch `subminer`
2. Play a video in MPV
3. Hover a word and press ...
4. See error
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area
description: Which part of SubMiner is affected?
options:
- Overlay / Yomitan popup
- Anki mining
- Subtitle annotations
- Subtitle sidebar
- Immersion tracking / stats
- Launcher / CLI
- MPV plugin
- Jellyfin integration
- Jimaku integration
- AniList integration
- YouTube integration
- Character dictionary
- WebSocket / texthooker API
- Configuration
- Documentation
- Other / not sure
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options:
- Linux
- macOS
- Windows
validations:
required: true
- type: input
id: version
attributes:
label: SubMiner version
description: Output of `subminer --version`, or the release tag / commit you are running.
placeholder: v0.15.0
validations:
required: true
- type: input
id: compositor
attributes:
label: Compositor (Linux only)
description: SubMiner's overlay supports Hyprland and sway. Name yours (and version if known). Leave blank on macOS / Windows.
placeholder: Hyprland 0.55
validations:
required: false
- type: input
id: mpv-version
attributes:
label: MPV version
description: Output of `mpv --version` (first line).
placeholder: mpv 0.38.0
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs / console output
description: |
Relevant logs. For verbose output, run `electron . --dev --log-level debug`.
This will be rendered as code automatically — no backticks needed.
render: shell
validations:
required: false
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://docs.subminer.moe
about: Setup, configuration, and feature docs — check here before filing an issue.
- name: Troubleshooting guide
url: https://docs.subminer.moe/troubleshooting
about: Common problems and fixes (Hyprland rules, MPV detection, Anki connection, etc.).
@@ -0,0 +1,59 @@
name: Feature Request
description: Suggest a new feature or an improvement to an existing one
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for the idea! Please search [existing issues](https://github.com/ksyasuda/SubMiner/issues?q=is%3Aissue) first to avoid duplicates.
- type: textarea
id: problem
attributes:
label: Problem / motivation
description: What problem are you trying to solve? What is missing or frustrating today?
placeholder: When mining a card I have to manually switch to Anki because...
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: Describe the feature or change you'd like to see.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Related area
description: Which part of SubMiner does this relate to?
options:
- Overlay / Yomitan popup
- Anki mining
- Subtitle annotations
- Subtitle sidebar
- Immersion tracking / stats
- Launcher / CLI
- MPV plugin
- Jellyfin integration
- Jimaku integration
- AniList integration
- YouTube integration
- Character dictionary
- WebSocket / texthooker API
- Configuration
- Documentation
- Other / new area
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Any workarounds you currently use or other approaches you've thought about.
validations:
required: false
+34 -1
View File
@@ -1,3 +1,36 @@
<!--
Thanks for contributing to SubMiner! Fill out the sections below.
Keep it short — a couple of sentences per section is fine.
-->
## Summary
<!-- What does this PR do and why? -->
## Type of change
<!-- Check all that apply. -->
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / internal
- [ ] Documentation
- [ ] Other
## Related issues
<!-- e.g. "Closes #123". Delete if none. -->
## How was this tested?
<!--
Describe verification. The default handoff gate is:
bun run typecheck && bun run test:fast && bun run test:env && bun run build && bun run test:smoke:dist
If docs-site/ changed, also: bun run docs:test && bun run docs:build
-->
## Checklist ## Checklist
- [ ] Added a changelog fragment in `changes/`, or this PR is labeled `skip-changelog` - [ ] 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)
+5 -5
View File
@@ -148,7 +148,7 @@ jobs:
name: appimage name: appimage
path: | path: |
release/*.AppImage release/*.AppImage
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
if-no-files-found: error if-no-files-found: error
@@ -226,7 +226,7 @@ jobs:
path: | path: |
release/*.dmg release/*.dmg
release/*.zip release/*.zip
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
if-no-files-found: error if-no-files-found: error
@@ -279,7 +279,7 @@ jobs:
path: | path: |
release/*.exe release/*.exe
release/*.zip release/*.zip
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
if-no-files-found: error if-no-files-found: error
@@ -353,7 +353,7 @@ jobs:
- name: Generate checksums - name: Generate checksums
run: | run: |
shopt -s nullglob shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/*.yml release/*.blockmap dist/launcher/subminer) files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer)
if [ "${#files[@]}" -eq 0 ]; then if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation." echo "No release artifacts found for checksum generation."
exit 1 exit 1
@@ -389,7 +389,7 @@ jobs:
release/*.exe release/*.exe
release/*.zip release/*.zip
release/*.tar.gz release/*.tar.gz
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
release/SHA256SUMS.txt release/SHA256SUMS.txt
dist/launcher/subminer dist/launcher/subminer
+5 -5
View File
@@ -139,7 +139,7 @@ jobs:
name: appimage name: appimage
path: | path: |
release/*.AppImage release/*.AppImage
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
build-macos: build-macos:
@@ -216,7 +216,7 @@ jobs:
path: | path: |
release/*.dmg release/*.dmg
release/*.zip release/*.zip
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
build-windows: build-windows:
@@ -268,7 +268,7 @@ jobs:
path: | path: |
release/*.exe release/*.exe
release/*.zip release/*.zip
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
if-no-files-found: error if-no-files-found: error
@@ -342,7 +342,7 @@ jobs:
- name: Generate checksums - name: Generate checksums
run: | run: |
shopt -s nullglob shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/*.yml release/*.blockmap dist/launcher/subminer) files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer)
if [ "${#files[@]}" -eq 0 ]; then if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation." echo "No release artifacts found for checksum generation."
exit 1 exit 1
@@ -396,7 +396,7 @@ jobs:
release/*.exe release/*.exe
release/*.zip release/*.zip
release/*.tar.gz release/*.tar.gz
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
release/SHA256SUMS.txt release/SHA256SUMS.txt
dist/launcher/subminer dist/launcher/subminer
+1 -1
View File
@@ -68,7 +68,7 @@ Start here, then leave this file.
## Release / PR Notes ## 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 - User-visible docs changes get a `type: docs` fragment
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check` - CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
- PR review helpers: - PR review helpers:
+158 -48
View File
@@ -1,71 +1,181 @@
# Changelog # Changelog
## v0.15.2 (2026-06-02)
### Changed
- Yomitan: Updated the bundled Yomitan build to the latest vendored revision.
### Fixed
- Anki - Animated AVIF: Clip timing no longer starts or ends early; word-audio lead-in and clip duration are now aligned to frame boundaries.
- Overlay (Hyprland): Fixed fullscreen overlay alignment - modal, stats, and sidebar content no longer shift below the mpv window.
- 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)
### Fixed
- **Linux Overlay Stacking**: Fixed the overlay intermittently dropping behind mpv on KDE Plasma and other non-Hyprland/Sway Wayland sessions; restored subtitle hover, pause-on-hover, and Yomitan lookups on X11/XWayland; the overlay now correctly layers above/below mpv based on fullscreen state, yields to foreground windows (Settings, Yomitan, AniList, etc.), and avoids startup flashes and fullscreen transition glitches.
- **Linux Overlay (Hyprland Lua)**: Fixed overlay placement on Hyprland 0.55+ when using a Lua-based config.
- **Manual Overlay Startup**: Fixed manual visible-overlay startup from mpv - now correctly attaches to playback, keeps the window bounds synced with mpv, and primes current subtitles before showing.
- **Playlist Transitions**: Reused the warm overlay when mpv advances to the next playlist item, avoiding a redundant tokenization pause and preserving visible subtitles across tracks.
- **macOS Overlay**: Fixed the visible subtitle overlay staying click-through after pause-until-ready releases playback; restored mpv focus after closing modal windows so subtitles and keybinds resume without clicking the player.
- **Mouse Keybindings**: Fixed keybinding capture and runtime handling for mouse buttons, including side buttons like `MBTN_BACK` and `MBTN_FORWARD`.
- **Windows mpv Shortcut**: Fixed the Windows `SubMiner mpv` shortcut so videos attach to an already-running background app instead of spawning a second process.
### Docs
- **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) ## v0.15.0 (2026-05-29)
### Breaking Changes ### Breaking Changes
- Subsync: The `subsync.defaultMode` config option has been removed; Subsync now always opens the manual subtitle picker regardless of any previously set default mode. - **Subsync:**
- The `subsync.defaultMode` config option has been removed
- Subsync now always opens the manual subtitle picker regardless of any previously set default mode
- **N+1 Highlighting:**
- N+1 highlighting now has its own dedicated `ankiConnect.nPlusOne.enabled` option, separate from known-word highlighting
- It is no longer enabled automatically when known-word highlighting is on - enable it explicitly to keep N+1 annotations
### Added ### Added
- Auto-Updater: Adds tray and `subminer -u` update checks with app update prompts, launcher and Linux rofi theme auto-updates, checksum verification, configurable notifications, and an opt-in prerelease channel via `updates.channel: "prerelease"`. - **Auto-Updater:**
- Settings Window: New dedicated Settings window via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections; click-to-learn keybinding controls including the AniSkip button key; AnkiConnect-backed deck, field, and note-type pickers that auto-fill from the configured Anki deck; cross-category search; and live save for most options including subtitle CSS, stats keys, logging level, Jimaku, Subsync, and Anki mappings. AI and translation settings remain config-file only. - Tray and `subminer -u` update checks with app update prompts
- Inline Character Portraits: Optional AniList character portraits appear inline for name-matched subtitle text; manual AniList overrides scoped per parent media directory so separate season folders maintain separate character dictionary selections. - Launcher and Linux rofi theme auto-updates
- Log Export: Sanitized log ZIP export from the tray menu and via `subminer logs -e`, with home-directory usernames redacted from exported contents. - Checksum verification and configurable notifications
- Launcher CLI: `subminer --version` / `subminer -v` prints the installed app version; `mpv.profile` config and Settings support passes a named mpv profile to managed launches; bundled mpv plugin startup options are now configurable from SubMiner config. - Opt-in prerelease channel via `updates.channel: "prerelease"`
- First-Run Setup: Optional installer for Bun and the `subminer` CLI on Linux, macOS, and Windows, including a Windows `subminer.cmd` PATH shim so `subminer` works without manually adding `SubMiner.exe` to PATH; setup recognizes existing Homebrew or user PATH installs and avoids writing into Homebrew-owned paths; includes an Open SubMiner Settings button; standalone setup app quits after completing, returning terminal control. - **Settings Window:**
- Primary Subtitle Visibility on Yomitan Popup: New `subtitleStyle.primaryVisibleOnYomitanPopup` option keeps hover-mode primary subtitles visible while a Yomitan popup is open. - New dedicated Settings window via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections
- Click-to-learn keybinding controls
- AnkiConnect-backed deck, field, and note-type pickers that auto-fill from the configured Anki deck
- Cross-category search
- Live save for most options including subtitle CSS, stats keys, logging level, Jimaku, Subsync, and Anki mappings
- AI and translation settings remain config-file only
- **Inline Character Portraits:**
- Optional AniList character portraits appear inline for name-matched subtitle text
- Manual AniList overrides scoped per parent media directory so separate season folders maintain separate character dictionary selections
- **Character Dictionary Manager:** New `Ctrl/Cmd+D` manager modal to remove, reorder, or override loaded entries.
- **Log Export:** Sanitized log ZIP export from the tray menu and via `subminer logs -e`, with home-directory usernames redacted from exported contents.
- **Launcher CLI:**
- `subminer --version` / `subminer -v` prints the installed app version
- `mpv.profile` config and Settings support passes a named mpv profile to managed launches
- Bundled mpv plugin startup options are now configurable from SubMiner config
- **First-Run Setup:**
- Optional installer for Bun and the `subminer` CLI on Linux, macOS, and Windows
- Windows `subminer.cmd` PATH shim so `subminer` works without manually adding `SubMiner.exe` to PATH
- Setup recognizes existing Homebrew or user PATH installs and avoids writing into Homebrew-owned paths
- Includes an Open SubMiner Settings button
- Standalone setup app quits after completing, returning terminal control
- **Primary Subtitle Visibility on Yomitan Popup:** New `subtitleStyle.primaryVisibleOnYomitanPopup` option keeps hover-mode primary subtitles visible while a Yomitan popup is open.
### Changed ### Changed
- Subtitle Appearance Config: Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`; known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`; subtitle font defaults updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`. Existing configs migrate automatically; legacy Anki color keys still accepted with deprecation warnings. - **Subtitle Appearance Config:**
- Subtitle Style Defaults: Stronger outline-style text shadow, thicker JLPT underlines, and frequency `topX` default raised to `10000`. - Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`
- Character Dictionary: Entries scoped to the current AniList media for name matching and inline portraits; generates Japanese-only name aliases so raw romanized/English aliases no longer surface as separate results; new `Ctrl/Cmd+D` manager modal to remove, reorder, or override loaded entries; in-app AniList selector waits for an explicit search with the box prefilled from the current filename; `subtitleStyle.nameMatchEnabled` is now the sole switch for dictionary sync and builds. - Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`
- Electron Runtime: Updated from 39.8.6 to 42.2.0, returning SubMiner to a supported Electron release line. - Subtitle font defaults updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`
- N+1 Highlighting Default: `ankiConnect.nPlusOne.enabled` is no longer implicitly enabled when known-word highlighting is on; existing configs that already had N+1 enabled are unchanged, but new configs must set it explicitly. - Existing configs migrate automatically; legacy Anki color keys still accepted with deprecation warnings
- Linux Auto-Update Flow: Linux tray "Check for Updates" now installs the new AppImage automatically, matching macOS and Windows; AppImages managed by a system package (e.g. AUR) and non-AppImage launches still use the GitHub-asset flow. - **Subtitle Style Defaults:**
- Jellyfin Setup: Removed the server presets dropdown; setup now shows a single editable server URL field. - Stronger outline-style text shadow
- Jellyfin Cast Identity: Device identity now derived from the OS hostname and always reported as SubMiner; previously configurable identity fields are ignored, preventing multiple installs from sharing a remote-session identity. - Thicker JLPT underlines
- Startup Defaults: Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off. - Frequency `topX` default raised to `10000`
- Setup Appearance: Removed the bundled mpv runtime plugin readiness card from the setup flow. - **Character Dictionary:**
- Entries scoped to the current AniList media for name matching and inline portraits
- Generates Japanese-only name aliases so raw romanized/English aliases no longer surface as separate results
- In-app AniList selector waits for an explicit search with the box prefilled from the current filename
- `subtitleStyle.nameMatchEnabled` is now the sole switch for dictionary sync and builds
- **Electron Runtime:** Updated from 39.8.6 to 42.2.0, returning SubMiner to a supported Electron release line.
- **Jellyfin Setup:**
- Removed the server presets dropdown
- Setup now shows a single editable server URL field
- **Jellyfin Cast Identity:**
- Device identity now derived from the OS hostname and always reported as SubMiner
- Previously configurable identity fields are ignored, preventing multiple installs from sharing a remote-session identity
- **Startup Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
- **Setup Appearance:** Removed the bundled mpv runtime plugin readiness card from the setup flow.
### Fixed ### Fixed
- AniList Progress: Progress updates fire correctly when playback reaches or skips past the watched threshold using fresh mpv timing events; season-specific results preferred for multi-season files with a clear message when the matched season is not in Planning or Watching; repeated missing-token checks no longer exhaust retry attempts or duplicate dead-letter entries. - **AniList Progress:**
- Anki Mining: Sentence-audio padding is opt-in by default; animated AVIF freeze-frame duration aligned to word audio length without double-counting; multi-line sentence alignment fixed for repeated subtitle text; Kiku duplicate-card detection, auto-merge, modal acknowledgment race, and field/tag ordering corrected; YouTube playback cards use mpv's resolved stream URLs; sentence cards refresh the secondary subtitle before saving; known-word cache appends correctly with multiple deck field mappings. - Progress updates fire correctly when playback reaches or skips past the watched threshold, using fresh mpv timing events
- Jellyfin Discovery: Startup, subtitle track selection, and duplicate ready-signal handling all fixed; paused mpv no longer misreported as playing; startup unpause no longer repeats after a manual pause or `y-t` toggle; delayed Japanese subtitle selection, later-loading foreign track hijacking, and long-lived sidebar ffmpeg extractor leaks fixed; resume corrected when a remote play command sends `StartPositionTicks: 0` despite saved progress; picker library discovery kept working regardless of app log level. - Season-specific results preferred for multi-season files, with a clear message when the matched season is not in Planning or Watching
- Jellyfin Remote: Tray checkbox stays in sync on Linux after tray, CLI, or startup changes; stale discovery sessions restarted when the server no longer lists the SubMiner cast target; remote controller visibility and progress sync fixed for seeks, stops, startup path changes, and Linux websocket reconnect windows; Play and Resume now behave correctly (Play from beginning, Resume from saved position); final progress reports reuse SubMiner's last known position when mpv resets on stop; Windows setup login flow fixed with an IPC bridge, immediate feedback, and a timeout with inline error for unreachable servers. - Repeated missing-token checks no longer exhaust retry attempts or duplicate dead-letter entries
- Jellyfin Subtitles and Overlay: Subtitle overlay shown automatically during Jellyfin playback; `y-t` toggle made reliable and sticky across stream redirects; managed subtitle defaults re-armed on redirect; passive Linux/Hyprland overlay shows no longer steal keyboard focus from mpv; subtitle timing improved with preferred embedded streams over external sidecars, correct Japanese-vs-English cue offset handling, per-stream delay shift restoration, and transient track-list read failure tolerance. - **Anki Mining:**
- Overlay (macOS): Overlay hides when mpv loses focus, is minimized, or is no longer the foreground app; stable through transient window geometry disappearances from macOS APIs and when clicking from the overlay back into mpv; stats overlay opened inactive so it appears over fullscreen mpv without switching Spaces; passthrough fixed so mpv controls stay clickable before hovering a subtitle bar; window-tracker polling reduced while mpv is stably focused. - Sentence-audio padding is opt-in by default
- Overlay (Linux / Hyprland): Placement refreshes after leaving fullscreen; overlay stays above mpv after focus changes from clicks or movement; Settings and Yomitan windows promoted above the subtitle overlay instead of opening behind it; overlay hides when the character dictionary modal opens, including during AniList lookup. - Animated AVIF freeze-frame duration aligned to word audio length without double-counting
- Overlay Lifecycle: First startup subtitle primed before autoplay resumes so the overlay renders text before playback begins; overlay and subtitle stream kept alive after `y-r` restart with correct Linux bounds reapplication; launcher-owned playback quits SubMiner on end while background/tray sessions stay alive; subtitle sync modal fixed on macOS so it no longer flashes on first attempt or leaves stale state; Windows managed mpv launches from a background instance now correctly receive the start command, retarget the new socket, bind to the player window, and receive startup overlay options. - Multi-line sentence alignment fixed for repeated subtitle text
- Yomitan Sidebar: Playback stays paused for sidebar-opened Yomitan popups when auto-pause is enabled; fixed popups not opening when startup races the Yomitan extension load; sidebar mining cards use audio and images from the clicked sidebar line instead of the current primary subtitle. - Kiku duplicate-card detection, auto-merge, modal acknowledgment race, and field/tag ordering corrected
- Launcher: Warm launches reuse a running background instance, reapply preferred subtitles, and close launcher-owned tray apps after playback ends; videos stay paused until subtitle priming and tokenization readiness complete; `subminer settings` on macOS exits cleanly when the window is closed; `subminer app` on Linux returns terminal control immediately; Linux first-run installs build with a valid Bun shebang; `subminer app --setup` opens the setup flow when SubMiner is already running in background. - YouTube playback cards use mpv's resolved stream URLs
- YouTube Playback: Selected subtitles downloaded to local temp files so the primary bar and sidebar read the same source, with cleanup on reload and quit; false load-failure notifications suppressed; tray icon created on launcher-managed playback that attaches to an already-running process; mpv plugin no longer starts a second SubMiner instance for app-owned YouTube playback. - Sentence cards refresh the secondary subtitle before saving
- Shortcuts: Native mpv menu shortcuts disabled during managed macOS playback so configured SubMiner shortcuts work while mpv has focus; custom session shortcuts including `stats.markWatchedKey` wired through mpv; multi-line copy/mine overlay correctly focused so number keys choose the line count on macOS and Windows. - **Jellyfin Discovery:**
- Controller Bindings: Controller config and debug shortcuts stay closed while controller support is disabled; binding learn mode starts from the edit pencil; remaps saved per controller profile; binding badges also start learn mode; row reset buttons restore individual bindings to defaults. - Startup, subtitle track selection, and duplicate ready-signal handling all fixed
- Logging: `logging.level` forwarded to launcher-started and Windows shortcut-started mpv sessions covering mpv log verbosity, plugin logging, and plugin-launched app logging; `logging.rotation` (default 7 days) and per-component `logging.files` toggles added with mpv logs disabled by default; repeated IPC socket warning spam suppressed while waiting for mpv to recreate the socket; Windows mpv IPC, subtitle track, and Yomitan diagnostics added. - Paused mpv no longer misreported as playing
- Updater: Linux `subminer -u` performs release updates independently of any running tray app using GitHub release metadata; macOS update dialogs from `subminer -u` reliably appear in the foreground with a manual-install message for builds that cannot apply native updates; macOS and Linux `electron-updater` routes through `/usr/bin/curl` to avoid Electron network crashes; Windows automatic updates keep the native NSIS install path while routing updater HTTP through main-process fetch to avoid delayed exit after launch. - Resume corrected when a remote play command sends `StartPositionTicks: 0` despite saved progress
- In-Player Stats: Layering fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window; Jellyfin playback stats grouped by item metadata so watched episodes merge with matching local library titles and keep clean display names. - **Jellyfin Remote:**
- Tray: Tray stays running when Yomitan settings are closed; settings loading no longer blocks other tray actions; Yomitan extension refreshes serialized at startup; embedded popup preview disabled to prevent renderer hangs during sidebar navigation; Windows "Open SubMiner Setup" action opens the setup window correctly after first-run is complete; session help modal close fixed without mpv running. - Tray checkbox stays in sync on Linux after tray, CLI, or startup changes
- Discord Rich Presence: No longer falls back to Jellyfin stream URLs; Jellyfin playback titles primed before stream loading so presence shows the show/episode title instead of a URL. - Remote controller visibility and progress sync fixed for seeks, stops, startup path changes, and Linux websocket reconnect windows
- WebSocket Annotations: Annotation spans and token metadata stay on the annotation WebSocket; the regular subtitle WebSocket is plain-text only. - Play and Resume now behave correctly (Play from beginning, Resume from saved position)
- Subtitle Frequency Highlighting: Frequency annotations kept for determiner-led noun compounds like `その場` while still filtering standalone determiners; fixed for Yomitan single-token compounds with internal particles such as `目の前` while keeping pure grammar/kana helper spans unannotated. - Final progress reports reuse SubMiner's last known position when mpv resets on stop
- Subtitle Annotation Prefetching: Cached colored annotations and character images ready sooner for live subtitle changes without delaying raw subtitle display. - Windows setup login flow fixed with an IPC bridge, immediate feedback, and a timeout with inline error for unreachable servers
- Packaging: macOS compiled mpv window helper correctly built into `dist/scripts` and bundled, preventing fallback to slow Swift source startup; stale Windows helper resource entry removed; one-shot `make clean build install` AppImage flows fixed so install picks up the AppImage built earlier in the same invocation. - **Overlay (macOS):**
- Windows Startup Errors: Fatal startup failures now show a native error dialog and write details to the app log instead of exiting silently. - Overlay hides when mpv loses focus, is minimized, or is no longer the foreground app
- Stays stable through transient window geometry disappearances from macOS APIs and when clicking from the overlay back into mpv
- Stats overlay opened inactive so it appears over fullscreen mpv without switching Spaces
- Passthrough fixed so mpv controls stay clickable before hovering a subtitle bar
- **Yomitan Sidebar:**
- Playback stays paused for sidebar-opened Yomitan popups when auto-pause is enabled
- Popups now open when startup races the Yomitan extension load
- Sidebar mining cards use audio and images from the clicked sidebar line instead of the current primary subtitle
- **Launcher:**
- `subminer app` on Linux returns terminal control immediately
- `subminer app --setup` opens the setup flow when SubMiner is already running in the background
- **YouTube Playback:**
- Selected subtitles downloaded to local temp files so the primary bar and sidebar read the same source, with cleanup on reload and quit
- False load-failure notifications suppressed
- Tray icon created on launcher-managed playback that attaches to an already-running process
- **Shortcuts:**
- Native mpv menu shortcuts disabled during managed macOS playback so configured SubMiner shortcuts work while mpv has focus
- Custom session shortcuts including `stats.markWatchedKey` wired through mpv
- Multi-line copy/mine overlay correctly focused so number keys choose the line count on macOS and Windows
- **Controller Bindings:**
- Controller config and debug shortcuts stay closed while controller support is disabled
- Binding learn mode starts from the edit pencil
- Remaps saved per controller profile
- Binding badges also start learn mode
- Row reset buttons restore individual bindings to defaults
- **Logging:**
- `logging.level` forwarded to launcher-started and Windows shortcut-started mpv sessions, covering mpv log verbosity, plugin logging, and plugin-launched app logging
- `logging.rotation` (default 7 days) and per-component `logging.files` toggles added, with mpv logs disabled by default
- Repeated IPC socket warning spam suppressed while waiting for mpv to recreate the socket
- Windows mpv IPC, subtitle track, and Yomitan diagnostics added
- **In-Player Stats:**
- Layering fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window
- Jellyfin playback stats grouped by item metadata so watched episodes merge with matching local library titles and keep clean display names
- **WebSocket Annotations:**
- Annotation spans and token metadata stay on the annotation WebSocket
- The regular subtitle WebSocket is plain-text only
- **Subtitle Annotation Prefetching:** Cached colored annotations and character images ready sooner for live subtitle changes without delaying raw subtitle display.
- **Windows Startup Errors:** Fatal startup failures now show a native error dialog and write details to the app log instead of exiting silently.
### Docs ### Docs
- Documentation Site: Published stable docs at the site root with current development docs under `/main/`; fixed versioned docs navigation, archived page link handling, and local dev version routing; documented all previously undocumented config options including `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options; added Playback Startup Flow and Runtime Sockets diagrams to the architecture docs with cross-reference pointers in the MPV Plugin and Troubleshooting pages. - **Documentation Site:**
- Published stable docs at the site root with current development docs under `/main/`
- Fixed versioned docs navigation, archived page link handling, and local dev version routing
- Documented all previously undocumented config options including `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options
- Added Playback Startup Flow and Runtime Sockets diagrams to the architecture docs with cross-reference pointers in the MPV Plugin and Troubleshooting pages
<details> <details>
<summary>Internal changes</summary> <summary>Internal changes</summary>
### Internal ### Internal
- Release Tooling: Release-note polishing treats pending fragments and reviewed prerelease notes as a cumulative final outcome, collapsing prerelease-only fixes into the final user-facing change; prerelease generation reuses existing reviewed notes and merges only new fragment material; `make clean` preserves `release/prerelease-notes.md`. - **Release Tooling:**
- Tests: Removed stale Yomitan vendor source-inspection assertions for changes that were not shipped. - Release-note polishing treats pending fragments and reviewed prerelease notes as a cumulative final outcome, collapsing prerelease-only fixes into the final user-facing change
- Prerelease generation reuses existing reviewed notes and merges only new fragment material
- `make clean` preserves `release/prerelease-notes.md`
- **Tests:** Removed stale Yomitan vendor source-inspection assertions for changes that were not shipped.
</details> </details>
@@ -73,7 +183,7 @@
### Added ### Added
- **Character Dictionary:** Added AniList-based character dictionary selection for resolving title mismatches open it in-app with the new `Ctrl+Alt+A` shortcut or from the CLI with `subminer dictionary --candidates` / `--select`. Series-scoped overrides replace stale entries in the merged dictionary. - **Character Dictionary:** Added AniList-based character dictionary selection for resolving title mismatches - open it in-app with the new `Ctrl+Alt+A` shortcut or from the CLI with `subminer dictionary --candidates` / `--select`. Series-scoped overrides replace stale entries in the merged dictionary.
- **Primary Subtitle Bar:** Added a `V` shortcut and mpv plugin binding to toggle the primary subtitle bar without affecting mpv's native subtitle visibility. - **Primary Subtitle Bar:** Added a `V` shortcut and mpv plugin binding to toggle the primary subtitle bar without affecting mpv's native subtitle visibility.
- **Texthooker:** Added `subminer texthooker -o` and a tray menu item to open the local texthooker page in the default browser. - **Texthooker:** Added `subminer texthooker -o` and a tray menu item to open the local texthooker page in the default browser.
@@ -127,7 +237,7 @@
### Internal ### Internal
- Replaced the changelog renderer with an AI polish pass that merges related fragments and writes user-facing release notes. `CHANGELOG.md` keeps internal items in a collapsed `<details>` block; GitHub release notes omit them entirely. - Replaced the changelog renderer with an AI polish pass that merges related fragments and writes user-facing release notes. `CHANGELOG.md` keeps internal items in a collapsed `<details>` block; GitHub release notes omit them entirely.
- Release CI no longer auto-builds pending `changes/*.md` fragments on tag. Tagging now fails fast if fragments remain run `bun run changelog:build` (requires the `claude` CLI) and commit before tagging. - Release CI no longer auto-builds pending `changes/*.md` fragments on tag. Tagging now fails fast if fragments remain - run `bun run changelog:build` (requires the `claude` CLI) and commit before tagging.
</details> </details>
@@ -146,8 +256,8 @@
- Stats: Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity. - Stats: Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity.
- Stats: Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility. - Stats: Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility.
- Stats: Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard. - Stats: Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard.
- Stats: Session timeline no longer plots seek-forward/seek-backward markers they were too noisy on sessions with lots of rewinds. - Stats: Session timeline no longer plots seek-forward/seek-backward markers - they were too noisy on sessions with lots of rewinds.
- Stats: Replaced the "Library Per Day" section on the Stats → Trends page with a "Library Summary" section. The new section shows a top-10 watch-time leaderboard chart and a sortable per-title table (watch time, videos, sessions, cards, words, lookups, lookups/100w, date range), all scoped to the current date range selector. - Stats: Replaced the "Library - Per Day" section on the Stats → Trends page with a "Library - Summary" section. The new section shows a top-10 watch-time leaderboard chart and a sortable per-title table (watch time, videos, sessions, cards, words, lookups, lookups/100w, date range), all scoped to the current date range selector.
### Fixed ### Fixed
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+7 -4
View File
@@ -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 APP_NAME := subminer
THEME_SOURCE := assets/themes/subminer.rasi THEME_SOURCE := assets/themes/subminer.rasi
@@ -72,7 +72,8 @@ help:
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \ " generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
"" \ "" \
"Other targets:" \ "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-linux Remove Linux install artifacts" \
" uninstall-macos Remove macOS install artifacts" \ " uninstall-macos Remove macOS install artifacts" \
" uninstall-windows Remove Windows mpv plugin artifacts" \ " uninstall-windows Remove Windows mpv plugin artifacts" \
@@ -105,8 +106,10 @@ print-dirs:
"MACOS_APP_SRC=$(MACOS_APP_SRC)" \ "MACOS_APP_SRC=$(MACOS_APP_SRC)" \
"MACOS_ZIP_SRC=$(MACOS_ZIP_SRC)" "MACOS_ZIP_SRC=$(MACOS_ZIP_SRC)"
deps: submodules:
@$(MAKE) --no-print-directory ensure-bun @git submodule update --init --recursive
deps: submodules ensure-bun
@bun install @bun install
@cd stats && bun install --frozen-lockfile @cd stats && bun install --frozen-lockfile
@cd vendor/texthooker-ui && bun install --frozen-lockfile @cd vendor/texthooker-ui && bun install --frozen-lockfile
+2
View File
@@ -121,6 +121,7 @@ Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but
| yt-dlp | Optional | YouTube playback | | yt-dlp | Optional | YouTube playback |
| fzf / rofi | Optional | Video picker in the launcher | | fzf / rofi | Optional | Video picker in the launcher |
| alass / ffsubsync | Optional | Subtitle sync | | alass / ffsubsync | Optional | Subtitle sync |
| guessit | Optional | Better anime title and episode detection |
<details> <details>
<summary><b>Platform-specific install commands</b></summary> <summary><b>Platform-specific install commands</b></summary>
@@ -228,6 +229,7 @@ SubMiner builds on the work of these open-source projects:
| Project | Role | | 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 | | [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 | | [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 | | [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
@@ -0,0 +1,4 @@
type: added
area: release
- Release notes now credit contributors with a `What's Changed` list (`by @author in #pr`) and a `New Contributors` section for first-time authors, resolved from changelog fragments via git and the GitHub API.
+7
View File
@@ -31,6 +31,13 @@ Rules:
- `README.md` is ignored by the generator - `README.md` is ignored by the generator
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment - if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
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: 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. - 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: fixed
area: anilist
- Marked AniList entries completed when a post-watch update reaches the final known episode of the season.
+6
View File
@@ -0,0 +1,6 @@
type: changed
area: playback
- AniSkip intro detection now runs in the SubMiner app instead of the mpv plugin: lookups cover every local file loaded during an mpv session (including playlist advances), and the plugin no longer performs any network calls.
- `mpv.aniskipEnabled` and `mpv.aniskipButtonKey` now hot-reload without restarting playback.
- AniSkip now requires the SubMiner app to be connected to mpv; plugin-only mpv sessions without the app no longer fetch skip windows.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: playback
- Fixed AniSkip intro markers disappearing after same-media mpv reloads.
- Fixed AniSkip metadata detection for intros that start at `0` seconds and common release-group filenames without `guessit`.
@@ -0,0 +1,4 @@
type: changed
area: release
- Changed PR changelog guidance to preserve multiple fragments for genuinely separate outcomes while directing contributors to update, remove, or merge same-PR fragment notes before adding follow-up churn.
@@ -0,0 +1,5 @@
type: fixed
area: jellyfin
- Restarted the Jellyfin remote session after successful setup login so websocket reconnects use the freshly saved credentials.
- Stopped the Jellyfin remote session on setup logout.
+25
View File
@@ -0,0 +1,25 @@
type: changed
area: notifications
breaking: true
- Added overlay notifications with a Catppuccin Macchiato stack, a 3-second transient timeout, and persistent long-running job notifications for character dictionary sync.
- Added `notifications.overlayPosition` to place overlay notifications at the top left, top center, or top right; top right remains the default.
- Added a notification history panel (default `Ctrl/Cmd+N`, configurable via `shortcuts.toggleNotificationHistory`) that logs every notification shown during the session; the toggle works whether the overlay or mpv has focus, the panel slides in from the same edge as notifications (right when centered), and entries can be removed individually or cleared.
- Made the overlay error/recovery toast follow the configured `notifications.overlayPosition` instead of always pinning to the top-right corner, and kept the notification stack and history panel side synced from that position before first open so left-side history panels slide in from the left.
- Routed startup tokenization, subtitle annotation, and character dictionary status through queued overlay notifications for `overlay`/`both` instead of falling back to mpv OSD while the overlay loads; queued loading cards are shown before their ready update when both happen before the overlay is ready, and the bundled mpv plugin now only emits startup OSD messages for `osd` and `osd-system`.
- Preserved character dictionary checking/building/importing/ready phases in overlay notification history and sent those phases to system notifications when `notificationType` is `both`.
- Initialized the tray and visible overlay shell before deferred tokenization warmups finish on visible-overlay startup, while keeping playback paused until SubMiner reports autoplay readiness.
- Kept playback feedback such as subtitle visibility, subtitle track, subtitle delay, and AniSkip prompt/skip text on overlay/OSD surfaces only; desktop/system notifications are reserved for real notifications like mined cards, errors, and updates.
- Routed mpv-plugin restart feedback through the configured overlay/OSD feedback surface so `overlay` and `both` notification modes show restart progress and completion in the overlay, while keeping the loading OSD spinner visible until the overlay reports ready.
- Reused the active primary/secondary subtitle mode overlay notification while cycling modes so rapid toggles update one card instead of stacking duplicate feedback.
- Updated repeated progress notifications such as subsync syncing in place so their spinner stays live instead of flickering on every tick.
- Stabilized overlay startup notifications so queued progress updates do not replay the card entrance animation or trigger macOS pass-through hover flicker after the loading OSD hands off to overlay notifications.
- Fixed mined-card overlay notifications so `overlay` and `both` modes show generated card thumbnails in both live cards and the notification history panel.
- Added Open in Anki buttons to mined-card overlay notifications and their history entries, with a direct AnkiConnect fallback when the live integration is unavailable.
- Fixed those Open in Anki buttons so their fallback honors runtime AnkiConnect URL overrides and the default AnkiConnect endpoint.
- Added an Update button to overlay update-available notifications so users can start the app update flow from the notification.
- Fixed sentence-card mining so the Ctrl+S flow shows only the Anki update progress notification instead of also stacking a generic SubMiner toast.
- Fixed overlay notification layering so notification close/actions stay clickable above subtitle bars on Linux overlays.
- Fixed character dictionary sync so duplicate MPV media-path events do not repeat check/ready notifications for the same opened video.
- Changed `both` notification routing to mean overlay + system; users who used `both` for mpv OSD + system notifications should set `notificationType` to `osd-system` in `config.jsonc`.
- Kept `osd` and `osd-system` as config-file-only legacy notification values; Settings normally offers only overlay, system, both, and none, while still showing an already configured legacy value as selected.
+6
View File
@@ -0,0 +1,6 @@
type: changed
area: stats
- Split local and Jellyfin library entries by detected season, using season folders first and filename parsing as fallback.
- Repaired older combined-series stats rows by moving parsed episodes into season-specific library entries, rebuilding summaries, and deleting now-empty legacy rows.
- Refresh anime detail and library cover art immediately after manually changing an AniList entry.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: build
- Updated `make deps` so a fresh source checkout initializes submodules before installing root, stats, and texthooker-ui dependencies.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed startup pause-until-ready so SubMiner releases playback after tokenization and overlay content are ready even when playback starts before the first subtitle line.
+9
View File
@@ -0,0 +1,9 @@
type: changed
area: stats
- Added the Stats Search tab for realtime subtitle sentence search with media context, headword matching, and mining actions for source-backed sentence cards or exact-match word/audio cards.
- Improved Stats mining from Search and vocabulary examples: empty `ankiConnect.deck` can use Yomitan's mining deck, sentence cards are created before slow media generation finishes, stored/requested secondary subtitles are preserved before falling back to sidecar files or temporary alass-retimed English sidecars for sentence Selection Text, invalid stored timings are blocked before FFmpeg runs, future out-of-order subtitle timing pairs are skipped until valid timings arrive, and partial media failures are shown.
- Fixed Stats mining field/audio behavior so sentence clips update `SentenceAudio`, word audio uses the configured Yomitan sources, English subtitle text is not written onto word cards, and secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
- Improved vocabulary review with remembered Hide Known/Hide Kana filters, cross-title Hide Kana filtering, duplicate-collapsed exclusions across token variants, and Related Seen Words matching based on shared readings or kanji.
- Reorganized the Stats Trends tab into clearer Activity, Cumulative Totals, Efficiency, Patterns, and Library sections, disambiguated per-period vs cumulative charts, and added Words/Min and Cards/Hour efficiency charts.
- Improved Stats browsing reliability by remembering library card size, retrying stored cover art without extra AniList lookups, preserving PNG/WebP cover MIME types, honoring custom AnkiConnect URLs for Browse, showing progress during session deletes, and making session deletes refresh faster.
+9
View File
@@ -0,0 +1,9 @@
type: fixed
area: overlay
- Fixed visible overlay startup/resume so subtitle bars can be hovered and clicked as soon as the first subtitle line appears, without waiting for the next subtitle update.
- Released playback after the first overlay measurement instead of waiting for cold subtitle annotation warmup, so overlay notifications and subtitle controls do not freeze during visible-overlay startup.
- Primed Linux overlay input from the first measured subtitle/notification surface before playback resumes, so first-line subtitles and startup notifications are clickable immediately.
- Restored visible-overlay loading feedback as an mpv OSD spinner that stops once the overlay is content-ready and visible.
- Starts that OSD spinner when mpv connects, opens media, or the visible overlay is requested, so cold startup shows feedback before the overlay is almost ready.
- Shows an immediate plugin-side mpv OSD on `start-file` for visible overlay startup, even when normal plugin status OSD messages are disabled or the launcher owns the overlay start, and keeps it spinning until Electron reports the visible overlay is content-ready.
+16 -6
View File
@@ -172,10 +172,19 @@
"updates": { "updates": {
"enabled": true, // Run automatic update checks in the background. Values: true | false "enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks. "checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none "notificationType": "system", // 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 "channel": "stable" // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior. }, // 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 // Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable. // 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. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "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. "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. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================
@@ -496,7 +506,7 @@
"tags": [ "tags": [
"SubMiner" "SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. ], // 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": { "fields": {
"word": "Expression", // Card field for the mined word or expression text. "word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio. "audio": "ExpressionAudio", // Card field that receives generated sentence audio.
@@ -539,7 +549,7 @@
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false "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 "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 "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 "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting. }, // Behavior setting.
"nPlusOne": { "nPlusOne": {
@@ -634,8 +644,8 @@
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false "autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false "pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path. "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 "aniskipEnabled": true, // Enable AniSkip intro detection, chapter markers, and the skip-intro key. Values: true | false
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible. "aniskipButtonKey": "TAB" // mpv key used to skip the detected intro while the skip prompt is visible.
}, // SubMiner-managed mpv launch and bundled plugin options. }, // SubMiner-managed mpv launch and bundled plugin options.
// ========================================== // ==========================================
+1
View File
@@ -328,6 +328,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'YouTube', link: '/youtube-integration' }, { text: 'YouTube', link: '/youtube-integration' },
{ text: 'Jimaku', link: '/jimaku-integration' }, { text: 'Jimaku', link: '/jimaku-integration' },
{ text: 'AniList', link: '/anilist-integration' }, { text: 'AniList', link: '/anilist-integration' },
{ text: 'AniSkip', link: '/aniskip-integration' },
{ text: 'Character Dictionary', link: '/character-dictionary' }, { text: 'Character Dictionary', link: '/character-dictionary' },
], ],
}, },
+2 -2
View File
@@ -4,7 +4,7 @@ SubMiner can sync your watch progress to [AniList](https://anilist.co) automatic
AniList data also powers two additional features: [cover art](#cover-art) for the stats dashboard and the [Character Dictionary](/character-dictionary) for in-overlay name lookup. AniList data also powers two additional features: [cover art](#cover-art) for the stats dashboard and the [Character Dictionary](/character-dictionary) for in-overlay name lookup.
[AniList](https://anilist.co) is a free website for tracking which anime you have watched. An **access token** is a private key SubMiner stores so it can update your list on your behalf you approve it once during setup, and you never paste a password into SubMiner. [AniList](https://anilist.co) is a free website for tracking which anime you have watched. An **access token** is a private key SubMiner stores so it can update your list on your behalf - you approve it once during setup, and you never paste a password into SubMiner.
## Setup ## Setup
@@ -38,7 +38,7 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
The update flow: 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. 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. 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`. 4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
+51
View File
@@ -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.
+12 -7
View File
@@ -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. 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? ::: tip New to these terms?
- **Anki** is the flashcard app where your study cards live. - **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. - **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 **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. - 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 ## Prerequisites
@@ -22,9 +23,9 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, image, and translation fields automatically. Two detection methods are available: 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). **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).
Use proxy mode if you want immediate enrichment. Use polling mode if your Yomitan instance is external (browser-based) or you prefer minimal configuration. Use proxy mode if you want immediate enrichment. Use polling mode if your Yomitan instance is external (browser-based) or you prefer minimal configuration.
@@ -36,7 +37,7 @@ In both modes, the enrichment workflow is the same:
4. Fills the translation field from the secondary subtitle or AI. 4. Fills the translation field from the secondary subtitle or AI.
5. Writes metadata to the miscInfo field. 5. Writes metadata to the miscInfo field.
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks. 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`. Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
### Proxy Mode Setup (Yomitan / Texthooker) ### 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 "overwriteImage": true, // replace existing image, or append
"mediaInsertMode": "append", // "append" or "prepend" to field content "mediaInsertMode": "append", // "append" or "prepend" to field content
"autoUpdateNewCards": true, // auto-update when new card detected "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. `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 ## AI Translation
@@ -350,7 +355,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
"overwriteImage": true, "overwriteImage": true,
"mediaInsertMode": "append", "mediaInsertMode": "append",
"autoUpdateNewCards": true, "autoUpdateNewCards": true,
"notificationType": "osd", "notificationType": "overlay",
}, },
"ai": { "ai": {
"enabled": false, "enabled": false,
+16 -16
View File
@@ -30,11 +30,11 @@ launcher/ # Standalone CLI launcher wrapper and mpv helpers
plugin/ plugin/
subminer/ # Modular mpv plugin (init · main · bootstrap · lifecycle · process subminer/ # Modular mpv plugin (init · main · bootstrap · lifecycle · process
# state · messages · hover · ui · options · environment · log # state · messages · hover · ui · options · environment · log
# binary · aniskip · aniskip_match) # binary)
src/ src/
ai/ # AI translation provider utilities (client, config) ai/ # AI translation provider utilities (client, config)
main-entry.ts # Background-mode bootstrap wrapper before loading main.js main-entry.ts # Background-mode bootstrap wrapper before loading main.js
main.ts # Entry point delegates to runtime composers/domain modules main.ts # Entry point - delegates to runtime composers/domain modules
preload.ts # Electron preload bridge preload.ts # Electron preload bridge
types.ts # Shared type definitions types.ts # Shared type definitions
main/ # Main-process composition/runtime adapters main/ # Main-process composition/runtime adapters
@@ -130,7 +130,7 @@ src/renderer/
### Launcher + Plugin Runtimes ### 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. - `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 ## Flow Diagram
@@ -226,17 +226,17 @@ Most runtime code follows a dependency-injection pattern:
The composition root (`src/main.ts`) delegates to focused modules in `src/main/` and `src/main/runtime/composers/`: The composition root (`src/main.ts`) delegates to focused modules in `src/main/` and `src/main/runtime/composers/`:
- `startup.ts` argv/env processing and bootstrap flow - `startup.ts` - argv/env processing and bootstrap flow
- `app-lifecycle.ts` Electron lifecycle event registration - `app-lifecycle.ts` - Electron lifecycle event registration
- `startup-lifecycle.ts` app-ready initialization sequence - `startup-lifecycle.ts` - app-ready initialization sequence
- `state.ts` centralized application runtime state container - `state.ts` - centralized application runtime state container
- `ipc-runtime.ts` IPC channel registration and handler wiring - `ipc-runtime.ts` - IPC channel registration and handler wiring
- `cli-runtime.ts` CLI command parsing and dispatch - `cli-runtime.ts` - CLI command parsing and dispatch
- `overlay-runtime.ts` overlay window selection and modal state management - `overlay-runtime.ts` - overlay window selection and modal state management
- `subsync-runtime.ts` subsync command orchestration - `subsync-runtime.ts` - subsync command orchestration
- `runtime/composers/anilist-tracking-composer.ts` AniList media tracking/probe/retry wiring - `runtime/composers/anilist-tracking-composer.ts` - AniList media tracking/probe/retry wiring
- `runtime/composers/jellyfin-runtime-composer.ts` Jellyfin config/client/playback/command/setup composition wiring - `runtime/composers/jellyfin-runtime-composer.ts` - Jellyfin config/client/playback/command/setup composition wiring
- `runtime/composers/mpv-runtime-composer.ts` MPV event/factory/tokenizer/warmup wiring - `runtime/composers/mpv-runtime-composer.ts` - MPV event/factory/tokenizer/warmup wiring
Composer modules share contract conventions via `src/main/runtime/composers/contracts.ts`: Composer modules share contract conventions via `src/main/runtime/composers/contracts.ts`:
@@ -271,9 +271,9 @@ For domains migrated to reducer-style transitions (for example AniList token/que
## Playback Startup Flow ## Playback Startup Flow
Before the app boots, something has to launch mpv, inject the plugin, and bring the overlay up. SubMiner-managed launches own this step the `subminer` launcher, the app's own playback, and the packaged Windows shortcut all follow the same path. The launcher reads `config.jsonc`, spawns mpv with the IPC socket and the bundled plugin, and passes runtime settings as `--script-opts`. The plugin never reads a config file: the shipped `subminer.conf` is intentionally empty so command-line opts always win. Before the app boots, something has to launch mpv, inject the plugin, and bring the overlay up. SubMiner-managed launches own this step - the `subminer` launcher, the app's own playback, and the packaged Windows shortcut all follow the same path. The launcher reads `config.jsonc`, spawns mpv with the IPC socket and the bundled plugin, and passes runtime settings as `--script-opts`. The plugin never reads a config file: the shipped `subminer.conf` is intentionally empty so command-line opts always win.
Once mpv is up, exactly one of two triggers brings up the overlay. On a first launch the plugin's `file-loaded` hook self-starts the app once the socket is ready (because the launcher injected `auto_start=yes`). When the app is already running or for explicit `--start-overlay` and YouTube flows the launcher instead attaches over the control socket and suppresses the plugin's auto-start, so the two never fire together. Both converge on the same app bring-up, which then runs the Program Lifecycle below. Once mpv is up, exactly one of two triggers brings up the overlay. On a first launch the plugin's `file-loaded` hook self-starts the app once the socket is ready (because the launcher injected `auto_start=yes`). When the app is already running - or for explicit `--start-overlay` and YouTube flows - the launcher instead attaches over the control socket and suppresses the plugin's auto-start, so the two never fire together. Both converge on the same app bring-up, which then runs the Program Lifecycle below.
```mermaid ```mermaid
flowchart TB flowchart TB
+158 -48
View File
@@ -1,71 +1,181 @@
# Changelog # Changelog
## v0.15.2 (2026-06-02)
**Changed**
- Yomitan: Updated the bundled Yomitan build to the latest vendored revision.
**Fixed**
- Anki - Animated AVIF: Clip timing no longer starts or ends early; word-audio lead-in and clip duration are now aligned to frame boundaries.
- Overlay (Hyprland): Fixed fullscreen overlay alignment - modal, stats, and sidebar content no longer shift below the mpv window.
- 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)
**Fixed**
- **Linux Overlay Stacking**: Fixed the overlay intermittently dropping behind mpv on KDE Plasma and other non-Hyprland/Sway Wayland sessions; restored subtitle hover, pause-on-hover, and Yomitan lookups on X11/XWayland; the overlay now correctly layers above/below mpv based on fullscreen state, yields to foreground windows (Settings, Yomitan, AniList, etc.), and avoids startup flashes and fullscreen transition glitches.
- **Linux Overlay (Hyprland Lua)**: Fixed overlay placement on Hyprland 0.55+ when using a Lua-based config.
- **Manual Overlay Startup**: Fixed manual visible-overlay startup from mpv - now correctly attaches to playback, keeps the window bounds synced with mpv, and primes current subtitles before showing.
- **Playlist Transitions**: Reused the warm overlay when mpv advances to the next playlist item, avoiding a redundant tokenization pause and preserving visible subtitles across tracks.
- **macOS Overlay**: Fixed the visible subtitle overlay staying click-through after pause-until-ready releases playback; restored mpv focus after closing modal windows so subtitles and keybinds resume without clicking the player.
- **Mouse Keybindings**: Fixed keybinding capture and runtime handling for mouse buttons, including side buttons like `MBTN_BACK` and `MBTN_FORWARD`.
- **Windows mpv Shortcut**: Fixed the Windows `SubMiner mpv` shortcut so videos attach to an already-running background app instead of spawning a second process.
**Docs**
- **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) ## v0.15.0 (2026-05-29)
**Breaking Changes** **Breaking Changes**
- Subsync: The `subsync.defaultMode` config option has been removed; Subsync now always opens the manual subtitle picker regardless of any previously set default mode. - **Subsync:**
- The `subsync.defaultMode` config option has been removed
- Subsync now always opens the manual subtitle picker regardless of any previously set default mode
- **N+1 Highlighting:**
- N+1 highlighting now has its own dedicated `ankiConnect.nPlusOne.enabled` option, separate from known-word highlighting
- It is no longer enabled automatically when known-word highlighting is on - enable it explicitly to keep N+1 annotations
**Added** **Added**
- Auto-Updater: Adds tray and `subminer -u` update checks with app update prompts, launcher and Linux rofi theme auto-updates, checksum verification, configurable notifications, and an opt-in prerelease channel via `updates.channel: "prerelease"`. - **Auto-Updater:**
- Settings Window: New dedicated Settings window via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections; click-to-learn keybinding controls including the AniSkip button key; AnkiConnect-backed deck, field, and note-type pickers that auto-fill from the configured Anki deck; cross-category search; and live save for most options including subtitle CSS, stats keys, logging level, Jimaku, Subsync, and Anki mappings. AI and translation settings remain config-file only. - Tray and `subminer -u` update checks with app update prompts
- Inline Character Portraits: Optional AniList character portraits appear inline for name-matched subtitle text; manual AniList overrides scoped per parent media directory so separate season folders maintain separate character dictionary selections. - Launcher and Linux rofi theme auto-updates
- Log Export: Sanitized log ZIP export from the tray menu and via `subminer logs -e`, with home-directory usernames redacted from exported contents. - Checksum verification and configurable notifications
- Launcher CLI: `subminer --version` / `subminer -v` prints the installed app version; `mpv.profile` config and Settings support passes a named mpv profile to managed launches; bundled mpv plugin startup options are now configurable from SubMiner config. - Opt-in prerelease channel via `updates.channel: "prerelease"`
- First-Run Setup: Optional installer for Bun and the `subminer` CLI on Linux, macOS, and Windows, including a Windows `subminer.cmd` PATH shim so `subminer` works without manually adding `SubMiner.exe` to PATH; setup recognizes existing Homebrew or user PATH installs and avoids writing into Homebrew-owned paths; includes an Open SubMiner Settings button; standalone setup app quits after completing, returning terminal control. - **Settings Window:**
- Primary Subtitle Visibility on Yomitan Popup: New `subtitleStyle.primaryVisibleOnYomitanPopup` option keeps hover-mode primary subtitles visible while a Yomitan popup is open. - New dedicated Settings window via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections
- Click-to-learn keybinding controls
- AnkiConnect-backed deck, field, and note-type pickers that auto-fill from the configured Anki deck
- Cross-category search
- Live save for most options including subtitle CSS, stats keys, logging level, Jimaku, Subsync, and Anki mappings
- AI and translation settings remain config-file only
- **Inline Character Portraits:**
- Optional AniList character portraits appear inline for name-matched subtitle text
- Manual AniList overrides scoped per parent media directory so separate season folders maintain separate character dictionary selections
- **Character Dictionary Manager:** New `Ctrl/Cmd+D` manager modal to remove, reorder, or override loaded entries.
- **Log Export:** Sanitized log ZIP export from the tray menu and via `subminer logs -e`, with home-directory usernames redacted from exported contents.
- **Launcher CLI:**
- `subminer --version` / `subminer -v` prints the installed app version
- `mpv.profile` config and Settings support passes a named mpv profile to managed launches
- Bundled mpv plugin startup options are now configurable from SubMiner config
- **First-Run Setup:**
- Optional installer for Bun and the `subminer` CLI on Linux, macOS, and Windows
- Windows `subminer.cmd` PATH shim so `subminer` works without manually adding `SubMiner.exe` to PATH
- Setup recognizes existing Homebrew or user PATH installs and avoids writing into Homebrew-owned paths
- Includes an Open SubMiner Settings button
- Standalone setup app quits after completing, returning terminal control
- **Primary Subtitle Visibility on Yomitan Popup:** New `subtitleStyle.primaryVisibleOnYomitanPopup` option keeps hover-mode primary subtitles visible while a Yomitan popup is open.
**Changed** **Changed**
- Subtitle Appearance Config: Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`; known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`; subtitle font defaults updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`. Existing configs migrate automatically; legacy Anki color keys still accepted with deprecation warnings. - **Subtitle Appearance Config:**
- Subtitle Style Defaults: Stronger outline-style text shadow, thicker JLPT underlines, and frequency `topX` default raised to `10000`. - Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`
- Character Dictionary: Entries scoped to the current AniList media for name matching and inline portraits; generates Japanese-only name aliases so raw romanized/English aliases no longer surface as separate results; new `Ctrl/Cmd+D` manager modal to remove, reorder, or override loaded entries; in-app AniList selector waits for an explicit search with the box prefilled from the current filename; `subtitleStyle.nameMatchEnabled` is now the sole switch for dictionary sync and builds. - Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`
- Electron Runtime: Updated from 39.8.6 to 42.2.0, returning SubMiner to a supported Electron release line. - Subtitle font defaults updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`
- N+1 Highlighting Default: `ankiConnect.nPlusOne.enabled` is no longer implicitly enabled when known-word highlighting is on; existing configs that already had N+1 enabled are unchanged, but new configs must set it explicitly. - Existing configs migrate automatically; legacy Anki color keys still accepted with deprecation warnings
- Linux Auto-Update Flow: Linux tray "Check for Updates" now installs the new AppImage automatically, matching macOS and Windows; AppImages managed by a system package (e.g. AUR) and non-AppImage launches still use the GitHub-asset flow. - **Subtitle Style Defaults:**
- Jellyfin Setup: Removed the server presets dropdown; setup now shows a single editable server URL field. - Stronger outline-style text shadow
- Jellyfin Cast Identity: Device identity now derived from the OS hostname and always reported as SubMiner; previously configurable identity fields are ignored, preventing multiple installs from sharing a remote-session identity. - Thicker JLPT underlines
- Startup Defaults: Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off. - Frequency `topX` default raised to `10000`
- Setup Appearance: Removed the bundled mpv runtime plugin readiness card from the setup flow. - **Character Dictionary:**
- Entries scoped to the current AniList media for name matching and inline portraits
- Generates Japanese-only name aliases so raw romanized/English aliases no longer surface as separate results
- In-app AniList selector waits for an explicit search with the box prefilled from the current filename
- `subtitleStyle.nameMatchEnabled` is now the sole switch for dictionary sync and builds
- **Electron Runtime:** Updated from 39.8.6 to 42.2.0, returning SubMiner to a supported Electron release line.
- **Jellyfin Setup:**
- Removed the server presets dropdown
- Setup now shows a single editable server URL field
- **Jellyfin Cast Identity:**
- Device identity now derived from the OS hostname and always reported as SubMiner
- Previously configurable identity fields are ignored, preventing multiple installs from sharing a remote-session identity
- **Startup Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
- **Setup Appearance:** Removed the bundled mpv runtime plugin readiness card from the setup flow.
**Fixed** **Fixed**
- AniList Progress: Progress updates fire correctly when playback reaches or skips past the watched threshold using fresh mpv timing events; season-specific results preferred for multi-season files with a clear message when the matched season is not in Planning or Watching; repeated missing-token checks no longer exhaust retry attempts or duplicate dead-letter entries. - **AniList Progress:**
- Anki Mining: Sentence-audio padding is opt-in by default; animated AVIF freeze-frame duration aligned to word audio length without double-counting; multi-line sentence alignment fixed for repeated subtitle text; Kiku duplicate-card detection, auto-merge, modal acknowledgment race, and field/tag ordering corrected; YouTube playback cards use mpv's resolved stream URLs; sentence cards refresh the secondary subtitle before saving; known-word cache appends correctly with multiple deck field mappings. - Progress updates fire correctly when playback reaches or skips past the watched threshold, using fresh mpv timing events
- Jellyfin Discovery: Startup, subtitle track selection, and duplicate ready-signal handling all fixed; paused mpv no longer misreported as playing; startup unpause no longer repeats after a manual pause or `y-t` toggle; delayed Japanese subtitle selection, later-loading foreign track hijacking, and long-lived sidebar ffmpeg extractor leaks fixed; resume corrected when a remote play command sends `StartPositionTicks: 0` despite saved progress; picker library discovery kept working regardless of app log level. - Season-specific results preferred for multi-season files, with a clear message when the matched season is not in Planning or Watching
- Jellyfin Remote: Tray checkbox stays in sync on Linux after tray, CLI, or startup changes; stale discovery sessions restarted when the server no longer lists the SubMiner cast target; remote controller visibility and progress sync fixed for seeks, stops, startup path changes, and Linux websocket reconnect windows; Play and Resume now behave correctly (Play from beginning, Resume from saved position); final progress reports reuse SubMiner's last known position when mpv resets on stop; Windows setup login flow fixed with an IPC bridge, immediate feedback, and a timeout with inline error for unreachable servers. - Repeated missing-token checks no longer exhaust retry attempts or duplicate dead-letter entries
- Jellyfin Subtitles and Overlay: Subtitle overlay shown automatically during Jellyfin playback; `y-t` toggle made reliable and sticky across stream redirects; managed subtitle defaults re-armed on redirect; passive Linux/Hyprland overlay shows no longer steal keyboard focus from mpv; subtitle timing improved with preferred embedded streams over external sidecars, correct Japanese-vs-English cue offset handling, per-stream delay shift restoration, and transient track-list read failure tolerance. - **Anki Mining:**
- Overlay (macOS): Overlay hides when mpv loses focus, is minimized, or is no longer the foreground app; stable through transient window geometry disappearances from macOS APIs and when clicking from the overlay back into mpv; stats overlay opened inactive so it appears over fullscreen mpv without switching Spaces; passthrough fixed so mpv controls stay clickable before hovering a subtitle bar; window-tracker polling reduced while mpv is stably focused. - Sentence-audio padding is opt-in by default
- Overlay (Linux / Hyprland): Placement refreshes after leaving fullscreen; overlay stays above mpv after focus changes from clicks or movement; Settings and Yomitan windows promoted above the subtitle overlay instead of opening behind it; overlay hides when the character dictionary modal opens, including during AniList lookup. - Animated AVIF freeze-frame duration aligned to word audio length without double-counting
- Overlay Lifecycle: First startup subtitle primed before autoplay resumes so the overlay renders text before playback begins; overlay and subtitle stream kept alive after `y-r` restart with correct Linux bounds reapplication; launcher-owned playback quits SubMiner on end while background/tray sessions stay alive; subtitle sync modal fixed on macOS so it no longer flashes on first attempt or leaves stale state; Windows managed mpv launches from a background instance now correctly receive the start command, retarget the new socket, bind to the player window, and receive startup overlay options. - Multi-line sentence alignment fixed for repeated subtitle text
- Yomitan Sidebar: Playback stays paused for sidebar-opened Yomitan popups when auto-pause is enabled; fixed popups not opening when startup races the Yomitan extension load; sidebar mining cards use audio and images from the clicked sidebar line instead of the current primary subtitle. - Kiku duplicate-card detection, auto-merge, modal acknowledgment race, and field/tag ordering corrected
- Launcher: Warm launches reuse a running background instance, reapply preferred subtitles, and close launcher-owned tray apps after playback ends; videos stay paused until subtitle priming and tokenization readiness complete; `subminer settings` on macOS exits cleanly when the window is closed; `subminer app` on Linux returns terminal control immediately; Linux first-run installs build with a valid Bun shebang; `subminer app --setup` opens the setup flow when SubMiner is already running in background. - YouTube playback cards use mpv's resolved stream URLs
- YouTube Playback: Selected subtitles downloaded to local temp files so the primary bar and sidebar read the same source, with cleanup on reload and quit; false load-failure notifications suppressed; tray icon created on launcher-managed playback that attaches to an already-running process; mpv plugin no longer starts a second SubMiner instance for app-owned YouTube playback. - Sentence cards refresh the secondary subtitle before saving
- Shortcuts: Native mpv menu shortcuts disabled during managed macOS playback so configured SubMiner shortcuts work while mpv has focus; custom session shortcuts including `stats.markWatchedKey` wired through mpv; multi-line copy/mine overlay correctly focused so number keys choose the line count on macOS and Windows. - **Jellyfin Discovery:**
- Controller Bindings: Controller config and debug shortcuts stay closed while controller support is disabled; binding learn mode starts from the edit pencil; remaps saved per controller profile; binding badges also start learn mode; row reset buttons restore individual bindings to defaults. - Startup, subtitle track selection, and duplicate ready-signal handling all fixed
- Logging: `logging.level` forwarded to launcher-started and Windows shortcut-started mpv sessions covering mpv log verbosity, plugin logging, and plugin-launched app logging; `logging.rotation` (default 7 days) and per-component `logging.files` toggles added with mpv logs disabled by default; repeated IPC socket warning spam suppressed while waiting for mpv to recreate the socket; Windows mpv IPC, subtitle track, and Yomitan diagnostics added. - Paused mpv no longer misreported as playing
- Updater: Linux `subminer -u` performs release updates independently of any running tray app using GitHub release metadata; macOS update dialogs from `subminer -u` reliably appear in the foreground with a manual-install message for builds that cannot apply native updates; macOS and Linux `electron-updater` routes through `/usr/bin/curl` to avoid Electron network crashes; Windows automatic updates keep the native NSIS install path while routing updater HTTP through main-process fetch to avoid delayed exit after launch. - Resume corrected when a remote play command sends `StartPositionTicks: 0` despite saved progress
- In-Player Stats: Layering fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window; Jellyfin playback stats grouped by item metadata so watched episodes merge with matching local library titles and keep clean display names. - **Jellyfin Remote:**
- Tray: Tray stays running when Yomitan settings are closed; settings loading no longer blocks other tray actions; Yomitan extension refreshes serialized at startup; embedded popup preview disabled to prevent renderer hangs during sidebar navigation; Windows "Open SubMiner Setup" action opens the setup window correctly after first-run is complete; session help modal close fixed without mpv running. - Tray checkbox stays in sync on Linux after tray, CLI, or startup changes
- Discord Rich Presence: No longer falls back to Jellyfin stream URLs; Jellyfin playback titles primed before stream loading so presence shows the show/episode title instead of a URL. - Remote controller visibility and progress sync fixed for seeks, stops, startup path changes, and Linux websocket reconnect windows
- WebSocket Annotations: Annotation spans and token metadata stay on the annotation WebSocket; the regular subtitle WebSocket is plain-text only. - Play and Resume now behave correctly (Play from beginning, Resume from saved position)
- Subtitle Frequency Highlighting: Frequency annotations kept for determiner-led noun compounds like `その場` while still filtering standalone determiners; fixed for Yomitan single-token compounds with internal particles such as `目の前` while keeping pure grammar/kana helper spans unannotated. - Final progress reports reuse SubMiner's last known position when mpv resets on stop
- Subtitle Annotation Prefetching: Cached colored annotations and character images ready sooner for live subtitle changes without delaying raw subtitle display. - Windows setup login flow fixed with an IPC bridge, immediate feedback, and a timeout with inline error for unreachable servers
- Packaging: macOS compiled mpv window helper correctly built into `dist/scripts` and bundled, preventing fallback to slow Swift source startup; stale Windows helper resource entry removed; one-shot `make clean build install` AppImage flows fixed so install picks up the AppImage built earlier in the same invocation. - **Overlay (macOS):**
- Windows Startup Errors: Fatal startup failures now show a native error dialog and write details to the app log instead of exiting silently. - Overlay hides when mpv loses focus, is minimized, or is no longer the foreground app
- Stays stable through transient window geometry disappearances from macOS APIs and when clicking from the overlay back into mpv
- Stats overlay opened inactive so it appears over fullscreen mpv without switching Spaces
- Passthrough fixed so mpv controls stay clickable before hovering a subtitle bar
- **Yomitan Sidebar:**
- Playback stays paused for sidebar-opened Yomitan popups when auto-pause is enabled
- Popups now open when startup races the Yomitan extension load
- Sidebar mining cards use audio and images from the clicked sidebar line instead of the current primary subtitle
- **Launcher:**
- `subminer app` on Linux returns terminal control immediately
- `subminer app --setup` opens the setup flow when SubMiner is already running in the background
- **YouTube Playback:**
- Selected subtitles downloaded to local temp files so the primary bar and sidebar read the same source, with cleanup on reload and quit
- False load-failure notifications suppressed
- Tray icon created on launcher-managed playback that attaches to an already-running process
- **Shortcuts:**
- Native mpv menu shortcuts disabled during managed macOS playback so configured SubMiner shortcuts work while mpv has focus
- Custom session shortcuts including `stats.markWatchedKey` wired through mpv
- Multi-line copy/mine overlay correctly focused so number keys choose the line count on macOS and Windows
- **Controller Bindings:**
- Controller config and debug shortcuts stay closed while controller support is disabled
- Binding learn mode starts from the edit pencil
- Remaps saved per controller profile
- Binding badges also start learn mode
- Row reset buttons restore individual bindings to defaults
- **Logging:**
- `logging.level` forwarded to launcher-started and Windows shortcut-started mpv sessions, covering mpv log verbosity, plugin logging, and plugin-launched app logging
- `logging.rotation` (default 7 days) and per-component `logging.files` toggles added, with mpv logs disabled by default
- Repeated IPC socket warning spam suppressed while waiting for mpv to recreate the socket
- Windows mpv IPC, subtitle track, and Yomitan diagnostics added
- **In-Player Stats:**
- Layering fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window
- Jellyfin playback stats grouped by item metadata so watched episodes merge with matching local library titles and keep clean display names
- **WebSocket Annotations:**
- Annotation spans and token metadata stay on the annotation WebSocket
- The regular subtitle WebSocket is plain-text only
- **Subtitle Annotation Prefetching:** Cached colored annotations and character images ready sooner for live subtitle changes without delaying raw subtitle display.
- **Windows Startup Errors:** Fatal startup failures now show a native error dialog and write details to the app log instead of exiting silently.
**Docs** **Docs**
- Documentation Site: Published stable docs at the site root with current development docs under `/main/`; fixed versioned docs navigation, archived page link handling, and local dev version routing; documented all previously undocumented config options including `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options; added Playback Startup Flow and Runtime Sockets diagrams to the architecture docs with cross-reference pointers in the MPV Plugin and Troubleshooting pages. - **Documentation Site:**
- Published stable docs at the site root with current development docs under `/main/`
- Fixed versioned docs navigation, archived page link handling, and local dev version routing
- Documented all previously undocumented config options including `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options
- Added Playback Startup Flow and Runtime Sockets diagrams to the architecture docs with cross-reference pointers in the MPV Plugin and Troubleshooting pages
<details> <details>
<summary>Internal changes</summary> <summary>Internal changes</summary>
**Internal** **Internal**
- Release Tooling: Release-note polishing treats pending fragments and reviewed prerelease notes as a cumulative final outcome, collapsing prerelease-only fixes into the final user-facing change; prerelease generation reuses existing reviewed notes and merges only new fragment material; `make clean` preserves `release/prerelease-notes.md`. - **Release Tooling:**
- Tests: Removed stale Yomitan vendor source-inspection assertions for changes that were not shipped. - Release-note polishing treats pending fragments and reviewed prerelease notes as a cumulative final outcome, collapsing prerelease-only fixes into the final user-facing change
- Prerelease generation reuses existing reviewed notes and merges only new fragment material
- `make clean` preserves `release/prerelease-notes.md`
- **Tests:** Removed stale Yomitan vendor source-inspection assertions for changes that were not shipped.
</details> </details>
@@ -78,7 +188,7 @@
**Added** **Added**
- **Character Dictionary:** Added AniList-based character dictionary selection for resolving title mismatches open it in-app with the new `Ctrl+Alt+A` shortcut or from the CLI with `subminer dictionary --candidates` / `--select`. Series-scoped overrides replace stale entries in the merged dictionary. - **Character Dictionary:** Added AniList-based character dictionary selection for resolving title mismatches - open it in-app with the new `Ctrl+Alt+A` shortcut or from the CLI with `subminer dictionary --candidates` / `--select`. Series-scoped overrides replace stale entries in the merged dictionary.
- **Primary Subtitle Bar:** Added a `V` shortcut and mpv plugin binding to toggle the primary subtitle bar without affecting mpv's native subtitle visibility. - **Primary Subtitle Bar:** Added a `V` shortcut and mpv plugin binding to toggle the primary subtitle bar without affecting mpv's native subtitle visibility.
- **Texthooker:** Added `subminer texthooker -o` and a tray menu item to open the local texthooker page in the default browser. - **Texthooker:** Added `subminer texthooker -o` and a tray menu item to open the local texthooker page in the default browser.
@@ -132,7 +242,7 @@
**Internal** **Internal**
- Replaced the changelog renderer with an AI polish pass that merges related fragments and writes user-facing release notes. `CHANGELOG.md` keeps internal items in a collapsed `<details>` block; GitHub release notes omit them entirely. - Replaced the changelog renderer with an AI polish pass that merges related fragments and writes user-facing release notes. `CHANGELOG.md` keeps internal items in a collapsed `<details>` block; GitHub release notes omit them entirely.
- Release CI no longer auto-builds pending `changes/*.md` fragments on tag. Tagging now fails fast if fragments remain run `bun run changelog:build` (requires the `claude` CLI) and commit before tagging. - Release CI no longer auto-builds pending `changes/*.md` fragments on tag. Tagging now fails fast if fragments remain - run `bun run changelog:build` (requires the `claude` CLI) and commit before tagging.
</details> </details>
@@ -156,8 +266,8 @@
- Stats: Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity. - Stats: Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity.
- Stats: Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility. - Stats: Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility.
- Stats: Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard. - Stats: Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard.
- Stats: Session timeline no longer plots seek-forward/seek-backward markers they were too noisy on sessions with lots of rewinds. - Stats: Session timeline no longer plots seek-forward/seek-backward markers - they were too noisy on sessions with lots of rewinds.
- Stats: Replaced the "Library Per Day" section on the Stats → Trends page with a "Library Summary" section. The new section shows a top-10 watch-time leaderboard chart and a sortable per-title table (watch time, videos, sessions, cards, words, lookups, lookups/100w, date range), all scoped to the current date range selector. - Stats: Replaced the "Library - Per Day" section on the Stats → Trends page with a "Library - Summary" section. The new section shows a top-10 watch-time leaderboard chart and a sortable per-title table (watch time, videos, sessions, cards, words, lookups, lookups/100w, date range), all scoped to the current date range selector.
**Fixed** **Fixed**
+56 -34
View File
@@ -1,6 +1,6 @@
# Character Dictionary # Character Dictionary
SubMiner can build a Yomitan-compatible character dictionary from [AniList](https://anilist.co) metadata so that character names in subtitles are recognized, highlighted, and enrichable with context portraits, roles, voice actors, and biographical detail without leaving the overlay. (AniList is an online anime/manga database; SubMiner pulls each show's character list from it.) SubMiner can build a Yomitan-compatible character dictionary from [AniList](https://anilist.co) metadata so that character names in subtitles are recognized, highlighted, and enrichable with context - portraits, roles, voice actors, and biographical detail - without leaving the overlay. (AniList is an online anime/manga database; SubMiner pulls each show's character list from it.)
This is helpful because proper names rarely appear in normal dictionaries, so character names would otherwise be flagged as "unknown" words and clutter your mining. Recognizing them keeps your N+1 highlighting focused on real vocabulary. This is helpful because proper names rarely appear in normal dictionaries, so character names would otherwise be flagged as "unknown" words and clutter your mining. Recognizing them keeps your N+1 highlighting focused on real vocabulary.
@@ -10,28 +10,25 @@ The dictionary is generated per-media, merged across your recently-watched title
The feature has three stages: **snapshot**, **merge**, and **match**. The feature has three stages: **snapshot**, **merge**, and **match**.
1. **Snapshot** When you start watching a new title, SubMiner queries the AniList GraphQL API for the media's character list. Each character's names, reading, role, description, birthday, voice actors, and portrait are fetched and saved as a local JSON snapshot in `character-dictionaries/snapshots/anilist-{mediaId}.json`. Images are downloaded and base64-encoded into the snapshot. 1. **Snapshot** - When you start watching a new title, SubMiner queries the AniList GraphQL API for the media's character list. Each character's names, reading, role, description, birthday, voice actors, and portrait are fetched and saved as a local JSON snapshot in `character-dictionaries/snapshots/anilist-{mediaId}.json`. Images are downloaded and base64-encoded into the snapshot.
2. **Merge** SubMiner maintains a most-recently-used list of media IDs (default: 3). Snapshots from those titles are merged into a single Yomitan ZIP `character-dictionaries/merged.zip` which is always named "SubMiner Character Dictionary" so Yomitan treats it as a single stable dictionary across rebuilds. 2. **Merge** - SubMiner maintains a most-recently-used list of media IDs (default: 3). Snapshots from those titles are merged into a single Yomitan ZIP - `character-dictionaries/merged.zip` - which is always named "SubMiner Character Dictionary" so Yomitan treats it as a single stable dictionary across rebuilds.
3. **Match** During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. SubMiner only accepts character entries for the current AniList media when that media ID is known, then flags matching tokens with `isNameMatch` and highlights them in the overlay with a distinct color. 3. **Match** - During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. SubMiner only accepts character entries for the current AniList media when that media ID is known, then flags matching tokens with `isNameMatch` and highlights them in the overlay with a distinct color.
## Enabling the Feature ## Enabling the Feature
Character dictionary sync is disabled by default. To turn it on: Character dictionary sync is disabled by default. To turn it on:
1. Authenticate with AniList (see [AniList Integration](/anilist-integration#setup)). 1. Enable **Name Match** in Settings → Subtitle Style, or set `subtitleStyle.nameMatchEnabled: true` in your config.
2. Set `subtitleStyle.nameMatchEnabled` to `true` in your config or enable **Name Match Enabled** in Settings. 2. Start watching - SubMiner queries AniList's public GraphQL API (no authentication required) and imports the merged dictionary into Yomitan automatically.
3. Start watching — SubMiner will generate a snapshot for the current media and import the merged dictionary into Yomitan automatically. 3. Optionally enable **Name Match Images** (Settings → Subtitle Style) to show inline circular character portraits next to matched names in subtitles.
```jsonc ```jsonc
{ {
"anilist": {
"enabled": true,
"accessToken": "your-token",
},
"subtitleStyle": { "subtitleStyle": {
"nameMatchEnabled": true, "nameMatchEnabled": true,
"nameMatchImagesEnabled": true, // optional - inline portraits
}, },
} }
``` ```
@@ -40,6 +37,10 @@ Character dictionary sync is disabled by default. To turn it on:
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup. The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup.
::: :::
::: info
AniList character data is fetched via public GraphQL queries - no account or access token is needed. AniList authentication is only required for the separate [watch-progress sync](/anilist-integration) feature.
:::
::: warning ::: warning
If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but SubMiner's own character-dictionary features are fully disabled. If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but SubMiner's own character-dictionary features are fully disabled.
::: :::
@@ -59,7 +60,7 @@ A single character produces many searchable terms so that names are recognized r
- ア・リ・ス → アリス (combined), plus individual segments - ア・リ・ス → アリス (combined), plus individual segments
**Honorific suffixes** each base name is expanded with 15 common suffixes: **Honorific suffixes** - each base name is expanded with 15 common suffixes:
| Honorific | Reading | | Honorific | Reading |
| --------- | ---------- | | --------- | ---------- |
@@ -79,16 +80,16 @@ A single character produces many searchable terms so that names are recognized r
| 社長 | しゃちょう | | 社長 | しゃちょう |
| 部長 | ぶちょう | | 部長 | ぶちょう |
**Romanized names** names stored in romaji on AniList are converted to kana aliases so they can match against Japanese subtitle text. **Romanized names** - names stored in romaji on AniList are converted to kana aliases so they can match against Japanese subtitle text.
This means a character like "太郎" generates entries for 太郎, 太郎さん, 太郎先生, 太郎君, 太郎ちゃん, and so on all with correct readings. This means a character like "太郎" generates entries for 太郎, 太郎さん, 太郎先生, 太郎君, 太郎ちゃん, and so on - all with correct readings.
## Name Matching ## Name Matching
Name matching runs inside Yomitan's scanning pipeline during subtitle tokenization. Name matching runs inside Yomitan's scanning pipeline during subtitle tokenization.
1. Yomitan receives subtitle text and scans for dictionary matches. 1. Yomitan receives subtitle text and scans for dictionary matches.
2. Entries from "SubMiner Character Dictionary" are checked with exact primary-source matching the token must match the entry's `originalText` with `isPrimary: true` and `matchType: 'exact'`. 2. Entries from "SubMiner Character Dictionary" are checked with exact primary-source matching - the token must match the entry's `originalText` with `isPrimary: true` and `matchType: 'exact'`.
3. When the current AniList media ID is known, entries whose embedded media ID belongs to a different title are ignored for name matching and inline portraits. 3. When the current AniList media ID is known, entries whose embedded media ID belongs to a different title are ignored for name matching and inline portraits.
4. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer. 4. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer.
5. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`). 5. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`).
@@ -106,17 +107,36 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting,
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names | | `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names | | `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
## Inline Character Portraits
When `subtitleStyle.nameMatchImagesEnabled` is enabled, SubMiner injects a small circular portrait image directly into the subtitle line next to each matched character name.
Portraits are sourced from the local snapshot - they are embedded at snapshot-generation time and served from the cached ZIP, so no network request happens during playback. Images are downloaded from AniList CDN once per character and stored in `character-dictionaries/img/`.
If a snapshot was generated before portrait data was available (e.g. during an earlier version or offline sync), SubMiner detects the missing image data on the next media match and automatically refreshes the snapshot so portraits are included in the next merged dictionary build.
**To enable:**
- Settings → Subtitle Style → **Name Match Images**, or
- `subtitleStyle.nameMatchImagesEnabled: true` in config.
The portrait size is controlled by the surrounding subtitle font size and renders as a circle clipped from the character's AniList cover image.
::: tip
Inline portraits help you quickly associate names with faces while building vocabulary - especially useful for shows with large casts where you're still learning who's who.
:::
## Dictionary Entries ## Dictionary Entries
Each character entry in the Yomitan dictionary includes structured content: Each character entry in the Yomitan dictionary includes structured content:
- **Name** the matched Japanese name form - **Name** - the matched Japanese name form
- **Known names** generated non-honorific Japanese aliases for that character, excluding raw romanized/English aliases from lookup results - **Known names** - generated non-honorific Japanese aliases for that character, excluding raw romanized/English aliases from lookup results
- **Role badge** color-coded by role: main (score 100), supporting (90), side (80), background (70) - **Role badge** - color-coded by role: main (score 100), supporting (90), side (80), background (70)
- **Portrait** character image from AniList, embedded in the ZIP - **Portrait** - character image from AniList, embedded in the ZIP
- **Description** biography text from AniList (collapsible) - **Description** - biography text from AniList (collapsible)
- **Character information** age, birthday, gender, blood type (collapsible) - **Character information** - age, birthday, gender, blood type (collapsible)
- **Voiced by** voice actor name and portrait (collapsible) - **Voiced by** - voice actor name and portrait (collapsible)
The three collapsible sections can be configured to start open or closed: The three collapsible sections can be configured to start open or closed:
@@ -138,14 +158,16 @@ 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. 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:** **Phases:**
1. **checking** Is there already a cached snapshot for this media ID? 1. **checking** - Is there already a cached snapshot for this media ID?
2. **generating** No cache hit: fetch characters from AniList GraphQL, download portraits (250ms throttle between image requests), save snapshot JSON. 2. **generating** - No cache hit: fetch characters from AniList GraphQL, download portraits (250ms throttle between image requests), save snapshot JSON.
3. **syncing** Add the media ID to the most-recently-used list. Evict old entries beyond `maxLoaded`. 3. **syncing** - Add the media ID to the most-recently-used list. Evict old entries beyond `maxLoaded`.
4. **building** Merge active snapshots into a single Yomitan ZIP. A SHA-1 revision hash is computed from the media set if it matches the previously imported revision, the import is skipped. 4. **building** - Merge active snapshots into a single Yomitan ZIP. A SHA-1 revision hash is computed from the media set - if it matches the previously imported revision, the import is skipped.
5. **importing** Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation). 5. **importing** - Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
6. **ready** Dictionary is live. Character names will match on the next subtitle line. 6. **ready** - Dictionary is live. Character names will match on the next subtitle line.
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search. **State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search.
@@ -254,9 +276,9 @@ merged.zip
## Reference Implementation ## Reference Implementation
SubMiner's character dictionary builder is inspired by the [Japanese Character Name Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) project a standalone Rust web service that generates Yomitan character dictionaries from AniList and VNDB data. SubMiner's character dictionary builder is inspired by the [Japanese Character Name Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) project - a standalone Rust web service that generates Yomitan character dictionaries from AniList and VNDB data.
The reference implementation covers similar ground name variant generation, honorific expansion, structured Yomitan content, portrait embedding and additionally supports VNDB as a data source for visual novel characters. Key differences: The reference implementation covers similar ground - name variant generation, honorific expansion, structured Yomitan content, portrait embedding - and additionally supports VNDB as a data source for visual novel characters. Key differences:
| | SubMiner | Reference Implementation | | | SubMiner | Reference Implementation |
| ---------------------- | -------------------------------------------- | ------------------------------------- | | ---------------------- | -------------------------------------------- | ------------------------------------- |
@@ -271,7 +293,7 @@ If you work with visual novels or want a standalone dictionary generator indepen
## Troubleshooting ## Troubleshooting
- **Names not highlighting:** Confirm `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry SubMiner needs a media ID to fetch characters. - **Names not highlighting:** Confirm `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry - SubMiner needs a media ID to fetch characters.
- **Inline portraits missing:** Confirm `subtitleStyle.nameMatchImagesEnabled` is `true`. On the next character dictionary sync, SubMiner refreshes current-version snapshots that do not contain usable cached character portrait data. Portraits still require AniList to return an image and the image download to succeed. - **Inline portraits missing:** Confirm `subtitleStyle.nameMatchImagesEnabled` is `true`. On the next character dictionary sync, SubMiner refreshes current-version snapshots that do not contain usable cached character portrait data. Portraits still require AniList to return an image and the image download to succeed.
- **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase. - **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase.
- **Wrong characters showing:** Open the in-app character dictionary manager (`Ctrl/Cmd+D`) to remove/reorder loaded titles, then use **Override** to correct the active AniList match. You can also run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id <id>`. SubMiner ignores character entries from other loaded titles for subtitle name matching and inline portraits once the current media ID is known. - **Wrong characters showing:** Open the in-app character dictionary manager (`Ctrl/Cmd+D`) to remove/reorder loaded titles, then use **Override** to correct the active AniList match. You can also run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id <id>`. SubMiner ignores character entries from other loaded titles for subtitle name matching and inline portraits once the current media ID is known.
@@ -280,6 +302,6 @@ If you work with visual novels or want a standalone dictionary generator indepen
## Related ## Related
- [Subtitle Annotations](/subtitle-annotations) how name matches interact with N+1, frequency, and JLPT layers - [Subtitle Annotations](/subtitle-annotations) - how name matches interact with N+1, frequency, and JLPT layers
- [AniList Integration](/anilist-integration) — authentication, episode tracking, and AniList settings - [AniList Integration](/anilist-integration) - watch-progress sync and AniList authentication (separate from character dictionary)
- [Configuration Reference](/configuration) full config options - [Configuration Reference](/configuration) - full config options
+147 -112
View File
@@ -8,7 +8,7 @@ outline: [2, 3]
import { withBase } from 'vitepress'; import { withBase } from 'vitepress';
</script> </script>
SubMiner is configured through a single file (`config.jsonc`). Most settings are also editable from the in-app **Settings** window you rarely need to edit the file by hand. This page is the full reference: it explains the Settings window, where the config file lives, and documents every option grouped by topic. New to SubMiner? The Quick Start below plus the [Settings window](#settings) cover everything most users need. SubMiner is configured through a single file (`config.jsonc`). Most settings are also editable from the in-app **Settings** window - you rarely need to edit the file by hand. This page is the full reference: it explains the Settings window, where the config file lives, and documents every option grouped by topic. New to SubMiner? The Quick Start below plus the [Settings window](#settings) cover everything most users need.
## Quick Start ## Quick Start
@@ -39,7 +39,7 @@ Then customize as needed using the sections below.
## Settings ## Settings
SubMiner includes a dedicated **Settings** window accessible from the tray menu, the app `--settings` flag, or launcher commands such as `subminer --settings` and `subminer settings`. It is the primary way to configure SubMiner all changes are written directly to `config.jsonc`, so manual file editing is not required for most users. SubMiner includes a dedicated **Settings** window accessible from the tray menu, the app `--settings` flag, or launcher commands such as `subminer --settings` and `subminer settings`. It is the primary way to configure SubMiner - all changes are written directly to `config.jsonc`, so manual file editing is not required for most users.
The Settings window groups options by workflow instead of mirroring the raw config-file shape: The Settings window groups options by workflow instead of mirroring the raw config-file shape:
@@ -52,7 +52,7 @@ The Settings window groups options by workflow instead of mirroring the raw conf
- Tracking & App - Tracking & App
- Advanced - 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. 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 - [**MPV Launcher**](#mpv-launcher) - mpv executable path, profile, and window launch mode
- [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading - [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading
- [**Updates**](#updates) - Automatic update checks, notifications, and prerelease testing - [**Updates**](#updates) - Automatic update checks, notifications, and prerelease testing
- [**Notifications**](#notifications) - Overlay notification placement
## Core Settings ## Core Settings
@@ -202,12 +203,40 @@ Configure automatic update checks and update notifications:
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| -------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------- | | -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. | | `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. | | `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
| `notificationType` | `"system"` \| `"osd"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. | | `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. `"both"` means overlay + system. |
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. | | `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
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 ### 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`) | | `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`. 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 | | 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`. | | `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. | | `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) | | `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
@@ -516,19 +545,19 @@ See `config.example.jsonc` for detailed configuration options.
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ----------------------- | ---------------------------------- | ------------------------------------------------------ | | ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) | | `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 | | `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) | | `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback. The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
**Display modes:** **Display modes:**
- **hidden** Secondary subtitles not shown - **hidden** - Secondary subtitles not shown
- **visible** Always visible at top of overlay - **visible** - Always visible at top of overlay
- **hover** Only visible when hovering over the subtitle area (default) - **hover** - Only visible when hovering over the subtitle area (default)
**See `config.example.jsonc`** for additional secondary subtitle configuration options. **See `config.example.jsonc`** for additional secondary subtitle configuration options.
@@ -571,13 +600,15 @@ See `config.example.jsonc` for detailed configuration options and more examples.
{ "key": "ArrowRight", "command": ["seek", 5] }, { "key": "ArrowRight", "command": ["seek", 5] },
{ "key": "ArrowLeft", "command": ["seek", -5] }, { "key": "ArrowLeft", "command": ["seek", -5] },
{ "key": "Shift+ArrowRight", "command": ["seek", 30] }, { "key": "Shift+ArrowRight", "command": ["seek", 30] },
{ "key": "MBTN_BACK", "command": ["sub-seek", -1] },
{ "key": "MBTN_FORWARD", "command": ["sub-seek", 1] },
{ "key": "KeyR", "command": ["script-binding", "immersive/auto-replay"] }, { "key": "KeyR", "command": ["script-binding", "immersive/auto-replay"] },
{ "key": "KeyA", "command": ["script-message", "ankiconnect-add-note"] } { "key": "KeyA", "command": ["script-message", "ankiconnect-add-note"] }
] ]
} }
``` ```
**Key format:** Use `KeyboardEvent.code` values (`Space`, `ArrowRight`, `KeyR`, etc.) with optional modifiers (`Ctrl+`, `Alt+`, `Shift+`, `Meta+`). **Key format:** Use `KeyboardEvent.code` values (`Space`, `ArrowRight`, `KeyR`, etc.) with optional modifiers (`Ctrl+`, `Alt+`, `Shift+`, `Meta+`). Mouse buttons use mpv button names: `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`.
**Disable a default binding:** Set command to `null`: **Disable a default binding:** Set command to `null`:
@@ -618,31 +649,33 @@ See `config.example.jsonc` for detailed configuration options.
"openControllerDebug": "Alt+Shift+C", "openControllerDebug": "Alt+Shift+C",
"openJimaku": "Ctrl+Shift+J", "openJimaku": "Ctrl+Shift+J",
"toggleSubtitleSidebar": "Backslash", "toggleSubtitleSidebar": "Backslash",
"toggleNotificationHistory": "CommandOrControl+N",
"multiCopyTimeoutMs": 3000 "multiCopyTimeoutMs": 3000
} }
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| -------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) | | `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) | | `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | | `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) | | `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) | | `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) | | `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) | | `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) | | `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) | | `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"`) | | `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"`) | | `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"`) | | `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"`) | | `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"`) | | `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"`) | | `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+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"`) | | `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. | | `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. **See `config.example.jsonc`** for the complete list of shortcut configuration options.
@@ -941,57 +974,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. **Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description | | Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | | `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | | `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | | `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) | | `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) | | `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) | | `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | | `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | | `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | 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. | | `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.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | | `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) | | `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | | `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) | | `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) | | `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. | | `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. | | `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | | `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) | | `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) | | `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) | | `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | | `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) | | `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. | | `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. | | `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) | | `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) | | `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. | | `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) | | `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). | | `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. | | `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.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | | `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) | | `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) | | `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | | `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | | `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) | | `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) | | `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. | | `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) | | `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). | | `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). | | `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). | | `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | | `behavior.notificationType` | `"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`) | | `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time | | `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | | `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | | `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides. `ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config. API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
@@ -1099,8 +1132,8 @@ Set `openBrowser` to `false` to only print the URL without opening a browser.
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below). Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
- [`alass`](https://github.com/kaegi/alass) fast, audio-independent sync using a secondary subtitle as reference - [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using a secondary subtitle as reference
- [`ffsubsync`](https://github.com/smacke/ffsubsync) audio-based sync using the video file as reference - [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
```json ```json
{ {
@@ -1120,6 +1153,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`. | | `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. | | `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
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`. Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
Customize it there, or set it to `null` to disable. Customize it there, or set it to `null` to disable.
@@ -1378,9 +1413,9 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
| `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). | | `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). |
| `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). | | `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). |
| `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). | | `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). |
| `lifetimeSummaries.global` | `true`, `false` | Maintain global lifetime stats rows (default: `true`). | | `lifetimeSummaries.global` | `true`, `false` | Maintain global lifetime stats rows (default: `true`). |
| `lifetimeSummaries.anime` | `true`, `false` | Maintain per-anime 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.media` | `true`, `false` | Maintain per-media lifetime stats rows (default: `true`). |
You can also disable immersion tracking for a single session using: You can also disable immersion tracking for a single session using:
@@ -1431,7 +1466,7 @@ Usage notes:
- The browser UI is served at `http://127.0.0.1:<serverPort>`. - 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 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 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 ### MPV Launcher
@@ -1454,26 +1489,26 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ----------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) | | `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) | | `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) | | `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) | | `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"`) | | `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`) | | `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`) | | `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: `""`) | | `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`) | | `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) | | `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. If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
Launch mode behavior: Launch mode behavior:
- **`normal`** mpv opens at its default window size with no extra flags. - **`normal`** - mpv opens at its default window size with no extra flags.
- **`maximized`** mpv starts maximized via `--window-maximized=yes`, keeping taskbar access. - **`maximized`** - mpv starts maximized via `--window-maximized=yes`, keeping taskbar access.
- **`fullscreen`** mpv starts in true fullscreen via `--fullscreen`. - **`fullscreen`** - mpv starts in true fullscreen via `--fullscreen`.
### YouTube Playback Settings ### YouTube Playback Settings
+1 -1
View File
@@ -25,7 +25,7 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
## Subtitle Download & Sync ## Subtitle Download & Sync
Search and download subtitles from Jimaku, then retime them with alass or ffsubsync all from within SubMiner. Search and download subtitles from Jimaku, then retime them with alass or ffsubsync - all from within SubMiner.
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/subtitle-sync-poster.jpg?v=${v}`)"> <!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/subtitle-sync-poster.jpg?v=${v}`)">
<source :src="withBase(`/assets/demos/subtitle-sync.webm?v=${v}`)" type="video/webm" /> <source :src="withBase(`/assets/demos/subtitle-sync.webm?v=${v}`)" type="video/webm" />
+3 -8
View File
@@ -11,15 +11,10 @@ For internal architecture/workflow guidance, use `docs/README.md` at the repo ro
```bash ```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
cd SubMiner cd SubMiner
# if you cloned without --recurse-submodules: make deps
git submodule update --init --recursive
bun install
(cd stats && bun install --frozen-lockfile)
(cd vendor/texthooker-ui && bun install --frozen-lockfile)
``` ```
`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 ## 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` | Build platform package for detected OS |
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` | | `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) | | `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 pretty` | Run scoped Prettier formatting for maintained source/config files |
| `make generate-config` | Generate default config from centralized registry | | `make generate-config` | Generate default config from centralized registry |
| `make build-linux` | Convenience wrapper for Linux packaging | | `make build-linux` | Convenience wrapper for Linux packaging |
+54 -44
View File
@@ -2,12 +2,12 @@
SubMiner can log your watching and mining activity to a local SQLite database, then surface it in the built-in stats dashboard. Tracking is enabled by default and can be turned off if you do not want local analytics. SubMiner can log your watching and mining activity to a local SQLite database, then surface it in the built-in stats dashboard. Tracking is enabled by default and can be turned off if you do not want local analytics.
"Immersion" here means time spent watching and reading native Japanese content. **All data stays on your computer** nothing is uploaded anywhere. (SQLite is just a single-file database; you do not need to install or manage anything.) "Immersion" here means time spent watching and reading native Japanese content. **All data stays on your computer** - nothing is uploaded anywhere. (SQLite is just a single-file database; you do not need to install or manage anything.)
When enabled, SubMiner records per-session statistics (watch time, subtitle lines seen, words encountered, cards mined) and maintains exact lifetime summary tables plus daily/monthly rollups. You can view that data in SubMiner's stats UI or query the database directly with any SQLite tool. When enabled, SubMiner records per-session statistics (watch time, subtitle lines seen, words encountered, cards mined) and maintains exact lifetime summary tables plus daily/monthly rollups. You can view that data in SubMiner's stats UI or query the database directly with any SQLite tool.
::: tip For most users ::: tip For most users
Just leave tracking on and use the built-in [Stats Dashboard](#stats-dashboard). The retention, performance, SQL, and schema sections further down are reference material for advanced users who want to inspect or tune the database you can safely skip them. Just leave tracking on and use the built-in [Stats Dashboard](#stats-dashboard). The retention, performance, SQL, and schema sections further down are reference material for advanced users who want to inspect or tune the database - you can safely skip them.
::: :::
Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_RATIO` (`85%`) value from `src/shared/watch-threshold.ts`. Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_RATIO` (`85%`) value from `src/shared/watch-threshold.ts`.
@@ -18,8 +18,8 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
{ {
"immersionTracking": { "immersionTracking": {
"enabled": true, "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. 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. 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.
![Stats Library](/screenshots/stats-library.png) ![Stats Library](/screenshots/stats-library.png)
#### Trends #### 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.
![Stats Trends](/screenshots/stats-trends.png) ![Stats Trends](/screenshots/stats-trends.png)
@@ -66,10 +72,14 @@ Expandable session history with new-word activity, cumulative totals, and pause/
#### Vocabulary #### 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.
![Stats Vocabulary](/screenshots/stats-vocabulary.png) ![Stats Vocabulary](/screenshots/stats-vocabulary.png)
#### 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`: Stats server config lives under `stats`:
```jsonc ```jsonc
@@ -78,8 +88,8 @@ Stats server config lives under `stats`:
"toggleKey": "Backquote", "toggleKey": "Backquote",
"serverPort": 6969, "serverPort": 6969,
"autoStartServer": true, "autoStartServer": true,
"autoOpenBrowser": false "autoOpenBrowser": false,
} },
} }
``` ```
@@ -96,15 +106,15 @@ Stats server config lives under `stats`:
## Mining Cards from the Stats Page ## 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 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. - **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. 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 ### 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: By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
| Data type | Retention | | Data type | Retention |
| -------------- | --------- | | --------------- | ------------ |
| Raw events | 0 (keep all) | | Raw events | 0 (keep all) |
| Telemetry | 0 (keep all) | | Telemetry | 0 (keep all) |
| Sessions | 0 (keep all) | | Sessions | 0 (keep all) |
| Daily rollups | 0 (keep all) | | Daily rollups | 0 (keep all) |
| Monthly 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. 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: All policy options live under `immersionTracking` in your config:
| Option | Description | | Option | Description |
| ------ | ----------- | | ------------------------------ | ------------------------------------------------------------------ |
| `batchSize` | Writes per flush batch | | `batchSize` | Writes per flush batch |
| `flushIntervalMs` | Max delay between flushes (default: 500ms) | | `flushIntervalMs` | Max delay between flushes (default: 500ms) |
| `queueCap` | Max queued writes before oldest are dropped | | `queueCap` | Max queued writes before oldest are dropped |
| `payloadCapBytes` | Max payload size per write | | `payloadCapBytes` | Max payload size per write |
| `maintenanceIntervalMs` | How often maintenance runs | | `maintenanceIntervalMs` | How often maintenance runs |
| `retention.eventsDays` | Raw event retention | | `retention.eventsDays` | Raw event retention |
| `retention.telemetryDays` | Telemetry retention | | `retention.telemetryDays` | Telemetry retention |
| `retention.sessionsDays` | Session retention | | `retention.sessionsDays` | Session retention |
| `retention.dailyRollupsDays` | Daily rollup retention | | `retention.dailyRollupsDays` | Daily rollup retention |
| `retention.monthlyRollupsDays` | Monthly rollup retention | | `retention.monthlyRollupsDays` | Monthly rollup retention |
| `retention.vacuumIntervalDays` | Minimum spacing between vacuums | | `retention.vacuumIntervalDays` | Minimum spacing between vacuums |
| `retentionMode` | `preset` or `advanced` | | `retentionMode` | `preset` or `advanced` |
| `retentionPreset` | `minimal`, `balanced`, or `deep-history` (used by `retentionMode`) | | `retentionPreset` | `minimal`, `balanced`, or `deep-history` (used by `retentionMode`) |
| `lifetimeSummaries.global` | Maintain global lifetime totals | | `lifetimeSummaries.global` | Maintain global lifetime totals |
| `lifetimeSummaries.anime` | Maintain per-anime lifetime totals | | `lifetimeSummaries.anime` | Maintain per-anime lifetime totals |
| `lifetimeSummaries.media` | Maintain per-media lifetime totals | | `lifetimeSummaries.media` | Maintain per-media lifetime totals |
## Query Templates ## Query Templates
@@ -281,11 +291,11 @@ LIMIT ?;
Core tables: Core tables:
- `imm_videos` video key/title/source metadata - `imm_videos` - video key/title/source metadata
- `imm_sessions` session UUID, video reference, timing/status, final denormalized totals - `imm_sessions` - session UUID, video reference, timing/status, final denormalized totals
- `imm_session_telemetry` high-frequency session aggregates over time - `imm_session_telemetry` - high-frequency session aggregates over time
- `imm_session_events` event stream with compact numeric event types - `imm_session_events` - event stream with compact numeric event types
- `imm_subtitle_lines` persisted subtitle text and timing per session/video - `imm_subtitle_lines` - persisted subtitle text and timing per session/video
Lifetime summary tables: Lifetime summary tables:
@@ -306,5 +316,5 @@ Vocabulary tables:
Media-art tables: Media-art tables:
- `imm_media_art` per-video cover metadata plus shared blob reference - `imm_media_art` - per-video cover metadata plus shared blob reference
- `imm_cover_art_blobs` deduplicated image bytes keyed by blob hash - `imm_cover_art_blobs` - deduplicated image bytes keyed by blob hash
+5 -5
View File
@@ -24,7 +24,7 @@ features:
src: /assets/mpv.svg src: /assets/mpv.svg
alt: mpv icon alt: mpv icon
title: Built for mpv title: Built for mpv
details: Tracks subtitles via mpv IPC in real time. Launch with the wrapper script or the mpv plugin no external bridge needed. details: Tracks subtitles via mpv IPC in real time. Launch with the wrapper script or the mpv plugin - no external bridge needed.
link: /usage link: /usage
linkText: How it works linkText: How it works
- icon: - icon:
@@ -45,14 +45,14 @@ features:
src: /assets/highlight.svg src: /assets/highlight.svg
alt: Highlight icon alt: Highlight icon
title: Reading Annotations title: Reading Annotations
details: N+1 targeting, character-name matching, frequency highlighting, and JLPT tagging all layered on subtitle text in real time. details: N+1 targeting, character-name matching, frequency highlighting, and JLPT tagging - all layered on subtitle text in real time.
link: /subtitle-annotations link: /subtitle-annotations
linkText: Annotation details linkText: Annotation details
- icon: - icon:
src: /assets/video.svg src: /assets/video.svg
alt: Video playback icon alt: Video playback icon
title: YouTube Playback title: YouTube Playback
details: Play YouTube URLs or ytsearch targets directly SubMiner automatically selects and loads subtitles for the video. details: Play YouTube URLs or ytsearch targets directly - SubMiner automatically selects and loads subtitles for the video.
link: /usage#youtube-playback link: /usage#youtube-playback
linkText: YouTube playback linkText: YouTube playback
- icon: - icon:
@@ -66,14 +66,14 @@ features:
src: /assets/subtitle-download.svg src: /assets/subtitle-download.svg
alt: Subtitle download icon alt: Subtitle download icon
title: Subtitle Download & Sync title: Subtitle Download & Sync
details: Search and pull subtitles from Jimaku, then retime subtitles with alass or ffsubsync all from the overlay. details: Search and pull subtitles from Jimaku, then retime subtitles with alass or ffsubsync - all from the overlay.
link: /jimaku-integration link: /jimaku-integration
linkText: Jimaku integration linkText: Jimaku integration
- icon: - icon:
src: /assets/tokenization.svg src: /assets/tokenization.svg
alt: Tracking chart icon alt: Tracking chart icon
title: Stats Dashboard title: Stats Dashboard
details: Browse session history, streak calendars, vocabulary frequency, and per-series progress in a local dashboard then mine cards straight from your viewing history. details: Browse session history, streak calendars, vocabulary frequency, and per-series progress in a local dashboard - then mine cards straight from your viewing history.
link: /immersion-tracking link: /immersion-tracking
linkText: Dashboard & tracking linkText: Dashboard & tracking
- icon: - icon:
+32 -32
View File
@@ -1,12 +1,12 @@
# Installation # Installation
SubMiner is a desktop app that draws an interactive layer an **overlay** on top of the [mpv](https://mpv.io) video player. As you watch native Japanese media, you can click or hover any word in the subtitles to look it up, then turn it into an Anki flashcard without pausing to switch apps. Building flashcards from real content you're watching is called **sentence mining**, and it's what SubMiner is built for. It bundles its own copy of **Yomitan** (a pop-up dictionary) and talks to **AnkiConnect** (an add-on that lets other programs add cards to Anki) so cards get filled in automatically. SubMiner is a desktop app that draws an interactive layer - an **overlay** - on top of the [mpv](https://mpv.io) video player. As you watch native Japanese media, you can click or hover any word in the subtitles to look it up, then turn it into an Anki flashcard without pausing to switch apps. Building flashcards from real content you're watching is called **sentence mining**, and it's what SubMiner is built for. It bundles its own copy of **Yomitan** (a pop-up dictionary) and talks to **AnkiConnect** (an add-on that lets other programs add cards to Anki) so cards get filled in automatically.
Three steps to get started: Three steps to get started:
1. **Install requirements** mpv and a few optional extras 1. **Install requirements** - mpv and a few optional extras
2. **Install SubMiner** from the AUR, or download from GitHub Releases 2. **Install SubMiner** - from the AUR, or download from GitHub Releases
3. **Launch the app** first-run setup walks you through dictionaries, the launcher, and everything else 3. **Launch the app** - first-run setup walks you through dictionaries, the launcher, and everything else
## 1. Install Requirements ## 1. Install Requirements
@@ -29,14 +29,14 @@ Only **mpv** is strictly required to run SubMiner. Everything else enhances the
### Linux ### Linux
**Window backend** you need one of these depending on your compositor: **Window backend** - you need one of these depending on your compositor:
- **Hyprland** native Wayland support (uses `hyprctl`) - **Hyprland** - native Wayland support (uses `hyprctl`)
- **Sway** native Wayland support (uses `swaymsg`) - **Sway** - native Wayland support (uses `swaymsg`)
- **X11 / Xwayland** for X11 sessions or any other Wayland compositor (uses `xdotool` and `xwininfo`) - **X11 / Xwayland** - for X11 sessions or any other Wayland compositor (uses `xdotool` and `xwininfo`)
::: warning Wayland support is compositor-specific ::: warning Wayland support is compositor-specific
Wayland has no universal API for window positioning each compositor exposes its own IPC, so SubMiner needs a dedicated backend per compositor. Only Hyprland and Sway have native Wayland backends. If you run a different Wayland compositor (GNOME, KDE Plasma, river, etc.), both mpv **and** SubMiner must run under X11 or Xwayland. The `subminer` launcher handles this automatically when `--backend x11` is set or the X11 backend is auto-detected. Wayland has no universal API for window positioning - each compositor exposes its own IPC, so SubMiner needs a dedicated backend per compositor. Only Hyprland and Sway have native Wayland backends. If you run a different Wayland compositor (GNOME, KDE Plasma, river, etc.), both mpv **and** SubMiner must run under X11 or Xwayland. The `subminer` launcher handles this automatically when `--backend x11` is set or the X11 backend is auto-detected.
::: :::
<details> <details>
@@ -69,7 +69,7 @@ sudo apt install yt-dlp fzf rofi chafa ffmpegthumbnailer
sudo apt install xdotool x11-utils sudo apt install xdotool x11-utils
# Optional: subtitle sync # Optional: subtitle sync
pip install ffsubsync pip install ffsubsync
# alass is not in apt install via cargo: cargo install alass-cli # alass is not in apt - install via cargo: cargo install alass-cli
``` ```
</details> </details>
@@ -94,7 +94,7 @@ pip install ffsubsync
### macOS ### macOS
macOS 11 (Big Sur) or later. Accessibility permission the macOS setting that lets one app observe and position another app's windows is required so the overlay can follow the mpv window (see [step 2](#macos-dmg)). macOS 11 (Big Sur) or later. Accessibility permission - the macOS setting that lets one app observe and position another app's windows - is required so the overlay can follow the mpv window (see [step 2](#macos-dmg)).
```bash ```bash
brew install mpv ffmpeg brew install mpv ffmpeg
@@ -111,7 +111,7 @@ pip install ffsubsync
Windows 10 or later. Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on `PATH`. Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary. Windows 10 or later. Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on `PATH`. Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary.
No compositor tools or window helpers are needed native window tracking is built in. No compositor tools or window helpers are needed - native window tracking is built in.
## 2. Install SubMiner ## 2. Install SubMiner
@@ -172,8 +172,8 @@ If you prefer to install it manually, see [manual launcher install](#manual-laun
Download the latest installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest): Download the latest installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
- `SubMiner-<version>.exe` installer (recommended) - `SubMiner-<version>.exe` - installer (recommended)
- `SubMiner-<version>-win.zip` portable fallback - `SubMiner-<version>-win.zip` - portable fallback
Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config during first-run setup. Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config during first-run setup.
@@ -185,7 +185,7 @@ Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config
```bash ```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
cd SubMiner cd SubMiner
bun install make deps
bun run build bun run build
# Optional: build AppImage # Optional: build AppImage
@@ -202,7 +202,7 @@ Bundled Yomitan is built during `bun run build`.
```bash ```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
cd SubMiner cd SubMiner
git submodule update --init --recursive make deps
make build-macos make build-macos
``` ```
@@ -216,14 +216,14 @@ The built app will be in the `release` directory (`.dmg` and `.zip`). For unsign
```powershell ```powershell
git clone https://github.com/ksyasuda/SubMiner.git git clone https://github.com/ksyasuda/SubMiner.git
cd SubMiner cd SubMiner
git submodule update --init --recursive
bun install bun install
Set-Location stats
# Windows requires building texthooker-ui manually before the main build bun install --frozen-lockfile
Set-Location vendor/texthooker-ui Set-Location ../vendor/texthooker-ui
bun install --frozen-lockfile bun install --frozen-lockfile
bun run build bun run build
Set-Location ../.. Set-Location ../..
bun run build:win bun run build:win
``` ```
@@ -240,18 +240,18 @@ subminer app --setup
# Linux (AppImage directly) # Linux (AppImage directly)
~/.local/bin/SubMiner.AppImage --setup ~/.local/bin/SubMiner.AppImage --setup
# macOS launch SubMiner.app from /Applications, or: # macOS - launch SubMiner.app from /Applications, or:
subminer app --setup subminer app --setup
``` ```
On **Windows**, just run `SubMiner.exe` the setup wizard opens automatically on first launch. On **Windows**, just run `SubMiner.exe` - the setup wizard opens automatically on first launch.
The setup wizard walks you through: The setup wizard walks you through:
- **Config file** auto-created at `~/.config/SubMiner/config.jsonc` (Linux/macOS) or `%APPDATA%\SubMiner\config.jsonc` (Windows) - **Config file** - auto-created at `~/.config/SubMiner/config.jsonc` (Linux/macOS) or `%APPDATA%\SubMiner\config.jsonc` (Windows)
- **Yomitan dictionaries** import at least one dictionary so word lookups work - **Yomitan dictionaries** - import at least one dictionary so word lookups work
- **Bun + `subminer` launcher** _(optional)_ installs the command-line launcher into a writable PATH directory - **Bun + `subminer` launcher** _(optional)_ - installs the command-line launcher into a writable PATH directory
- **Windows shortcut** _(Windows only)_ create a `SubMiner mpv` Start Menu/Desktop shortcut - **Windows shortcut** _(Windows only)_ - create a `SubMiner mpv` Start Menu/Desktop shortcut
The `Finish setup` button requires a config file and at least one Yomitan dictionary. Bun and the launcher are optional and never block setup completion. The `Finish setup` button requires a config file and at least one Yomitan dictionary. Bun and the launcher are optional and never block setup completion.
@@ -268,7 +268,7 @@ subminer video.mkv
You should see the overlay appear over mpv. If subtitles are loaded, they will appear as interactive text in the overlay. You should see the overlay appear over mpv. If subtitles are loaded, they will appear as interactive text in the overlay.
On **Windows**, the recommended way to play video is with the **SubMiner mpv** shortcut created during setup double-click it, or drag a video file onto it. On **Windows**, the recommended way to play video is with the **SubMiner mpv** shortcut created during setup - double-click it, or drag a video file onto it.
### Verify Setup ### Verify Setup
@@ -285,7 +285,7 @@ This checks for the app binary, mpv, ffmpeg, config file, and socket path. Fix a
If you plan to mine Anki cards: If you plan to mine Anki cards:
1. Install [Anki](https://apps.ankiweb.net/) 1. Install [Anki](https://apps.ankiweb.net/)
2. Install [AnkiConnect](https://ankiweb.net/shared/info/2055492159) open Anki → **Tools → Add-ons → Get Add-ons** → enter code `2055492159` 2. Install [AnkiConnect](https://ankiweb.net/shared/info/2055492159) - open Anki → **Tools → Add-ons → Get Add-ons** → enter code `2055492159`
3. Restart Anki and keep it running while using SubMiner 3. Restart Anki and keep it running while using SubMiner
AnkiConnect listens on `http://127.0.0.1:8765` by default. SubMiner connects automatically with no extra config needed. AnkiConnect listens on `http://127.0.0.1:8765` by default. SubMiner connects automatically with no extra config needed.
@@ -310,9 +310,9 @@ The tray "Check for Updates" entry installs the new app automatically on Linux,
SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IPC socket, renders subtitles as interactive text using a bundled Yomitan dictionary engine, and optionally creates Anki flashcards via AnkiConnect. SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IPC socket, renders subtitles as interactive text using a bundled Yomitan dictionary engine, and optionally creates Anki flashcards via AnkiConnect.
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) without it the overlay starts but subtitles won't appear. The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear.
The bundled mpv plugin is injected at runtime automatically you don't need to install it separately. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference. The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
## Platform Notes ## Platform Notes
@@ -330,7 +330,7 @@ Ensure `mecab` is available on your PATH when launching SubMiner.
- The **SubMiner mpv** shortcut is the recommended way to launch playback. It starts `mpv.exe` with the right IPC socket and subtitle defaults. - The **SubMiner mpv** shortcut is the recommended way to launch playback. It starts `mpv.exe` with the right IPC socket and subtitle defaults.
- First-run setup adds only `%LOCALAPPDATA%\SubMiner\bin` to the HKCU user PATH. It does not add `SubMiner.exe` to PATH. - First-run setup adds only `%LOCALAPPDATA%\SubMiner\bin` to the HKCU user PATH. It does not add `SubMiner.exe` to PATH.
- IPC socket on Windows is `\\.\pipe\subminer-socket` do not use `/tmp/subminer-socket`. - IPC socket on Windows is `\\.\pipe\subminer-socket` - do not use `/tmp/subminer-socket`.
- Config is stored at `%APPDATA%\SubMiner\config.jsonc`. - Config is stored at `%APPDATA%\SubMiner\config.jsonc`.
## Manual Launcher Install ## Manual Launcher Install
@@ -374,4 +374,4 @@ cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
Override with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`. Override with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
Next: [Usage](/usage) learn about the `subminer` wrapper, keybindings, and YouTube playback. Next: [Usage](/usage) - learn about the `subminer` wrapper, keybindings, and YouTube playback.
+9 -9
View File
@@ -1,8 +1,8 @@
# IPC + Runtime Contracts # IPC + Runtime Contracts
SubMiner's Electron app runs two isolated processes main and renderer that can only communicate through IPC channels. This boundary is intentional: the renderer is an untrusted surface (it loads Yomitan, renders user-controlled subtitle text, and runs in a Chromium sandbox), so every message crossing the bridge passes through a validation layer before it can reach domain logic. SubMiner's Electron app runs two isolated processes - main and renderer - that can only communicate through IPC channels. This boundary is intentional: the renderer is an untrusted surface (it loads Yomitan, renders user-controlled subtitle text, and runs in a Chromium sandbox), so every message crossing the bridge passes through a validation layer before it can reach domain logic.
The contract system enforces this by making channel names, payload shapes, and validators co-located and co-evolved. A change to any IPC surface touches the contract, the validator, the preload bridge, and the handler in the same commit drift between any of those layers is treated as a bug. The contract system enforces this by making channel names, payload shapes, and validators co-located and co-evolved. A change to any IPC surface touches the contract, the validator, the preload bridge, and the handler in the same commit - drift between any of those layers is treated as a bug.
## Message Flow ## Message Flow
@@ -38,7 +38,7 @@ flowchart TB
## Runtime Sockets ## Runtime Sockets
The renderer↔main bridge above lives *inside* the Electron app. A separate set of OS sockets connects the app to the other runtimes mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes. The renderer↔main bridge above lives *inside* the Electron app. A separate set of OS sockets connects the app to the other runtimes - mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes.
- **mpv IPC socket** (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows): the `MpvIpcClient` in the main process connects here to send JSON commands and subscribe to playback/subtitle properties via `observe_property`. Created by mpv's `--input-ipc-server`. - **mpv IPC socket** (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows): the `MpvIpcClient` in the main process connects here to send JSON commands and subscribe to playback/subtitle properties via `observe_property`. Created by mpv's `--input-ipc-server`.
- **App control socket** (`/tmp/subminer-control-<uid>-<hash>.sock`, or a named pipe on Windows): the launcher and the mpv plugin send CLI-style commands (`--start`, `--show-visible-overlay`, `--texthooker`) to a running app here. It also dedupes a second `subminer` invocation into the existing instance instead of launching twice. - **App control socket** (`/tmp/subminer-control-<uid>-<hash>.sock`, or a named pipe on Windows): the launcher and the mpv plugin send CLI-style commands (`--start`, `--show-visible-overlay`, `--texthooker`) to a running app here. It also dedupes a second `subminer` invocation into the existing instance instead of launching twice.
@@ -73,7 +73,7 @@ How these sockets are established during launch is covered in [Playback Startup
| --- | --- | | --- | --- |
| `src/shared/ipc/contracts.ts` | Canonical channel names and payload type contracts. Single source of truth for both processes. | | `src/shared/ipc/contracts.ts` | Canonical channel names and payload type contracts. Single source of truth for both processes. |
| `src/shared/ipc/validators.ts` | Runtime payload parsers and type guards. Every `invoke` payload is validated here before the handler runs. | | `src/shared/ipc/validators.ts` | Runtime payload parsers and type guards. Every `invoke` payload is validated here before the handler runs. |
| `src/preload.ts` | Renderer-side bridge. Exposes a typed API surface to the renderer only approved channels are accessible. | | `src/preload.ts` | Renderer-side bridge. Exposes a typed API surface to the renderer - only approved channels are accessible. |
| `src/main/ipc-runtime.ts` | Main-process handler registration and routing. Wires validated channels to domain handlers. | | `src/main/ipc-runtime.ts` | Main-process handler registration and routing. Wires validated channels to domain handlers. |
| `src/core/services/ipc.ts` | Service-level invoke handling. Applies guardrails (validation, error wrapping) before calling domain logic. | | `src/core/services/ipc.ts` | Service-level invoke handling. Applies guardrails (validation, error wrapping) before calling domain logic. |
| `src/core/services/anki-jimaku-ipc.ts` | Integration-specific IPC boundary for Anki and Jimaku operations. | | `src/core/services/anki-jimaku-ipc.ts` | Integration-specific IPC boundary for Anki and Jimaku operations. |
@@ -81,19 +81,19 @@ How these sockets are established during launch is covered in [Playback Startup
## Contract Rules ## Contract Rules
These rules exist to prevent a class of bugs where the renderer and main process silently disagree about message shapes which surfaces as undefined fields, swallowed errors, or state corruption. These rules exist to prevent a class of bugs where the renderer and main process silently disagree about message shapes - which surfaces as undefined fields, swallowed errors, or state corruption.
- **Use shared constants.** Channel names come from `contracts.ts`, never ad-hoc literal strings. This makes channels greppable and refactor-safe. - **Use shared constants.** Channel names come from `contracts.ts`, never ad-hoc literal strings. This makes channels greppable and refactor-safe.
- **Validate before handling.** Every `invoke` payload passes through `validators.ts` before reaching domain logic. This catches shape drift at the boundary instead of deep inside a service. - **Validate before handling.** Every `invoke` payload passes through `validators.ts` before reaching domain logic. This catches shape drift at the boundary instead of deep inside a service.
- **Return structured failures.** Handlers return `{ ok: false, error: string }` on failure rather than throwing. The renderer can always distinguish success from failure without try/catch. - **Return structured failures.** Handlers return `{ ok: false, error: string }` on failure rather than throwing. The renderer can always distinguish success from failure without try/catch.
- **Keep payloads narrow.** Send only what the handler needs. Avoid passing entire state objects across the bridge it couples the renderer to internal main-process structure. - **Keep payloads narrow.** Send only what the handler needs. Avoid passing entire state objects across the bridge - it couples the renderer to internal main-process structure.
- **Co-evolve all layers.** When a payload shape changes, update `contracts.ts`, `validators.ts`, `preload.ts`, and the handler in the same commit. Partial updates are treated as bugs. - **Co-evolve all layers.** When a payload shape changes, update `contracts.ts`, `validators.ts`, `preload.ts`, and the handler in the same commit. Partial updates are treated as bugs.
## Two Message Patterns ## Two Message Patterns
**Invoke (request/response):** The renderer calls a typed bridge method and awaits a result. The main process validates the payload, runs the handler, and returns a structured response. Used for operations where the renderer needs a result lookups, config reads, mining actions. **Invoke (request/response):** The renderer calls a typed bridge method and awaits a result. The main process validates the payload, runs the handler, and returns a structured response. Used for operations where the renderer needs a result - lookups, config reads, mining actions.
**Fire-and-forget (send):** The renderer sends a message with no response. The main process validates and handles it silently. Malformed payloads are dropped. Used for notifications where the renderer doesn't need confirmation UI state hints, focus events, position updates. **Fire-and-forget (send):** The renderer sends a message with no response. The main process validates and handles it silently. Malformed payloads are dropped. Used for notifications where the renderer doesn't need confirmation - UI state hints, focus events, position updates.
## Add a New IPC Action ## Add a New IPC Action
@@ -108,7 +108,7 @@ These rules exist to prevent a class of bugs where the renderer and main process
- Prefer runtime/domain composition via `src/main/runtime/composers/*` and `src/main/runtime/domains/*`. IPC handlers should delegate to composers rather than containing orchestration logic. - Prefer runtime/domain composition via `src/main/runtime/composers/*` and `src/main/runtime/domains/*`. IPC handlers should delegate to composers rather than containing orchestration logic.
- Route shared mutable state updates through transition helpers in `src/main/state.ts` for migrated domains. Direct mutation from IPC handlers bypasses invariant checks. - Route shared mutable state updates through transition helpers in `src/main/state.ts` for migrated domains. Direct mutation from IPC handlers bypasses invariant checks.
- Keep IPC handlers thin they validate, delegate, and return. Business logic belongs in services. - Keep IPC handlers thin - they validate, delegate, and return. Business logic belongs in services.
## Troubleshooting ## Troubleshooting
+10 -10
View File
@@ -1,12 +1,12 @@
# Jellyfin Integration # Jellyfin Integration
[Jellyfin](https://jellyfin.org) is a free, self-hosted media server think of it as your own private streaming service for video you own. If you keep your anime on a Jellyfin server, SubMiner can play episodes through mpv with the full mining overlay. [Jellyfin](https://jellyfin.org) is a free, self-hosted media server - think of it as your own private streaming service for video you own. If you keep your anime on a Jellyfin server, SubMiner can play episodes through mpv with the full mining overlay.
::: tip Who needs this? ::: tip Who needs this?
This page is only relevant if you already run (or have access to) a Jellyfin server. If you watch local files or YouTube, you can skip it. The in-app setup window (`subminer jellyfin`) is the easiest starting point. This page is only relevant if you already run (or have access to) a Jellyfin server. If you watch local files or YouTube, you can skip it. The in-app setup window (`subminer jellyfin`) is the easiest starting point.
::: :::
SubMiner can act as a **cast-to-device target** for Jellyfin (similar to jellyfin-mpv-shim). Sign in once, turn on discovery, and SubMiner shows up in the "Play on…" / cast menu of any Jellyfin app web, phone, or TV. Pick an episode, cast it to SubMiner, and it plays in SubMiner's mpv window with the full overlay and Yomitan click-to-lookup. SubMiner can act as a **cast-to-device target** for Jellyfin (similar to jellyfin-mpv-shim). Sign in once, turn on discovery, and SubMiner shows up in the "Play on…" / cast menu of any Jellyfin app - web, phone, or TV. Pick an episode, cast it to SubMiner, and it plays in SubMiner's mpv window with the full overlay and Yomitan click-to-lookup.
This is the recommended way to use Jellyfin with SubMiner. A terminal-only option is covered in [Launcher playback](#launcher-playback) at the end. This is the recommended way to use Jellyfin with SubMiner. A terminal-only option is covered in [Launcher playback](#launcher-playback) at the end.
@@ -28,7 +28,7 @@ Open the tray menu and click **Configure Jellyfin**. In the window that opens, e
On success, SubMiner: On success, SubMiner:
- saves an encrypted session token your password is never stored, - saves an encrypted session token - your password is never stored,
- turns the Jellyfin integration on, and - turns the Jellyfin integration on, and
- remembers the server and username for next time. - remembers the server and username for next time.
@@ -38,12 +38,12 @@ Reopen this window any time to switch servers or **Logout**.
Discovery is what makes SubMiner appear as a cast target. Two ways to enable it: Discovery is what makes SubMiner appear as a cast target. Two ways to enable it:
- **For the current session** open the tray menu and tick **Jellyfin Discovery**. (This item appears once you've signed in.) - **For the current session** - open the tray menu and tick **Jellyfin Discovery**. (This item appears once you've signed in.)
- **Automatically on every launch** already on by default. After your first sign-in, SubMiner auto-connects to Jellyfin at startup, so the cast target is ready without touching the tray. You can change this under [Settings](#settings). - **Automatically on every launch** - already on by default. After your first sign-in, SubMiner auto-connects to Jellyfin at startup, so the cast target is ready without touching the tray. You can change this under [Settings](#settings).
### 4. Cast from any Jellyfin app ### 4. Cast from any Jellyfin app
In the Jellyfin web UI or mobile app, start playing something, open the **cast / "Play on"** menu, and pick your device SubMiner appears there named after your computer's hostname. Playback opens in SubMiner. In the Jellyfin web UI or mobile app, start playing something, open the **cast / "Play on"** menu, and pick your device - SubMiner appears there named after your computer's hostname. Playback opens in SubMiner.
From then on, pause / resume / seek / stop and audio or subtitle track changes you make in the Jellyfin app are mirrored in SubMiner, and your watch progress syncs back to Jellyfin (now-playing and resume position). From then on, pause / resume / seek / stop and audio or subtitle track changes you make in the Jellyfin app are mirrored in SubMiner, and your watch progress syncs back to Jellyfin (now-playing and resume position).
@@ -63,7 +63,7 @@ All Jellyfin options live under **Settings → Integrations → Jellyfin** (open
| Setting | Default | What it does | | Setting | Default | What it does |
| ------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------- | | ------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------- |
| **Enabled** | Off | Turns the Jellyfin integration on. Switched on for you when you sign in. | | **Enabled** | Off | Turns the Jellyfin integration on. Switched on for you when you sign in. |
| **Server Url** | | Your Jellyfin server. Filled in when you sign in. | | **Server Url** | - | Your Jellyfin server. Filled in when you sign in. |
| **Remote Control Enabled** | On | Lets SubMiner act as a cast target. | | **Remote Control Enabled** | On | Lets SubMiner act as a cast target. |
| **Remote Control Auto Connect** | On | Connects to Jellyfin at startup so discovery is automatic. Turn off if you'd rather start it from the tray each time. | | **Remote Control Auto Connect** | On | Connects to Jellyfin at startup so discovery is automatic. Turn off if you'd rather start it from the tray each time. |
| **Auto Announce** | Off | Re-broadcasts visibility on connect. Enable if your device is slow to appear in the cast menu. | | **Auto Announce** | Off | Re-broadcasts visibility on connect. Enable if your device is slow to appear in the cast menu. |
@@ -88,14 +88,14 @@ See [Configuration](/configuration) for the full list (transcode codec, direct-p
**SubMiner doesn't appear in the cast menu** **SubMiner doesn't appear in the cast menu**
- Make sure SubMiner is running. - Make sure SubMiner is running.
- Make sure you're signed in reopen **Configure Jellyfin** and log in again if your token expired. - Make sure you're signed in - reopen **Configure Jellyfin** and log in again if your token expired.
- Make sure discovery is on (tray **Jellyfin Discovery**, or **Remote Control Auto Connect** in settings). - Make sure discovery is on (tray **Jellyfin Discovery**, or **Remote Control Auto Connect** in settings).
- Make sure SubMiner and the Jellyfin client point at the same server. - Make sure SubMiner and the Jellyfin client point at the same server.
**Casting starts but nothing plays** **Casting starts but nothing plays**
- Confirm the item plays normally in another Jellyfin client. - Confirm the item plays normally in another Jellyfin client.
- If mpv was closed, give it a moment SubMiner launches it on demand and retries. - If mpv was closed, give it a moment - SubMiner launches it on demand and retries.
**SubMiner keeps disconnecting** **SubMiner keeps disconnecting**
@@ -104,7 +104,7 @@ See [Configuration](/configuration) for the full list (transcode codec, direct-p
## Security notes ## Security notes
- The Jellyfin session (access token + user ID) is kept in SubMiner's local encrypted token storage. Your password is used only to log in and is never saved. - The Jellyfin session (access token + user ID) is kept in SubMiner's local encrypted token storage. Your password is used only to log in and is never saved.
- Treat the token storage and your `config.jsonc` as secrets don't commit them. - Treat the token storage and your `config.jsonc` as secrets - don't commit them.
- Advanced/headless: the `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID` environment variables can supply a session without the sign-in window. - Advanced/headless: the `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID` environment variables can supply a session without the sign-in window.
## Launcher playback ## Launcher playback
+16 -16
View File
@@ -1,6 +1,6 @@
# Jimaku Integration # Jimaku Integration
[Jimaku](https://jimaku.cc) is a community-driven subtitle repository for anime a shared online library of subtitle files contributed by other learners. SubMiner integrates with the Jimaku API so you can search, browse, and download Japanese subtitle files directly from the overlay no alt-tabbing or manual file management required. Downloaded subtitles are loaded into mpv immediately. [Jimaku](https://jimaku.cc) is a community-driven subtitle repository for anime - a shared online library of subtitle files contributed by other learners. SubMiner integrates with the Jimaku API so you can search, browse, and download Japanese subtitle files directly from the overlay - no alt-tabbing or manual file management required. Downloaded subtitles are loaded into mpv immediately.
::: tip Prerequisite: a free API key ::: tip Prerequisite: a free API key
You need a Jimaku account and an API key (a personal access string) before this feature works. Create an account at [jimaku.cc](https://jimaku.cc), copy your key, and add it to your config as shown under [Configuration](#configuration) below. Without a key, the search modal will report "Jimaku API key not set." You need a Jimaku account and an API key (a personal access string) before this feature works. Create an account at [jimaku.cc](https://jimaku.cc), copy your key, and add it to your config as shown under [Configuration](#configuration) below. Without a key, the search modal will report "Jimaku API key not set."
@@ -10,14 +10,14 @@ You need a Jimaku account and an API key (a personal access string) before this
The Jimaku integration runs through an in-overlay modal accessible via a keyboard shortcut (`Ctrl+Shift+J` by default). The Jimaku integration runs through an in-overlay modal accessible via a keyboard shortcut (`Ctrl+Shift+J` by default).
When you open the modal, SubMiner parses the current video filename to extract a title, season, and episode number. Common naming conventions are supported `S01E03`, `1x03`, `E03`, and dash-separated episode numbers all work. If the filename yields a high-confidence match (title + episode), SubMiner auto-searches immediately. When you open the modal, SubMiner parses the current video filename to extract a title, season, and episode number. Common naming conventions are supported - `S01E03`, `1x03`, `E03`, and dash-separated episode numbers all work. If the filename yields a high-confidence match (title + episode), SubMiner auto-searches immediately.
From there: From there:
1. **Search** SubMiner queries the Jimaku API with the parsed title. Results appear as a list of anime entries (Japanese and English names). 1. **Search** - SubMiner queries the Jimaku API with the parsed title. Results appear as a list of anime entries (Japanese and English names).
2. **Browse entries** Select an entry to load its available subtitle files, filtered by episode if one was detected. 2. **Browse entries** - Select an entry to load its available subtitle files, filtered by episode if one was detected.
3. **Browse files** Files show name, size, and last-modified date. If a language preference is configured, files are sorted accordingly (e.g., Japanese-tagged files first). 3. **Browse files** - Files show name, size, and last-modified date. If a language preference is configured, files are sorted accordingly (e.g., Japanese-tagged files first).
4. **Download** Selecting a file downloads it to the same directory as the video (or a temp directory for remote/streamed media) and loads it into mpv as a new subtitle track. 4. **Download** - Selecting a file downloads it to the same directory as the video (or a temp directory for remote/streamed media) and loads it into mpv as a new subtitle track.
If no files match the current episode filter, a "Show all files" button lets you broaden the search to all episodes for that entry. If no files match the current episode filter, a "Show all files" button lets you broaden the search to all episodes for that entry.
@@ -48,8 +48,8 @@ Add a `jimaku` section to your `config.jsonc`:
| Option | Type | Default | Description | | Option | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `jimaku.apiKey` | `string` | | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. | | `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. |
| `jimaku.apiKeyCommand` | `string` | | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). | | `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). |
| `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. | | `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
| `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. | | `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. |
| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. | | `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. |
@@ -68,8 +68,8 @@ The keyboard shortcut is configured separately under `shortcuts`:
An API key is required to use the Jimaku integration. You can get one from [jimaku.cc](https://jimaku.cc). There are two ways to provide it: An API key is required to use the Jimaku integration. You can get one from [jimaku.cc](https://jimaku.cc). There are two ways to provide it:
- **`apiKey`** set the key directly in config. Simple, but the key is stored in plaintext. - **`apiKey`** - set the key directly in config. Simple, but the key is stored in plaintext.
- **`apiKeyCommand`** a shell command that outputs the key. Runs with a 10-second timeout. Preferred if you use a secret manager like `pass`, `gpg`, or a keychain tool. - **`apiKeyCommand`** - a shell command that outputs the key. Runs with a 10-second timeout. Preferred if you use a secret manager like `pass`, `gpg`, or a keychain tool.
If both are set, `apiKey` takes priority. If both are set, `apiKey` takes priority.
@@ -79,8 +79,8 @@ SubMiner extracts media info from the current video path to pre-fill the search
- **Season + episode patterns:** `S01E03`, `1x03` - **Season + episode patterns:** `S01E03`, `1x03`
- **Episode-only patterns:** `E03`, `EP03`, or dash-separated numbers like `Title - 03 -` - **Episode-only patterns:** `E03`, `EP03`, or dash-separated numbers like `Title - 03 -`
- **Bracket tags:** `[SubGroup]`, `[1080p]`, `[HEVC]` stripped before title extraction - **Bracket tags:** `[SubGroup]`, `[1080p]`, `[HEVC]` - stripped before title extraction
- **Year tags:** `(2024)` stripped - **Year tags:** `(2024)` - stripped
- **Dots and underscores:** treated as spaces - **Dots and underscores:** treated as spaces
- **Remote/streamed URLs:** SubMiner checks URL query parameters (`title`, `name`, `q`) and path segments to extract a meaningful title - **Remote/streamed URLs:** SubMiner checks URL query parameters (`title`, `name`, `q`) and path segments to extract a meaningful title
@@ -98,7 +98,7 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati
**No entries found** **No entries found**
Try simplifying the title remove season/episode qualifiers and search with just the anime name. Jimaku's search matches against its own database of anime titles, so the exact spelling matters. Try simplifying the title - remove season/episode qualifiers and search with just the anime name. Jimaku's search matches against its own database of anime titles, so the exact spelling matters.
**No files found for this episode** **No files found for this episode**
@@ -110,6 +110,6 @@ Verify mpv is running and connected via IPC. SubMiner loads the subtitle by issu
## Related ## Related
- [Configuration Reference](/configuration#jimaku) full config options - [Configuration Reference](/configuration#jimaku) - full config options
- [Mining Workflow](/mining-workflow#jimaku-subtitle-search) how Jimaku fits into the sentence mining loop - [Mining Workflow](/mining-workflow#jimaku-subtitle-search) - how Jimaku fits into the sentence mining loop
- [Troubleshooting](/troubleshooting#jimaku) additional error guidance - [Troubleshooting](/troubleshooting#jimaku) - additional error guidance
+2 -2
View File
@@ -3,7 +3,7 @@
The `subminer` launcher is an all-in-one script that handles video selection, mpv startup, and overlay management. It is the recommended way to use SubMiner on Linux and macOS because it guarantees mpv is launched with the correct IPC socket and SubMiner defaults. It's a Bun script distributed as a release asset alongside the AppImage and DMG. The `subminer` launcher is an all-in-one script that handles video selection, mpv startup, and overlay management. It is the recommended way to use SubMiner on Linux and macOS because it guarantees mpv is launched with the correct IPC socket and SubMiner defaults. It's a Bun script distributed as a release asset alongside the AppImage and DMG.
::: tip Windows users ::: tip Windows users
On Windows, the recommended way to launch playback is the **SubMiner mpv** shortcut created during first-run setup double-click it, drag a file onto it, or run `SubMiner.exe --launch-mpv` from a terminal. See [Windows mpv Shortcut](/usage#windows-mpv-shortcut) for details. On Windows, the recommended way to launch playback is the **SubMiner mpv** shortcut created during first-run setup - double-click it, drag a file onto it, or run `SubMiner.exe --launch-mpv` from a terminal. See [Windows mpv Shortcut](/usage#windows-mpv-shortcut) for details.
::: :::
## Video Picker ## Video Picker
@@ -121,4 +121,4 @@ With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`
- Default log level is `info` - Default log level is `info`
- `--background` mode defaults to `warn` unless `--log-level` is explicitly set - `--background` mode defaults to `warn` unless `--log-level` is explicitly set
- `--dev` / `--debug` control app behavior, not logging verbosity use `--log-level` for that - `--dev` / `--debug` control app behavior, not logging verbosity - use `--log-level` for that
+36 -34
View File
@@ -1,14 +1,14 @@
# Mining Workflow # Mining Workflow
This guide walks through the sentence mining loop from watching a video to creating Anki cards with audio, screenshots, and context. This guide walks through the sentence mining loop - from watching a video to creating Anki cards with audio, screenshots, and context.
## Overview ## 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. 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.
> **Yomitan** is the popup dictionary that shows definitions when you hover or scan a word. **AnkiConnect** is the add-on that lets SubMiner talk to Anki. Both are set up during installation see [Anki Integration](/anki-integration) if you have not configured them yet. > **Yomitan** is the popup dictionary that shows definitions when you hover or scan a word. **AnkiConnect** is the add-on that lets SubMiner talk to Anki. Both are set up during installation - see [Anki Integration](/anki-integration) if you have not configured them yet.
## Creating Anki Cards ## Creating Anki Cards
@@ -39,7 +39,7 @@ If you prefer a hands-on approach (animecards-style), you can copy the current s
1. Add a word via Yomitan as usual. 1. Add a word via Yomitan as usual.
2. Press `Ctrl/Cmd+C` to copy the current subtitle line to the clipboard. 2. Press `Ctrl/Cmd+C` to copy the current subtitle line to the clipboard.
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1``9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard. - For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1``9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation the same fields auto-update would fill. 3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation - the same fields auto-update would fill.
Manual clipboard updates always replace generated sentence audio, even when `ankiConnect.behavior.overwriteAudio` is disabled. The word audio field is left unchanged because the word itself does not change in this flow. Manual clipboard updates always replace generated sentence audio, even when `ankiConnect.behavior.overwriteAudio` is disabled. The word audio field is left unchanged because the word itself does not change in this flow.
@@ -61,7 +61,7 @@ Create a standalone sentence card without going through Yomitan:
The sentence card uses the note type configured in `isLapis.sentenceCardModel` and always maps sentence/audio to `Sentence` and `SentenceAudio`. The sentence card uses the note type configured in `isLapis.sentenceCardModel` and always maps sentence/audio to `Sentence` and `SentenceAudio`.
::: warning Requires Lapis/Kiku note type ::: warning Requires Lapis/Kiku note type
Sentence card creation requires a [Lapis](https://github.com/donkuri/lapis) or [Kiku](https://github.com/youyoumu/kiku) compatible note type and `ankiConnect.isLapis.enabled: true` in your config. See [Anki Integration Sentence Cards](/anki-integration#sentence-cards-lapis) for setup. Sentence card creation requires a [Lapis](https://github.com/donkuri/lapis) or [Kiku](https://github.com/youyoumu/kiku) compatible note type and `ankiConnect.isLapis.enabled: true` in your config. See [Anki Integration - Sentence Cards](/anki-integration#sentence-cards-lapis) for setup.
::: :::
### 4. Mark as Audio Card ### 4. Mark as Audio Card
@@ -69,7 +69,7 @@ Sentence card creation requires a [Lapis](https://github.com/donkuri/lapis) or [
After adding a word via Yomitan, press the audio card shortcut to overwrite the audio with a longer clip spanning the full subtitle timing. After adding a word via Yomitan, press the audio card shortcut to overwrite the audio with a longer clip spanning the full subtitle timing.
::: warning Requires Lapis/Kiku note type ::: warning Requires Lapis/Kiku note type
Audio card marking requires a [Lapis](https://github.com/donkuri/lapis) or [Kiku](https://github.com/youyoumu/kiku) compatible note type and `ankiConnect.isLapis.enabled: true` in your config. See [Anki Integration Sentence Cards](/anki-integration#sentence-cards-lapis) for setup. Audio card marking requires a [Lapis](https://github.com/donkuri/lapis) or [Kiku](https://github.com/youyoumu/kiku) compatible note type and `ankiConnect.isLapis.enabled: true` in your config. See [Anki Integration - Sentence Cards](/anki-integration#sentence-cards-lapis) for setup.
::: :::
### Field Grouping (Kiku) ### Field Grouping (Kiku)
@@ -82,11 +82,11 @@ If you mine the same word from different sentences, SubMiner can merge the cards
- **Auto mode** (`ankiConnect.isKiku.fieldGrouping: "auto"`): Merges automatically. Both sentences, audio clips, and images are combined into the existing card. The duplicate is optionally deleted. - **Auto mode** (`ankiConnect.isKiku.fieldGrouping: "auto"`): Merges automatically. Both sentences, audio clips, and images are combined into the existing card. The duplicate is optionally deleted.
- **Manual mode** (`ankiConnect.isKiku.fieldGrouping: "manual"`): A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming. - **Manual mode** (`ankiConnect.isKiku.fieldGrouping: "manual"`): A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming.
See [Anki Integration Field Grouping](/anki-integration#field-grouping-kiku) for configuration options, merge behavior, and modal keyboard shortcuts. See [Anki Integration - Field Grouping](/anki-integration#field-grouping-kiku) for configuration options, merge behavior, and modal keyboard shortcuts.
## Overlay Model ## Overlay Model
SubMiner uses one overlay window with modal surfaces. It carries two subtitle bars a primary reading bar and a secondary translation/context bar plus modal dialogs that open on top. SubMiner uses one overlay window with modal surfaces. It carries two subtitle bars - a primary reading bar and a secondary translation/context bar - plus modal dialogs that open on top.
Toggle the entire overlay window with `Alt+Shift+O` (global) or `y-t` (mpv plugin). Toggle the entire overlay window with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
@@ -99,14 +99,14 @@ The primary bar renders subtitles as tokenized hoverable word spans. Each word i
- Auto pause/resume while the Yomitan popup is open (enabled by default via `subtitleStyle.autoPauseVideoOnYomitanPopup`) - Auto pause/resume while the Yomitan popup is open (enabled by default via `subtitleStyle.autoPauseVideoOnYomitanPopup`)
- Right-click to pause/resume - Right-click to pause/resume
- Right-click + drag to reposition subtitles - Right-click + drag to reposition subtitles
- **Reading annotations** known words, N+1 targets, character-name matches, JLPT levels, and frequency hits can all be visually highlighted - **Reading annotations** - known words, N+1 targets, character-name matches, JLPT levels, and frequency hits can all be visually highlighted
### Secondary Subtitle Bar ### Secondary Subtitle Bar
The secondary bar is a compact top-strip region in the same overlay window. It shows a secondary subtitle track (typically English) for translation/context while keeping the primary reading flow below. It is useful for: The secondary bar is a compact top-strip region in the same overlay window. It shows a secondary subtitle track (typically English) for translation/context while keeping the primary reading flow below. It is useful for:
- Quick comprehension checks without leaving the mining flow. - Quick comprehension checks without leaving the mining flow.
- Auto-populating the translation field on mined cards when a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it). - Auto-populating the translation field on mined cards - when a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it).
It is controlled by `secondarySub` configuration and shares its lifecycle with the main overlay window. Cycle which track feeds it with `Shift+J`. It is controlled by `secondarySub` configuration and shares its lifecycle with the main overlay window. Cycle which track feeds it with `Shift+J`.
@@ -114,18 +114,18 @@ It is controlled by `secondarySub` configuration and shares its lifecycle with t
Both the primary and secondary subtitle bars share the same three visibility modes, and each can be changed independently at runtime: Both the primary and secondary subtitle bars share the same three visibility modes, and each can be changed independently at runtime:
- **Hidden** the bar is not shown. - **Hidden** - the bar is not shown.
- **Visible** the bar is always shown. - **Visible** - the bar is always shown.
- **Hover** the bar is revealed only while you hover over the overlay. - **Hover** - the bar is revealed only while you hover over the overlay.
By default the **primary** bar is `visible` (`subtitleStyle.primaryDefaultMode`) and the **secondary** bar is `hover` (`secondarySub.defaultMode`). By default the **primary** bar is `visible` (`subtitleStyle.primaryDefaultMode`) and the **secondary** bar is `hover` (`secondarySub.defaultMode`).
Cycle each bar's mode at runtime with its own shortcut: Cycle each bar's mode at runtime with its own shortcut:
| Shortcut | Action | Config key | | Shortcut | Action | Config key |
| -------------------- | -------------------------------------------------------- | ------------------------------ | | ------------------ | -------------------------------------------------------- | ------------------------------ |
| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local | | `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | | `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
### Modal Surfaces ### Modal Surfaces
@@ -133,7 +133,7 @@ Jimaku search, field-grouping, runtime options, and manual subsync open as modal
## Looking Up Words ## Looking Up Words
1. Hover over the subtitle area the overlay activates pointer events. 1. Hover over the subtitle area - the overlay activates pointer events.
2. Hover the word you want. SubMiner keeps per-token boundaries so Yomitan can target that token cleanly. 2. Hover the word you want. SubMiner keeps per-token boundaries so Yomitan can target that token cleanly.
3. Trigger Yomitan lookup with your configured lookup key/modifier (for example `Shift` if that is how your Yomitan profile is set up). 3. Trigger Yomitan lookup with your configured lookup key/modifier (for example `Shift` if that is how your Yomitan profile is set up).
4. Yomitan opens its lookup popup for the hovered token. 4. Yomitan opens its lookup popup for the hovered token.
@@ -143,17 +143,17 @@ Jimaku search, field-grouping, runtime options, and manual subsync open as modal
With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard: With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard:
1. **Navigate** push the left stick left/right to move the token highlight across subtitle words. 1. **Navigate** - push the left stick left/right to move the token highlight across subtitle words.
2. **Look up** press `A` to trigger Yomitan lookup on the highlighted word. 2. **Look up** - press `A` to trigger Yomitan lookup on the highlighted word.
3. **Browse the popup** push the left stick up/down to smooth-scroll through the Yomitan popup, or use the right stick for larger jumps. 3. **Browse the popup** - push the left stick up/down to smooth-scroll through the Yomitan popup, or use the right stick for larger jumps.
4. **Cycle audio** press `R1` to move to the next dictionary audio entry, `L1` to play the current one. 4. **Cycle audio** - press `R1` to move to the next dictionary audio entry, `L1` to play the current one.
5. **Mine** press `X` to create an Anki card for the current sentence (same as `Ctrl+S`). 5. **Mine** - press `X` to create an Anki card for the current sentence (same as `Ctrl+S`).
6. **Close** press `B` to dismiss the Yomitan popup and return to subtitle navigation. 6. **Close** - press `B` to dismiss the Yomitan popup and return to subtitle navigation.
7. **Pause/resume** press `L3` (left stick click) to toggle mpv pause at any time. 7. **Pause/resume** - press `L3` (left stick click) to toggle mpv pause at any time.
After controller support is enabled, the controller and keyboard can be used interchangeably switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller. After controller support is enabled, the controller and keyboard can be used interchangeably - switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
See [Usage Controller Support](/usage#controller-support) for setup details and [Configuration Controller Support](/configuration#controller-support) for the full mapping and tuning options. See [Usage - Controller Support](/usage#controller-support) for setup details and [Configuration - Controller Support](/configuration#controller-support) for the full mapping and tuning options.
## Subtitle Sync (Subsync) ## Subtitle Sync (Subsync)
@@ -166,11 +166,13 @@ 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. 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.
Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found. 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 ## Texthooker
SubMiner runs a local HTTP server at `http://127.0.0.1:5174` (configurable port) that serves a texthooker UI. This allows external tools such as a browser-based Yomitan instance to receive subtitle text in real time. SubMiner runs a local HTTP server at `http://127.0.0.1:5174` (configurable port) that serves a texthooker UI. This allows external tools - such as a browser-based Yomitan instance - to receive subtitle text in real time.
The texthooker page displays the current subtitle and updates as new lines arrive. This is useful if you prefer to do lookups in a browser rather than through the overlay's built-in Yomitan. The texthooker page displays the current subtitle and updates as new lines arrive. This is useful if you prefer to do lookups in a browser rather than through the overlay's built-in Yomitan.
@@ -180,8 +182,8 @@ If you want to build your own browser client, websocket consumer, or automation
These features support the mining loop but have their own dedicated pages: These features support the mining loop but have their own dedicated pages:
- **[Jimaku subtitle search](/jimaku-integration)** search and download anime subtitle files directly from the overlay (`Ctrl+Shift+J` by default), then load them into mpv. - **[Jimaku subtitle search](/jimaku-integration)** - search and download anime subtitle files directly from the overlay (`Ctrl+Shift+J` by default), then load them into mpv.
- **[N+1 word highlighting](/subtitle-annotations#n1-word-highlighting)** cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion. - **[N+1 word highlighting](/subtitle-annotations#n1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
- **[Immersion tracking](/immersion-tracking)** log watching and mining activity to a local database and view session times, words seen, and cards mined in the built-in stats dashboard. - **[Immersion tracking](/immersion-tracking)** - log watching and mining activity to a local database and view session times, words seen, and cards mined in the built-in stats dashboard.
Next: [Anki Integration](/anki-integration) field mapping, media generation, and card enrichment configuration. Next: [Anki Integration](/anki-integration) - field mapping, media generation, and card enrichment configuration.
+31 -46
View File
@@ -1,8 +1,8 @@
# MPV Plugin # 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. **Who needs this page:** Most users never touch the plugin directly - SubMiner-managed launches (the app, the `subminer` launcher, or the Windows shortcut) inject the bundled plugin automatically for that session, so there is nothing to install into mpv's global `scripts` directory. Read on if you launch mpv from another tool and want SubMiner's in-player controls, or you want to script mpv against SubMiner.
The plugin ships as a modular Lua package under `plugin/subminer/` (entry point `init.lua`, which loads `main.lua` and sibling modules). Earlier releases shipped a single global `main.lua`; runtime loading replaces it. The plugin ships as a modular Lua package under `plugin/subminer/` (entry point `init.lua`, which loads `main.lua` and sibling modules). Earlier releases shipped a single global `main.lua`; runtime loading replaces it.
@@ -27,28 +27,28 @@ input-ipc-server=\\.\pipe\subminer-socket
## Keybindings ## Keybindings
Most plugin actions use a `y` chord prefix press `y`, then the second key (a "chord"): Most plugin actions use a `y` chord prefix - press `y`, then the second key (a "chord"):
| Chord | Action | | Chord | Action |
| ---------------- | -------------------------------------- | | --------------- | -------------------------------------- |
| `y-y` | Open menu | | `y-y` | Open menu |
| `y-s` | Start overlay | | `y-s` | Start overlay |
| `y-S` | Stop overlay | | `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay | | `y-t` | Toggle visible overlay |
| `y-o` | Open settings window | | `y-o` | Open settings window |
| `y-r` | Restart overlay | | `y-r` | Restart overlay |
| `y-c` | Check status | | `y-c` | Check status |
| `y-h` | Open session help / keybinding modal | | `y-h` | Open session help / keybinding modal |
| `v` | Toggle primary subtitle bar visibility | | `v` | Toggle primary subtitle bar visibility |
| `TAB` (default) | Skip intro (AniSkip) | | `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. The bare `v` binding is a forced mpv binding. It overrides mpv's default primary subtitle visibility toggle and routes the action to SubMiner's primary subtitle bar instead.
## Shared Shortcuts (Session Bindings) ## Shared Shortcuts (Session Bindings)
The `y-*` chords above are built into the plugin. Everything else you configure under [`shortcuts.*`](/shortcuts) plus any custom [`keybindings`](/configuration) and the stats toggle/mark-watched keys is **injected into mpv at runtime**, so the same shortcut works both inside mpv and in the SubMiner overlay. You do not edit any mpv config to enable them. The `y-*` chords above are built into the plugin. Everything else you configure under [`shortcuts.*`](/shortcuts) - plus any custom [`keybindings`](/configuration) and the stats toggle/mark-watched keys - is **injected into mpv at runtime**, so the same shortcut works both inside mpv and in the SubMiner overlay. You do not edit any mpv config to enable them.
How it works: How it works:
@@ -58,11 +58,11 @@ How it works:
Because the bindings come from the same configuration the overlay uses, you maintain one set of shortcuts for both surfaces. Because the bindings come from the same configuration the overlay uses, you maintain one set of shortcuts for both surfaces.
Live updates: changing a shortcut in the app rewrites `session-bindings.json` and sends the plugin a `subminer-reload-session-bindings` script message, so mpv re-registers the bindings immediately no mpv restart required. Live updates: changing a shortcut in the app rewrites `session-bindings.json` and sends the plugin a `subminer-reload-session-bindings` script message, so mpv re-registers the bindings immediately - no mpv restart required.
Notes: Notes:
- Accelerators are normalized per platform `CommandOrControl` resolves to `Cmd` on macOS and `Ctrl` elsewhere. - Accelerators are normalized per platform - `CommandOrControl` resolves to `Cmd` on macOS and `Ctrl` elsewhere.
- Multi-line actions (`copySubtitleMultiple`, `mineSentenceMultiple`) register temporary `1``9` digit follow-up bindings after the trigger key, with `Esc` to cancel. - Multi-line actions (`copySubtitleMultiple`, `mineSentenceMultiple`) register temporary `1``9` digit follow-up bindings after the trigger key, with `Esc` to cancel.
- If two shortcuts compile to the same key, or an accelerator can't be mapped to an mpv key, the app logs a warning and skips that binding instead of registering a broken one. - If two shortcuts compile to the same key, or an accelerator can't be mapped to an mpv key, the app logs a warning and skips that binding instead of registering a broken one.
@@ -110,14 +110,14 @@ Packaged Windows plugin installs also rewrite `socket_path` to `\\.\pipe\submine
When `backend=auto`, the plugin detects the window manager: When `backend=auto`, the plugin detects the window manager:
1. **macOS** detected via platform or `OSTYPE`. 1. **macOS** - detected via platform or `OSTYPE`.
2. **Hyprland** detected via `HYPRLAND_INSTANCE_SIGNATURE`. 2. **Hyprland** - detected via `HYPRLAND_INSTANCE_SIGNATURE`.
3. **Sway** detected via `SWAYSOCK`. 3. **Sway** - detected via `SWAYSOCK`.
4. **X11** detected via `XDG_SESSION_TYPE=x11` or `DISPLAY`. 4. **X11** - detected via `XDG_SESSION_TYPE=x11` or `DISPLAY`.
5. **Fallback** defaults to X11 with a warning. 5. **Fallback** - defaults to X11 with a warning.
::: tip Wayland is compositor-specific ::: tip Wayland is compositor-specific
Native Wayland support is only available for Hyprland and Sway. If you use a different Wayland compositor, auto-detection will fall back to X11 both mpv and SubMiner must be running under Xwayland, and `xdotool` and `xwininfo` must be installed. Native Wayland support is only available for Hyprland and Sway. If you use a different Wayland compositor, auto-detection will fall back to X11 - both mpv and SubMiner must be running under Xwayland, and `xdotool` and `xwininfo` must be installed.
::: :::
## Script Messages ## Script Messages
@@ -133,10 +133,10 @@ script-message subminer-options
script-message subminer-restart script-message subminer-restart
script-message subminer-status script-message subminer-status
script-message subminer-autoplay-ready 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: 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. `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. `--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 ## 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). For how the plugin's auto-start fits into the full launch sequence - including when the launcher starts the overlay instead of the plugin - see [Playback Startup Flow](./architecture#playback-startup-flow).
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay. - **File loaded**: If `auto_start=yes`, the plugin starts the overlay.
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback). - **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused. 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). - **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. - **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. - **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.
@@ -181,4 +166,4 @@ The plugin is useful when you:
- Want on-demand overlay control without the wrapper. - Want on-demand overlay control without the wrapper.
- Use mpv's built-in file browser or playlist features. - Use mpv's built-in file browser or playlist features.
You can install both the plugin provides chord keybindings for convenience, while the wrapper handles the full lifecycle. You can install both - the plugin provides chord keybindings for convenience, while the wrapper handles the full lifecycle.
+16 -6
View File
@@ -172,10 +172,19 @@
"updates": { "updates": {
"enabled": true, // Run automatic update checks in the background. Values: true | false "enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks. "checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none "notificationType": "system", // 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 "channel": "stable" // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior. }, // 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 // Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable. // 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. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "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. "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. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================
@@ -496,7 +506,7 @@
"tags": [ "tags": [
"SubMiner" "SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. ], // 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": { "fields": {
"word": "Expression", // Card field for the mined word or expression text. "word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio. "audio": "ExpressionAudio", // Card field that receives generated sentence audio.
@@ -539,7 +549,7 @@
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false "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 "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 "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 "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting. }, // Behavior setting.
"nPlusOne": { "nPlusOne": {
@@ -634,8 +644,8 @@
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false "autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false "pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path. "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 "aniskipEnabled": true, // Enable AniSkip intro detection, chapter markers, and the skip-intro key. Values: true | false
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible. "aniskipButtonKey": "TAB" // mpv key used to skip the detected intro while the skip prompt is visible.
}, // SubMiner-managed mpv launch and bundled plugin options. }, // SubMiner-managed mpv launch and bundled plugin options.
// ========================================== // ==========================================
+11 -6
View File
@@ -1,12 +1,12 @@
# Keyboard Shortcuts # Keyboard Shortcuts
This page is the complete reference for every keystroke SubMiner responds to. If you are just getting started, focus on the **Mining Shortcuts** and **Overlay Controls** sections those cover the day-to-day mining loop. The rest can wait until you need them. This page is the complete reference for every keystroke SubMiner responds to. If you are just getting started, focus on the **Mining Shortcuts** and **Overlay Controls** sections - those cover the day-to-day mining loop. The rest can wait until you need them.
A few terms used throughout: A few terms used throughout:
- **Overlay** the transparent SubMiner window that sits on top of mpv and shows the interactive subtitles. Most shortcuts only work while this window has focus (click the video once if a shortcut seems to do nothing). - **Overlay** - the transparent SubMiner window that sits on top of mpv and shows the interactive subtitles. Most shortcuts only work while this window has focus (click the video once if a shortcut seems to do nothing).
- **`Ctrl/Cmd`** use `Ctrl` on Windows/Linux and `Cmd` (⌘) on macOS. In the config file this is written as `CommandOrControl`. - **`Ctrl/Cmd`** - use `Ctrl` on Windows/Linux and `Cmd` (⌘) on macOS. In the config file this is written as `CommandOrControl`.
- **Accelerator** Electron's name for a shortcut string like `Alt+Shift+O`. - **Accelerator** - Electron's name for a shortcut string like `Alt+Shift+O`.
All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindings`. Set any shortcut to `null` to disable it. All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindings`. Set any shortcut to `null` to disable it.
@@ -82,6 +82,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | | `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` | | `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | | `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+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | | `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
@@ -105,7 +106,7 @@ Controller input only drives the overlay while keyboard-only mode is enabled. Th
## MPV Plugin Chords ## MPV Plugin Chords
When the mpv plugin is installed, all commands use a `y` chord prefix press `y`, then the second key within 1 second. When the mpv plugin is installed, all commands use a `y` chord prefix - press `y`, then the second key within 1 second.
| Chord | Action | | Chord | Action |
| ----- | -------------------------------------- | | ----- | -------------------------------------- |
@@ -152,9 +153,13 @@ The `keybindings` array overrides or extends the overlay's built-in key handling
"keybindings": [ "keybindings": [
{ "key": "f", "command": ["cycle", "fullscreen"] }, { "key": "f", "command": ["cycle", "fullscreen"] },
{ "key": "m", "command": ["cycle", "mute"] }, { "key": "m", "command": ["cycle", "mute"] },
{ "key": "MBTN_BACK", "command": ["sub-seek", -1] },
{ "key": "MBTN_FORWARD", "command": ["sub-seek", 1] },
{ "key": "Space", "command": null }, // disable default Space → pause { "key": "Space", "command": null }, // disable default Space → pause
], ],
} }
``` ```
Both `shortcuts`, `keybindings`, and `subtitleSidebar` are [hot-reloadable](/configuration#hot-reload-behavior) — changes take effect without restarting SubMiner. Mouse keybinding names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`.
Both `shortcuts`, `keybindings`, and `subtitleSidebar` are [hot-reloadable](/configuration#hot-reload-behavior) - changes take effect without restarting SubMiner.
+15 -11
View File
@@ -2,7 +2,11 @@
SubMiner annotates subtitle tokens in real time as they appear in the overlay. Four annotation layers work together to surface useful context while you watch: **N+1 highlighting**, **character-name highlighting**, **frequency highlighting**, and **JLPT tagging**. SubMiner annotates subtitle tokens in real time as they appear in the overlay. Four annotation layers work together to surface useful context while you watch: **N+1 highlighting**, **character-name highlighting**, **frequency highlighting**, and **JLPT tagging**.
All four are opt-in and configured under `subtitleStyle`, `ankiConnect.knownWords`, and `ankiConnect.nPlusOne` in your config. They apply independently you can enable any combination. All four are opt-in and configured under `subtitleStyle`, `ankiConnect.knownWords`, and `ankiConnect.nPlusOne` in your config. They apply independently - you can enable any combination.
::: tip Tokenization
SubMiner's primary tokenizer is Yomitan itself - subtitle text is tokenized based entirely on the dictionaries you have installed in Yomitan. Installing many large dictionaries can increase noise and slow down lookups, so be selective about which dictionaries you install and their priority order.
:::
Before any of those layers render, SubMiner strips annotation metadata from tokens that are usually just subtitle glue or annotation noise. Standalone particles, auxiliaries, adnominals, common explanatory endings like `んです` / `のだ`, merged trailing quote-particle forms like `...って`, auxiliary-stem grammar tails like `そうだ` (MeCab POS3 `助動詞語幹`), repeated kana interjections, and similar non-lexical helper tokens remain hoverable in the subtitle text, but they render as plain tokens without known-word, N+1, frequency, JLPT, or name-match annotation styling. Before any of those layers render, SubMiner strips annotation metadata from tokens that are usually just subtitle glue or annotation noise. Standalone particles, auxiliaries, adnominals, common explanatory endings like `んです` / `のだ`, merged trailing quote-particle forms like `...って`, auxiliary-stem grammar tails like `そうだ` (MeCab POS3 `助動詞語幹`), repeated kana interjections, and similar non-lexical helper tokens remain hoverable in the subtitle text, but they render as plain tokens without known-word, N+1, frequency, JLPT, or name-match annotation styling.
@@ -39,7 +43,7 @@ Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection
## Character-Name Highlighting ## Character-Name Highlighting
Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles portraits, roles, voice actors, and biographical detail. Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles - portraits, roles, voice actors, and biographical detail.
**How it works:** **How it works:**
@@ -60,12 +64,12 @@ For full details on dictionary generation, name variant expansion, auto-sync lif
## Frequency Highlighting ## Frequency Highlighting
Frequency highlighting colors tokens based on how common they are, using dictionary frequency rank data. This helps you spot high-value vocabulary at a glance. Frequency highlighting colors tokens based on how common they are, using dictionary frequency rank data. This helps you spot high-value vocabulary at a glance. Frequency ranks are sourced from the **highest-ranked frequency dictionary** installed in Yomitan - other frequency dictionaries are not consulted.
**Modes:** **Modes:**
- **Single** all highlighted tokens share one color (`singleColor`). - **Single** - all highlighted tokens share one color (`singleColor`).
- **Banded** tokens are assigned to five color bands from most common to least common within the `topX` window. - **Banded** - tokens are assigned to five color bands from most common to least common within the `topX` window.
SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` files. Only tokens with a positive rank at or below `topX` are highlighted. SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` files. Only tokens with a positive rank at or below `topX` are highlighted.
@@ -130,14 +134,14 @@ All annotation layers can be toggled at runtime via the mpv command menu without
- `subtitleStyle.enableJlpt` (`On` / `Off`) - `subtitleStyle.enableJlpt` (`On` / `Off`)
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`) - `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
Toggles only apply to new subtitle lines after the change the currently displayed line is not re-tokenized in place. Toggles only apply to new subtitle lines after the change - the currently displayed line is not re-tokenized in place.
## Rendering Priority ## Rendering Priority
When multiple annotations apply to the same token, the visual priority is: When multiple annotations apply to the same token, the visual priority is:
1. **N+1 target** (highest) the single unknown word in an N+1 sentence 1. **N+1 target** (highest) - the single unknown word in an N+1 sentence
2. **Character-name match** dictionary-driven character-name token styling 2. **Character-name match** - dictionary-driven character-name token styling
3. **Known-word color** already-learned token tint 3. **Known-word color** - already-learned token tint
4. **Frequency highlight** common-word coloring (not applied when N+1/character-name/known-word already matched) 4. **Frequency highlight** - common-word coloring (not applied when N+1/character-name/known-word already matched)
5. **JLPT underline** level-based underline (stacks with the above since it uses underline rather than text color) 5. **JLPT underline** - level-based underline (stacks with the above since it uses underline rather than text color)
+3 -3
View File
@@ -10,7 +10,7 @@ When SubMiner parses the active subtitle source into a cue list, the sidebar bec
- The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`). - The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`).
- Clicking any cue seeks mpv to that timestamp. - Clicking any cue seeks mpv to that timestamp.
- The sidebar stays synchronized with the overlay media transitions and subtitle source changes update both simultaneously. - The sidebar stays synchronized with the overlay - media transitions and subtitle source changes update both simultaneously.
The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar. The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar.
@@ -18,9 +18,9 @@ The sidebar only appears when a parsed cue list is available. External subtitle
Two layout modes are available via `subtitleSidebar.layout`: Two layout modes are available via `subtitleSidebar.layout`:
**`overlay`** (default) The sidebar floats over mpv as a panel. It does not affect the player window size or position. **`overlay`** (default) - The sidebar floats over mpv as a panel. It does not affect the player window size or position.
**`embedded`** Reserves space on the right side of the player and shifts the video area to mimic a split-pane layout. Useful if you want the cue list visible without it covering the video. If you see unexpected positioning in your environment, switch back to `overlay` to isolate the issue. **`embedded`** - Reserves space on the right side of the player and shifts the video area to mimic a split-pane layout. Useful if you want the cue list visible without it covering the video. If you see unexpected positioning in your environment, switch back to `overlay` to isolate the issue.
## Configuration ## Configuration
+86 -14
View File
@@ -1,6 +1,6 @@
# Troubleshooting # Troubleshooting
Common issues and how to resolve them. Most problems fall into one of a few buckets the overlay shows but subtitles don't (see [MPV Connection](#mpv-connection)), cards aren't being created or come out empty (see [AnkiConnect](#ankiconnect)), or word lookups don't appear (see [Yomitan](#yomitan)). If an error message popped up on screen, search this page for the exact text most headings below are quoted error strings. Common issues and how to resolve them. Most problems fall into one of a few buckets - the overlay shows but subtitles don't (see [MPV Connection](#mpv-connection)), cards aren't being created or come out empty (see [AnkiConnect](#ankiconnect)), or word lookups don't appear (see [Yomitan](#yomitan)). If an error message popped up on screen, search this page for the exact text - most headings below are quoted error strings.
## MPV Connection ## MPV Connection
@@ -102,7 +102,7 @@ If the overlay never appears at all, see [Playback Startup Flow](./architecture#
**"Failed to parse MPV message"** **"Failed to parse MPV message"**
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path. Logged when a malformed JSON line arrives from the mpv socket. Usually harmless - SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
## Updates ## Updates
@@ -126,13 +126,13 @@ The detected launcher is installed in a protected path such as `/usr/local/bin/s
**OSD update notification did not appear** **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 ## AnkiConnect
**"AnkiConnect: unable to connect"** **"AnkiConnect: unable to connect"**
First confirm you've completed the [Anki Integration prerequisites](/anki-integration#prerequisites) Anki must be running with the AnkiConnect add-on installed. First confirm you've completed the [Anki Integration prerequisites](/anki-integration#prerequisites) - Anki must be running with the AnkiConnect add-on installed.
SubMiner connects to the active Anki endpoint: SubMiner connects to the active Anki endpoint:
@@ -148,7 +148,7 @@ SubMiner retries with exponential backoff (up to 5 s) and suppresses repeated er
**Cards are created but fields are empty** **Cards are created but fields are empty**
Field names in your config must match your Anki note type exactly (case-sensitive). Check `ankiConnect.fields` for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated. Field names in your config must match your Anki note type exactly (case-sensitive). Check `ankiConnect.fields` - for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated.
See [Anki Integration](/anki-integration) for the full field mapping reference. See [Anki Integration](/anki-integration) for the full field mapping reference.
@@ -169,7 +169,7 @@ Shown when SubMiner tries to update a card that no longer exists, or when AnkiCo
**Overlay appears but clicks pass through / cannot interact** **Overlay appears but clicks pass through / cannot interact**
- Make sure you are hovering over subtitle text the overlay only becomes interactive when the cursor is over a subtitle. - Make sure you are hovering over subtitle text - the overlay only becomes interactive when the cursor is over a subtitle.
- On macOS/Windows: toggle the overlay off and back on (`Alt+Shift+O`) to re-enable pointer events. - On macOS/Windows: toggle the overlay off and back on (`Alt+Shift+O`) to re-enable pointer events.
- On Linux: mouse event handling is unreliable in some Electron/compositor combinations. If clicks consistently fail, toggle the overlay off, click the underlying mpv window, then toggle it back on. - On Linux: mouse event handling is unreliable in some Electron/compositor combinations. If clicks consistently fail, toggle the overlay off, click the underlying mpv window, then toggle it back on.
@@ -208,7 +208,7 @@ If you installed from the AppImage and see this error, the package may be incomp
**Yomitan lookup popup does not appear when hovering words or triggering lookup** **Yomitan lookup popup does not appear when hovering words or triggering lookup**
- Verify Yomitan loaded successfully check the terminal output for "Loaded Yomitan extension". - Verify Yomitan loaded successfully - check the terminal output for "Loaded Yomitan extension".
- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --yomitan`) and confirm at least one dictionary is imported. - Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --yomitan`) and confirm at least one dictionary is imported.
- If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window. - If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window.
- If the overlay shows subtitles but hover lookup never resolves on tokens, the tokenizer may have failed. See the MeCab section below. - If the overlay shows subtitles but hover lookup never resolves on tokens, the tokenizer may have failed. See the MeCab section below.
@@ -232,6 +232,15 @@ Japanese word boundaries depend on Yomitan parser output. If segmentation seems
- Verify Yomitan dictionaries are installed and active. - Verify Yomitan dictionaries are installed and active.
- Note that CJK characters without spaces are segmented using parser heuristics, which is not always perfect. - Note that CJK characters without spaces are segmented using parser heuristics, which is not always perfect.
## Character Dictionary
Character names from AniList are matched and highlighted in subtitles via the bundled Yomitan. See [Character Dictionary](/character-dictionary) for setup and the full troubleshooting list - the most common issues:
- **Names not highlighting:** Confirm `subtitleStyle.nameMatchEnabled` is `true`, and that the current media resolved to an AniList entry (SubMiner needs a media ID to fetch characters). No AniList account or token is required - character data uses public GraphQL queries.
- **Inline portraits missing:** Confirm `subtitleStyle.nameMatchImagesEnabled` is `true`. Portraits also require AniList to return an image and the download to succeed during snapshot generation.
- **Wrong characters showing:** Open the in-app manager (`Ctrl/Cmd+D`) and use **Override** to pin the correct AniList match for the series.
- **Feature unavailable:** If `yomitan.externalProfilePath` is set, SubMiner runs in read-only external-profile mode and its character-dictionary features are disabled.
## Media Generation ## Media Generation
**"FFmpeg not found"** **"FFmpeg not found"**
@@ -260,7 +269,7 @@ Global shortcuts (`Alt+Shift+O`, `Alt+Shift+Y`) may conflict with other applicat
- Check your DE/WM keybinding settings for conflicts. - Check your DE/WM keybinding settings for conflicts.
- Change the shortcut in your config under `shortcuts.toggleVisibleOverlayGlobal`. - Change the shortcut in your config under `shortcuts.toggleVisibleOverlayGlobal`.
- On Wayland, global shortcut registration has limitations depending on the compositor. Only Hyprland and Sway are supported natively see the [Hyprland](#hyprland) section below for shortcut passthrough rules. Other Wayland compositors require X11/Xwayland. - On Wayland, global shortcut registration has limitations depending on the compositor. Only Hyprland and Sway are supported natively - see the [Hyprland](#hyprland) section below for shortcut passthrough rules. Other Wayland compositors require X11/Xwayland.
**Overlay keybindings not working** **Overlay keybindings not working**
@@ -317,18 +326,35 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati
### Linux ### Linux
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors are not supported both mpv and SubMiner must run under X11 or Xwayland instead. - **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma--other-wayland-compositors)).
- **X11 / Xwayland**: Requires `xdotool` and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway both mpv and SubMiner must be running under X11/Xwayland for window tracking to work. - **X11 / Xwayland**: Requires `xdotool`, `xprop`, and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway - both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host. - **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
- **Mouse passthrough**: On Linux, Electron's mouse passthrough is unreliable. SubMiner keeps pointer events enabled, meaning you may need to toggle the overlay off to interact with mpv controls underneath. - **Mouse passthrough**: On Linux X11/Xwayland, SubMiner uses `xdotool` to poll the cursor and only enables overlay input while the cursor is over subtitle or popup regions. Outside those regions, pointer input passes through to mpv. Native Wayland compositors other than Hyprland/Sway cannot provide the stacking control SubMiner needs.
### Hyprland ### Hyprland
SubMiner's overlay is a transparent, frameless, always-on-top Electron window. Hyprland needs window rules to keep it transparent and borderless, and `pass` bindings to forward global shortcuts to SubMiner. SubMiner's overlay is a transparent, frameless Electron window that must be kept above mpv. SubMiner tries to apply the floating, borderless, no-shadow, and no-blur properties itself each time it places the overlay. It detects Hyprland's active config provider and uses Lua `hl.dsp.window.*` dispatchers for recent Hyprland Lua configs, or the legacy dispatcher syntax for older hyprlang configs. On many configurations that is enough, but if your Hyprland version doesn't honor those runtime dispatches - or a broad rule in your config forces opacity/blur on every window - add explicit window rules so the overlay is exempt. You also need `pass` bindings to forward global shortcuts to SubMiner (see below).
**Overlay is not transparent or has a visible border** **Overlay is not transparent or has a visible border**
Add these window rules to your `hyprland.conf`: Add a window rule matching SubMiner's window class. Recent Hyprland uses the Lua config format:
```lua
hl.window_rule({
match = { class = "^SubMiner$" },
float = true,
border_size = 0,
xray = false,
no_shadow = true,
no_blur = true,
no_dim = true,
opaque = true,
dim_around = false,
opacity = "1.0 override 1.0 override",
})
```
On older Hyprland releases that still use the hyprlang config (`hyprland.conf`), use the equivalent `windowrule` lines:
```ini ```ini
windowrule = float on, match:class SubMiner windowrule = float on, match:class SubMiner
@@ -338,7 +364,7 @@ windowrule = no_shadow on, match:class SubMiner
windowrule = no_blur on, match:class SubMiner windowrule = no_blur on, match:class SubMiner
``` ```
Without `xray off override`, the compositor may render the transparent overlay incorrectly — you might see a solid background or visual artifacts instead of the mpv video underneath. If you still see a solid background or visual artifacts instead of the mpv video underneath, the culprit is almost always a global opacity/blur rule applying to the overlay - the `opaque`/`opacity` and `no_blur` fields above override it.
**Global shortcuts not working** **Global shortcuts not working**
@@ -359,7 +385,53 @@ SubMiner watches mpv's `fullscreen` property and refreshes the overlay geometry
For more details, see the Hyprland docs on [global keybinds](https://wiki.hypr.land/Configuring/Binds/#global-keybinds) and [window rules](https://wiki.hypr.land/Configuring/Window-Rules/). For more details, see the Hyprland docs on [global keybinds](https://wiki.hypr.land/Configuring/Binds/#global-keybinds) and [window rules](https://wiki.hypr.land/Configuring/Window-Rules/).
### KDE Plasma & other Wayland compositors
On any Wayland session that is not Hyprland or Sway (KDE Plasma, GNOME, and others), the overlay can only stay above mpv when both processes run under **XWayland** - the Wayland protocol forbids clients from controlling window stacking, so the overlay's "always on top" becomes a no-op on a native Wayland surface.
SubMiner handles this automatically:
- It launches its own window under XWayland (it sets `--ozone-platform-hint=x11`).
- Every mpv it launches (via the `subminer` launcher, Jellyfin, or YouTube) is pinned to XWayland too - Wayland environment hints are stripped and an X11 GPU context (`--gpu-context=x11egl,x11`) is applied.
- While mpv is windowed, the overlay is a managed X11 window owned by the tracked mpv window (`WM_TRANSIENT_FOR`), so it stays above mpv while other foreground X11/Xwayland apps can still cover both windows.
- While tracked mpv is fullscreen, SubMiner swaps the visible overlay to a focusable-false X11 override-redirect window. That path can stay above the active fullscreen mpv window without requiring a KDE/KWin-specific rule, and SubMiner hides/releases it when mpv is no longer the active X11/Xwayland window.
- The visible overlay is shown inactive on Linux, so normal hover should not steal keyboard focus from mpv.
- During startup and fullscreen transitions, SubMiner waits for tracked mpv geometry before showing the visible overlay and skips the fullscreen restack hide/show path after mpv leaves fullscreen. That avoids a temporary full-screen overlay or black window while the subtitle tokenizer and Yomitan warmups finish.
- If the subtitle sidebar is open during a windowed/fullscreen transition, SubMiner restores it on the replacement overlay window. Subtitle hit regions are also refreshed as soon as the first measured subtitle line is reported, so hover and Yomitan lookup should work on the first visible line.
Requirements: `xdotool`, `xprop`, and `xwininfo` must be installed. SubMiner uses root `_NET_ACTIVE_WINDOW` from `xprop` for focus detection and falls back to `xdotool getactivewindow` when that signal is unavailable.
**Overlay sits behind mpv / pause-on-hover and Yomitan stop working**
This almost always means mpv came up as a **native Wayland** window that the XWayland overlay cannot cover. It happens when mpv is launched **manually** (your own command), because SubMiner can only force XWayland on the mpv processes it launches itself. Fix it one of these ways:
- Launch playback through SubMiner (the `subminer` launcher or the tray), which forces XWayland for you, or
- Force XWayland in your own mpv invocation, e.g. `mpv --gpu-context=x11egl …`, or launch with `WAYLAND_DISPLAY= mpv …`, or set `gpu-context=x11egl` in your `mpv.conf`.
To confirm mpv is on XWayland, `xdotool search --class mpv` should return a window id (a native Wayland mpv returns nothing).
**Overlay stays above an unrelated foreground app**
SubMiner can only detect focus for X11/Xwayland windows in this mode. If a native Wayland app covers mpv but the overlay stays visible, run that app under Xwayland too or use Hyprland/Sway native support. Generic X11 cannot observe native Wayland foreground windows.
### macOS ### macOS
- **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility. - **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility.
- **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app` - **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app`
## See Also
Feature-specific issues are covered in each feature's own page:
- [Anki Integration](/anki-integration) - card creation, field mapping, and AnkiConnect setup
- [AniList Integration](/anilist-integration) - watch-progress sync and authentication
- [Character Dictionary](/character-dictionary) - AniList character name matching and inline portraits
- [Jellyfin Integration](/jellyfin-integration) - remote playback and library connection
- [Jimaku Integration](/jimaku-integration) - subtitle fetching and API rate limits
- [YouTube Integration](/youtube-integration) - subtitle generation and playback
- [Immersion Tracking](/immersion-tracking) - telemetry and session logging
- [WebSocket / Texthooker API](/websocket-texthooker-api) - external texthooker clients
- [Subtitle Annotations](/subtitle-annotations) - N+1, frequency, JLPT, and name-match layers
- [Subtitle Sidebar](/subtitle-sidebar) - sidebar navigation and behavior
- [Configuration Reference](/configuration) - full config options
- [Shortcuts](/shortcuts) - keybinding reference
+11 -9
View File
@@ -8,7 +8,7 @@ Play a video with SubMiner:
subminer video.mkv subminer video.mkv
``` ```
On **Windows**, use the **SubMiner mpv** shortcut created during first-run setup double-click it, or drag a video file onto it. On **Windows**, use the **SubMiner mpv** shortcut created during first-run setup - double-click it, or drag a video file onto it.
That's the simplest way to get started. The `subminer` launcher handles mpv, the IPC socket, and the overlay automatically. That's the simplest way to get started. The `subminer` launcher handles mpv, the IPC socket, and the overlay automatically.
@@ -41,20 +41,20 @@ Field names must match your Anki note type exactly (case-sensitive). See [Anki I
When you launch SubMiner, it wires up mpv and the overlay for you: When you launch SubMiner, it wires up mpv and the overlay for you:
1. SubMiner starts the overlay app in the background 1. SubMiner starts the overlay app in the background
2. mpv runs with an **IPC socket** at `/tmp/subminer-socket` a small local channel two programs use to talk to each other, so the overlay can ask mpv what subtitle is on screen right now 2. mpv runs with an **IPC socket** at `/tmp/subminer-socket` - a small local channel two programs use to talk to each other, so the overlay can ask mpv what subtitle is on screen right now
3. The overlay connects and subscribes to subtitle changes 3. The overlay connects and subscribes to subtitle changes
From there, subtitles render as interactive, hoverable word spans and you mine cards directly from the overlay. For the overlay anatomy and the full mining loop word lookup, card creation, annotations see [Mining Workflow](/mining-workflow). From there, subtitles render as interactive, hoverable word spans and you mine cards directly from the overlay. For the overlay anatomy and the full mining loop - word lookup, card creation, annotations - see [Mining Workflow](/mining-workflow).
### Ways to Launch ### Ways to Launch
| Approach | Use when | How | | Approach | Use when | How |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| **`subminer` launcher** | You want SubMiner to handle everything launch mpv, set up the socket, start the overlay. **Recommended for most users.** | `subminer video.mkv` | | **`subminer` launcher** | You want SubMiner to handle everything - launch mpv, set up the socket, start the overlay. **Recommended for most users.** | `subminer video.mkv` |
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` | | **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut | | **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
The mpv plugin is always available it's bundled with SubMiner and injected at runtime. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect. The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
## Live Config Reload ## Live Config Reload
@@ -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 --dev # Enable app/dev mode only
SubMiner.AppImage --start --debug # Alias for --dev SubMiner.AppImage --start --debug # Alias for --dev
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode 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 --yomitan # Open Yomitan settings
SubMiner.AppImage --settings # Open SubMiner settings window SubMiner.AppImage --settings # Open SubMiner settings window
SubMiner.AppImage --jellyfin # Open Jellyfin setup 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. 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`. 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). - 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 ### 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. - 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. - 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"]`). - 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`. - 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. 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.
@@ -301,7 +303,7 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
1. Connect a controller before or after launching SubMiner. 1. Connect a controller before or after launching SubMiner.
2. Set `controller.enabled` to `true` in your config. 2. Set `controller.enabled` to `true` in your config.
3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline. 3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
4. Enable keyboard-only mode press `Y` on the controller (default binding) or use the overlay keybinding. 4. Enable keyboard-only mode - press `Y` on the controller (default binding) or use the overlay keybinding.
5. Click the binding badge, edit pencil, or `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller. 5. Click the binding badge, edit pencil, or `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
6. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps. 6. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
7. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup. 7. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
@@ -333,7 +335,7 @@ By default SubMiner uses the first connected controller after controller support
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input. Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
All button and axis mappings are configurable under the `controller` config block. Learned remaps are saved under `controller.profiles` for the selected controller id. See [Configuration Controller Support](/configuration#controller-support) for the full options. All button and axis mappings are configurable under the `controller` config block. Learned remaps are saved under `controller.profiles` for the selected controller id. See [Configuration - Controller Support](/configuration#controller-support) for the full options.
## Keybindings ## Keybindings
@@ -361,4 +363,4 @@ Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan p
- Drop video files onto the overlay to replace current playback. - Drop video files onto the overlay to replace current playback.
- Hold `Shift` while dropping to append to the playlist instead. - Hold `Shift` while dropping to append to the playlist instead.
Next: [Mining Workflow](/mining-workflow) word lookup, card creation, and the full mining loop. Next: [Mining Workflow](/mining-workflow) - word lookup, card creation, and the full mining loop.
+7 -7
View File
@@ -1,6 +1,6 @@
# WebSocket / Texthooker API & Integration # WebSocket / Texthooker API & Integration
**Who this page is for:** developers and tinkerers who want to consume SubMiner's live subtitle stream from their own tools a browser tab, an automation script, or another mpv plugin. If you just want subtitles in a browser tab for Yomitan, skip to [Texthooker Integration Guide](#texthooker-integration-guide); the rest is reference for building custom clients. **Who this page is for:** developers and tinkerers who want to consume SubMiner's live subtitle stream from their own tools - a browser tab, an automation script, or another mpv plugin. If you just want subtitles in a browser tab for Yomitan, skip to [Texthooker Integration Guide](#texthooker-integration-guide); the rest is reference for building custom clients.
A *texthooker* is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to. A *texthooker* is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to.
@@ -24,7 +24,7 @@ This page documents those integration points and shows how to build custom consu
## Enable and Configure the Services ## Enable and Configure the Services
SubMiner's integration ports are configured in `config.jsonc`. All three services are **off by default** the block below shows the values to set to turn them on. SubMiner's integration ports are configured in `config.jsonc`. All three services are **off by default** - the block below shows the values to set to turn them on.
```jsonc ```jsonc
{ {
@@ -50,7 +50,7 @@ SubMiner's integration ports are configured in `config.jsonc`. All three service
- `texthooker.launchAtStartup` defaults to `false`. Set it to `true` to start the local HTTP UI automatically. - `texthooker.launchAtStartup` defaults to `false`. Set it to `true` to start the local HTTP UI automatically.
- `texthooker.openBrowser` controls whether SubMiner opens the texthooker page in your browser when it starts. - `texthooker.openBrowser` controls whether SubMiner opens the texthooker page in your browser when it starts.
If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only helper process. The launcher derives the plugin's texthooker setting from your SubMiner config (`texthooker.launchAtStartup`) and injects it at runtime there is no plugin config file to edit. If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only helper process. The launcher derives the plugin's texthooker setting from your SubMiner config (`texthooker.launchAtStartup`) and injects it at runtime - there is no plugin config file to edit.
## Developer API Documentation ## Developer API Documentation
@@ -265,10 +265,10 @@ script-message subminer-options
script-message subminer-restart script-message subminer-restart
script-message subminer-status script-message subminer-status
script-message subminer-autoplay-ready 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: The start command also accepts inline overrides:
```text ```text
@@ -283,7 +283,7 @@ Examples:
- send `subminer-start` after your own media-selection script chooses a file - send `subminer-start` after your own media-selection script chooses a file
- send `subminer-status` before running follow-up automation - 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 #### Build a launcher wrapper
@@ -368,7 +368,7 @@ ws.on('message', async (raw) => {
## Related Pages ## Related Pages
- [Configuration](/configuration#websocket-server) - [Configuration](/configuration#websocket-server)
- [Mining Workflow Texthooker](/mining-workflow#texthooker) - [Mining Workflow - Texthooker](/mining-workflow#texthooker)
- [MPV Plugin](/mpv-plugin) - [MPV Plugin](/mpv-plugin)
- [Launcher Script](/launcher-script) - [Launcher Script](/launcher-script)
- [Anki Integration](/anki-integration#proxy-mode-setup-yomitan--texthooker) - [Anki Integration](/anki-integration#proxy-mode-setup-yomitan--texthooker)
+3 -3
View File
@@ -4,8 +4,8 @@ SubMiner auto-loads Japanese subtitles when you play a YouTube URL, giving you t
## Requirements ## Requirements
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** must be installed and on your `PATH`. yt-dlp is a free command-line tool that reads YouTube video and subtitle info; SubMiner calls it behind the scenes. (`PATH` is the list of folders your system searches for programs most installers add yt-dlp to it automatically. If yours did not, set `SUBMINER_YTDLP_BIN` to the full path of the yt-dlp binary.) - **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** must be installed and on your `PATH`. yt-dlp is a free command-line tool that reads YouTube video and subtitle info; SubMiner calls it behind the scenes. (`PATH` is the list of folders your system searches for programs - most installers add yt-dlp to it automatically. If yours did not, set `SUBMINER_YTDLP_BIN` to the full path of the yt-dlp binary.)
- mpv with `--input-ipc-server` configured (handled automatically when you launch playback through the `subminer` launcher no manual setup needed). - mpv with `--input-ipc-server` configured (handled automatically when you launch playback through the `subminer` launcher - no manual setup needed).
## How It Works ## How It Works
@@ -32,7 +32,7 @@ flowchart TD
C[Track discovery]:::action C[Track discovery]:::action
D{Auto or manual selection?}:::step D{Auto or manual selection?}:::step
E[Auto-select best tracks]:::action E[Auto-select best tracks]:::action
F[Manual picker Ctrl+Alt+C]:::action F[Manual picker - Ctrl+Alt+C]:::action
G[Download subtitle files]:::action G[Download subtitle files]:::action
H[Convert TimedText to VTT]:::enrich H[Convert TimedText to VTT]:::enrich
I[Normalize auto-caption duplicates]:::enrich I[Normalize auto-caption duplicates]:::enrich
+4 -3
View File
@@ -33,7 +33,7 @@
`bun run build` `bun run build`
When validating auto-update metadata, also run the relevant platform package When validating auto-update metadata, also run the relevant platform package
build and confirm `release/` contains the generated updater metadata build and confirm `release/` contains the generated updater metadata
(`*.yml`) and blockmaps (`*.blockmap`). (`latest*.yml`) and blockmaps (`*.blockmap`).
8. If `docs-site/` changed, also run: 8. If `docs-site/` changed, also run:
`bun run docs:test` `bun run docs:test`
`bun run docs:build` `bun run docs:build`
@@ -55,7 +55,7 @@
`bun run test:env` `bun run test:env`
`bun run build` `bun run build`
When validating packaged updater output, confirm the platform build writes When validating packaged updater output, confirm the platform build writes
`*.yml` and `*.blockmap` files under `release/`. `latest*.yml` and `*.blockmap` files under `release/`.
5. Commit the prerelease prep (package.json version bump + the generated 5. Commit the prerelease prep (package.json version bump + the generated
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the `release/prerelease-notes.md`). CI does not regenerate notes — it uses the
committed file — so review it before committing. If you add more committed file — so review it before committing. If you add more
@@ -77,6 +77,7 @@ Notes:
- `changelog:check` now rejects tag/package version mismatches. - `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: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. - `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`) end with GitHub-style attribution: a `## Whats 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. - 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. - 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. - 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.
@@ -87,7 +88,7 @@ Notes:
- Keep Cloudflare Pages Git auto-deploy disabled for `docs.subminer.moe`. Production docs are direct-uploaded by Wrangler from GitHub Actions with `--branch main`. - Keep Cloudflare Pages Git auto-deploy disabled for `docs.subminer.moe`. Production docs are direct-uploaded by Wrangler from GitHub Actions with `--branch main`.
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed. - AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation. - Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled. - Release and prerelease workflows upload updater metadata (`latest*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS `SubMiner-<version>-mac.zip`, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer. - macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS `SubMiner-<version>-mac.zip`, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks. - macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks.
- Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks. - Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks.
+1
View File
@@ -19,6 +19,7 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
- [Domains](./domains.md) - who owns what - [Domains](./domains.md) - who owns what
- [Layering](./layering.md) - how modules should depend on each other - [Layering](./layering.md) - how modules should depend on each other
- [Subtitle Overlay Priming](./subtitle-overlay-priming.md) - visible-overlay subtitle startup flow
- Public contributor summary: [`docs-site/architecture.md`](../../docs-site/architecture.md) - Public contributor summary: [`docs-site/architecture.md`](../../docs-site/architecture.md)
## Current Shape ## Current Shape
@@ -0,0 +1,102 @@
<!-- read_when: changing visible overlay startup, Linux/X11 overlay window shape, mpv subtitle callbacks, or subtitle tokenization emission -->
# Subtitle Overlay Priming
Status: active
Last verified: 2026-06-01
Owner: Kyle Yasuda
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
Visible-overlay subtitle priming fills the overlay from mpv's current subtitle properties before
waiting for the next live mpv subtitle event. This avoids a stale or blank overlay when the user
manually shows the visible overlay while playback is already sitting on a subtitle.
On Linux/X11, visible-overlay show and later mpv bounds refreshes restore the Electron window shape
to the full current overlay bounds. Electron's `BrowserWindow.setShape()` applies a bounding shape,
not an input-only region; stale shapes can leave a mapped 1920x1080 overlay with smaller X11 shape
extents such as `800x600+0+0`, so renderer and websocket subtitle state are correct while bottom
subtitles do not draw.
## Entry Points
- `src/main.ts` calls `primeCurrentSubtitleForVisibleOverlay()` when manual visible-overlay show
paths run.
- `src/main.ts` calls `restoreVisibleOverlayWindowShapeForShow()` before visible-overlay show
actions on Linux, and `resetVisibleOverlayInputState()` restores a full shape instead of applying
an empty shape.
- `src/main.ts` also restores the Linux/X11 shape after applying mpv overlay bounds, so a newly
created 800x600 hidden Electron window cannot keep clipping after it is resized to mpv geometry.
- `primeCurrentSubtitleForVisibleOverlay()` delegates to
`primeVisibleOverlaySubtitleFromMpv()` in `src/main/runtime/current-subtitle-snapshot.ts`.
- `restoreVisibleOverlayWindowShapeForShow()` delegates to `restoreLinuxOverlayWindowShape()` in
`src/main/runtime/linux-overlay-window-shape.ts`.
- Inputs are callback deps, not globals: `getMpvClient`, `setCurrentSubText`,
`getCurrentSubtitleData`, `consumeCachedSubtitle`, `onSubtitleChange`,
`refreshCurrentSubtitle`, `emitSubtitle`, optional secondary-subtitle callbacks, and `logDebug`.
## Primary Subtitle Flow
1. Read the connected mpv client through `getMpvClient()`. Exit if no connected client.
2. Request mpv `sub-text`. On failure, log a
`[visible-overlay-subtitle-prime] failed to read sub-text` debug line and exit.
3. Normalize non-string `sub-text` to `''`, then call `setCurrentSubText(text)` so app state
matches mpv before any overlay emission.
4. Empty text: call `onSubtitleChange(text)`, emit `{ text, tokens: null }`, then prime secondary
subtitles.
5. Current cached payload: if `getCurrentSubtitleData()?.text === text`, call
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final
payload.
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
`subtitleProcessingController` method. This gives the visible overlay priority over background
prefetch work and re-centers prefetch around the live playback time.
## Emitted State
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal
annotated subtitle payload to overlay windows and subtitle websocket listeners.
- Secondary priming reads mpv `secondary-sub-text`, stores it in
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
- 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
one full-window rectangle: `{ x: 0, y: 0, width, height }`.
- Restore the shape after `setBounds()`/mpv geometry updates, not only before showing the overlay.
Manual startup can create the hidden overlay at Electron's default 800x600 size before the window
tracker applies the real mpv bounds.
- Do not use `setShape([])` as a passive reset for the visible overlay. On the tested X11/XWayland
path, empty or stale bounding shapes produced invisible or clipped subtitles even though the
overlay window remained mapped above mpv.
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
the Linux cursor-poll fallback, not bounding-shape clipping.
## Config And Migration
No config or schema migration. This workflow reuses existing mpv properties, overlay IPC events,
subtitle tokenization cache, and prefetch controls.
+1
View File
@@ -13,6 +13,7 @@ Read when: finding internal docs or checking verification status
| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map | | Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership | | Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership |
| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells | | Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
| Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow |
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy | | KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles | | Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps | | Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
@@ -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.
+1 -2
View File
@@ -45,9 +45,8 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
autoStart: true, autoStart: true,
autoStartVisibleOverlay: true, autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false, texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
}, },
appPath: '/tmp/subminer.app', appPath: '/tmp/subminer.app',
launcherJellyfinConfig: {}, launcherJellyfinConfig: {},
+20 -11
View File
@@ -82,9 +82,8 @@ function createContext(): LauncherCommandContext {
autoStart: true, autoStart: true,
autoStartVisibleOverlay: true, autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false, texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
}, },
appPath: '/tmp/SubMiner.AppImage', appPath: '/tmp/SubMiner.AppImage',
launcherJellyfinConfig: {}, launcherJellyfinConfig: {},
@@ -209,9 +208,8 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
autoStart: true, autoStart: true,
autoStartVisibleOverlay: false, autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false, autoStartPauseUntilReady: false,
osdMessages: false,
texthookerEnabled: false, texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
}; };
const appPath = context.appPath ?? ''; const appPath = context.appPath ?? '';
state.appPath = appPath; state.appPath = appPath;
@@ -272,9 +270,8 @@ test('plugin auto-start playback attaches a warm background app through the laun
autoStart: true, autoStart: true,
autoStartVisibleOverlay: true, autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true, texthookerEnabled: true,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
}; };
const calls: string[] = []; const calls: string[] = [];
const receivedStartMpvOptions: Record<string, unknown>[] = []; const receivedStartMpvOptions: Record<string, unknown>[] = [];
@@ -341,12 +338,12 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
autoStart: true, autoStart: true,
autoStartVisibleOverlay: true, autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true, texthookerEnabled: true,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
}; };
let availabilityConfigDir: string | undefined; let availabilityConfigDir: string | undefined;
let overlayConfigDir: string | undefined; let overlayConfigDir: string | undefined;
let overlayLoadingOsd: boolean | undefined;
try { try {
process.env.XDG_CONFIG_HOME = xdgConfigHome; 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' }), chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {}, checkDependencies: () => {},
registerCleanup: () => {}, registerCleanup: () => {},
startMpv: async () => {}, startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
overlayLoadingOsd = (
options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined
)?.overlayLoadingOsd;
},
waitForUnixSocketReady: async () => true, waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => { startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
overlayConfigDir = 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(availabilityConfigDir, expectedConfigDir);
assert.equal(overlayConfigDir, expectedConfigDir); assert.equal(overlayConfigDir, expectedConfigDir);
assert.equal(overlayLoadingOsd, true);
} finally { } finally {
if (originalXdgConfigHome === undefined) { if (originalXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME; 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, autoStart: true,
autoStartVisibleOverlay: true, autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true, texthookerEnabled: true,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
}; };
const calls: string[] = []; const calls: string[] = [];
+9
View File
@@ -232,6 +232,14 @@ export async function runPlaybackCommandWithDeps(
? { ...pluginRuntimeConfig, autoStart: false } ? { ...pluginRuntimeConfig, autoStart: false }
: pluginRuntimeConfig; : pluginRuntimeConfig;
const shouldShowOverlayLoadingOsd =
!isAppOwnedYoutubeFlow &&
(pluginRuntimeConfig.autoStartVisibleOverlay || args.startOverlay || args.autoStartOverlay) &&
(pluginRuntimeConfig.autoStart ||
args.startOverlay ||
args.autoStartOverlay ||
shouldLauncherAttachRunningApp);
const shouldPauseUntilOverlayReady = const shouldPauseUntilOverlayReady =
pluginRuntimeConfig.autoStart && pluginRuntimeConfig.autoStart &&
pluginRuntimeConfig.autoStartVisibleOverlay && pluginRuntimeConfig.autoStartVisibleOverlay &&
@@ -266,6 +274,7 @@ export async function runPlaybackCommandWithDeps(
} }
: {}), : {}),
backend: args.backend, backend: args.backend,
overlayLoadingOsd: shouldShowOverlayLoadingOsd,
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled, texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
}, },
}, },
+27 -20
View File
@@ -91,8 +91,6 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
autoStartSubMiner: false, autoStartSubMiner: false,
pauseUntilOverlayReady: false, pauseUntilOverlayReady: false,
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage', 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.autoStartSubMiner, false);
assert.equal(parsed.pauseUntilOverlayReady, false); assert.equal(parsed.pauseUntilOverlayReady, false);
assert.equal(parsed.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage'); 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', () => { 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', () => { test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugin defaults', () => {
const parsed = parsePluginRuntimeConfigFromMainConfig({ const parsed = parsePluginRuntimeConfigFromMainConfig({
auto_start_overlay: false, auto_start_overlay: false,
ankiConnect: {
behavior: {
notificationType: 'osd-system',
},
},
texthooker: { texthooker: {
launchAtStartup: false, launchAtStartup: false,
}, },
@@ -138,8 +139,6 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi
autoStartSubMiner: true, autoStartSubMiner: true,
pauseUntilOverlayReady: true, pauseUntilOverlayReady: true,
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage', 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.autoStart, true);
assert.equal(parsed.autoStartVisibleOverlay, false); assert.equal(parsed.autoStartVisibleOverlay, false);
assert.equal(parsed.autoStartPauseUntilReady, true); assert.equal(parsed.autoStartPauseUntilReady, true);
assert.equal(parsed.osdMessages, true);
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage'); assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
assert.equal(parsed.texthookerEnabled, false); 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', () => { 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.autoStart, true);
assert.equal(parsed.autoStartVisibleOverlay, false); assert.equal(parsed.autoStartVisibleOverlay, false);
assert.equal(parsed.autoStartPauseUntilReady, true); assert.equal(parsed.autoStartPauseUntilReady, true);
assert.equal(parsed.osdMessages, false);
assert.equal(parsed.texthookerEnabled, 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', () => { test('buildPluginRuntimeScriptOptParts emits config values that override plugin defaults', () => {
@@ -175,9 +184,8 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
autoStart: true, autoStart: true,
autoStartVisibleOverlay: false, autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: true,
texthookerEnabled: false, texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'F8',
}, },
'/fallback/SubMiner.AppImage', '/fallback/SubMiner.AppImage',
), ),
@@ -187,10 +195,11 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
'subminer-backend=x11', 'subminer-backend=x11',
'subminer-auto_start=yes', 'subminer-auto_start=yes',
'subminer-auto_start_visible_overlay=no', 'subminer-auto_start_visible_overlay=no',
'subminer-overlay_loading_osd=no',
'subminer-auto_start_pause_until_ready=yes', '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-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, autoStart: true,
autoStartVisibleOverlay: false, autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false, texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'F8,\nF9',
}, },
'/fallback/SubMiner.AppImage', '/fallback/SubMiner.AppImage',
), ),
@@ -217,10 +225,11 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
'subminer-backend=x11', 'subminer-backend=x11',
'subminer-auto_start=yes', 'subminer-auto_start=yes',
'subminer-auto_start_visible_overlay=no', 'subminer-auto_start_visible_overlay=no',
'subminer-overlay_loading_osd=no',
'subminer-auto_start_pause_until_ready=yes', '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-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, pauseUntilOverlayReady: undefined,
subminerBinaryPath: undefined, subminerBinaryPath: undefined,
profile: 'anime', profile: 'anime',
aniskipEnabled: undefined,
aniskipButtonKey: undefined,
}, },
); );
-2
View File
@@ -39,7 +39,5 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
pauseUntilOverlayReady: pauseUntilOverlayReady:
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined, typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath), subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath),
aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined,
aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey),
}; };
} }
+5 -7
View File
@@ -16,10 +16,9 @@ function booleanOrDefault(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback; return typeof value === 'boolean' ? value : fallback;
} }
function nonEmptyStringOrDefault(value: unknown, fallback: string): string { function pluginOsdMessagesFromNotificationType(root: Record<string, unknown> | null): boolean {
if (typeof value !== 'string') return fallback; const notificationType = rootObject(rootObject(root, 'ankiConnect'), 'behavior').notificationType;
const trimmed = value.trim(); return notificationType === 'osd' || notificationType === 'osd-system';
return trimmed.length > 0 ? trimmed : fallback;
} }
function validBackendOrDefault(value: unknown, fallback: Backend): Backend { function validBackendOrDefault(value: unknown, fallback: Backend): Backend {
@@ -53,9 +52,8 @@ export function parsePluginRuntimeConfigFromMainConfig(
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true), autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false), autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true), autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
osdMessages: pluginOsdMessagesFromNotificationType(root),
texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false), 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( log(
'debug', 'debug',
logLevel, 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; return parsed;
} }
+20 -15
View File
@@ -10,6 +10,7 @@ import { getAppControlSocketPath } from '../src/shared/app-control';
import { withProcessExitIntercept } from './test-support/exit-intercept.js'; import { withProcessExitIntercept } from './test-support/exit-intercept.js';
import { import {
buildConfiguredMpvDefaultArgs, buildConfiguredMpvDefaultArgs,
buildRuntimeExtraScriptOptParts,
buildMpvBackendArgs, buildMpvBackendArgs,
buildMpvEnv, buildMpvEnv,
cleanupPlaybackSession, cleanupPlaybackSession,
@@ -22,7 +23,6 @@ import {
runAppCommandCaptureOutput, runAppCommandCaptureOutput,
resolveLauncherRuntimePluginPath, resolveLauncherRuntimePluginPath,
resolveLauncherRuntimePluginPlan, resolveLauncherRuntimePluginPlan,
shouldResolveAniSkipMetadata,
stopOverlay, stopOverlay,
startOverlay, startOverlay,
state, state,
@@ -374,6 +374,25 @@ test('resolveLauncherRuntimePluginPlan reports missing bundled plugin when no in
assert.match(plan.errorMessage ?? '', /Packaged mpv plugin assets were not found/); assert.match(plan.errorMessage ?? '', /Packaged mpv plugin assets were not found/);
}); });
test('buildRuntimeExtraScriptOptParts marks launcher-owned startup pause gate', () => {
assert.deepEqual(
buildRuntimeExtraScriptOptParts('/tmp/video.mkv', 'file', {
startPaused: true,
runtimePluginConfig: {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
},
}),
['subminer-auto_start_pause_until_ready_owns_initial_pause=yes'],
);
});
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => { test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
const error = withProcessExitIntercept(() => { const error = withProcessExitIntercept(() => {
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs()); launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
@@ -526,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 { function makeArgs(overrides: Partial<Args> = {}): Args {
return { return {
backend: 'x11', backend: 'x11',
+42 -79
View File
@@ -5,6 +5,12 @@ import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js'; import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js'; import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js';
import {
MPV_X11_BACKEND_ARGS,
applyX11EnvOverrides,
getLinuxDesktopEnv,
shouldForceX11MpvBackend as shouldForceX11MpvBackendForBackend,
} from '../src/shared/mpv-x11-backend.js';
import { import {
isAppControlServerAvailable as checkAppControlServerAvailable, isAppControlServerAvailable as checkAppControlServerAvailable,
sendAppControlCommand, sendAppControlCommand,
@@ -21,7 +27,7 @@ import {
shouldForwardLogLevel, shouldForwardLogLevel,
} from './types.js'; } from './types.js';
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js'; import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js'; import { buildSubminerScriptOpts } from './script-opts.js';
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js'; import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
import { nowMs } from './time.js'; import { nowMs } from './time.js';
import { import {
@@ -458,39 +464,8 @@ export function detectBackend(
fail('Could not detect display backend'); fail('Could not detect display backend');
} }
type LinuxDesktopEnv = {
xdgCurrentDesktop: string;
xdgSessionDesktop: string;
hasWayland: boolean;
};
function getLinuxDesktopEnv(env: NodeJS.ProcessEnv): LinuxDesktopEnv {
const xdgCurrentDesktop = (env.XDG_CURRENT_DESKTOP || '').toLowerCase();
const xdgSessionDesktop = (env.XDG_SESSION_DESKTOP || '').toLowerCase();
const xdgSessionType = (env.XDG_SESSION_TYPE || '').toLowerCase();
return {
xdgCurrentDesktop,
xdgSessionDesktop,
hasWayland: Boolean(env.WAYLAND_DISPLAY) || xdgSessionType === 'wayland',
};
}
function shouldForceX11MpvBackend(args: Pick<Args, 'backend'>, env: NodeJS.ProcessEnv): boolean { function shouldForceX11MpvBackend(args: Pick<Args, 'backend'>, env: NodeJS.ProcessEnv): boolean {
if (process.platform !== 'linux' || !env.DISPLAY?.trim()) { return shouldForceX11MpvBackendForBackend(args.backend, env);
return false;
}
const linuxDesktopEnv = getLinuxDesktopEnv(env);
const supportedWaylandBackend =
Boolean(env.HYPRLAND_INSTANCE_SIGNATURE || env.SWAYSOCK) ||
linuxDesktopEnv.xdgCurrentDesktop.includes('hyprland') ||
linuxDesktopEnv.xdgCurrentDesktop.includes('sway') ||
linuxDesktopEnv.xdgSessionDesktop.includes('hyprland') ||
linuxDesktopEnv.xdgSessionDesktop.includes('sway');
return (
args.backend === 'x11' ||
(args.backend === 'auto' && linuxDesktopEnv.hasWayland && !supportedWaylandBackend)
);
} }
function resolveAppBinaryCandidate(candidate: string, pathModule: PathModule = path): string { function resolveAppBinaryCandidate(candidate: string, pathModule: PathModule = path): string {
@@ -848,18 +823,36 @@ export async function loadSubtitleIntoMpv(
} }
} }
export function shouldResolveAniSkipMetadata( type StartMpvOptions = {
startPaused?: boolean;
disableYoutubeSubtitleAutoLoad?: boolean;
runtimePluginPath?: string | null;
runtimePluginConfig?: PluginRuntimeConfig;
};
export function buildRuntimeExtraScriptOptParts(
target: string, target: string,
targetKind: 'file' | 'url', targetKind: 'file' | 'url',
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string }, options?: Pick<
): boolean { StartMpvOptions,
if (targetKind !== 'file') { 'startPaused' | 'disableYoutubeSubtitleAutoLoad' | 'runtimePluginConfig'
return false; >,
} ): string[] {
if (preloadedSubtitles?.primaryPath || preloadedSubtitles?.secondaryPath) { const launcherOwnsAutoplayReadyInitialPause =
return false; options?.startPaused === true &&
} options.runtimePluginConfig?.autoStart === true &&
return !isYoutubeTarget(target); options.runtimePluginConfig.autoStartVisibleOverlay === true &&
options.runtimePluginConfig.autoStartPauseUntilReady === true;
return [
...(launcherOwnsAutoplayReadyInitialPause
? ['subminer-auto_start_pause_until_ready_owns_initial_pause=yes']
: []),
...(targetKind === 'url' &&
isYoutubeTarget(target) &&
options?.disableYoutubeSubtitleAutoLoad === true
? ['subminer-auto_start_pause_until_ready=no']
: []),
];
} }
export async function startMpv( export async function startMpv(
@@ -869,12 +862,7 @@ export async function startMpv(
socketPath: string, socketPath: string,
appPath: string, appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string }, preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
options?: { options?: StartMpvOptions,
startPaused?: boolean;
disableYoutubeSubtitleAutoLoad?: boolean;
runtimePluginPath?: string | null;
runtimePluginConfig?: PluginRuntimeConfig;
},
): Promise<void> { ): Promise<void> {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) { if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`); fail(`Video file not found: ${target}`);
@@ -932,29 +920,14 @@ export async function startMpv(
if (options?.startPaused) { if (options?.startPaused) {
mpvArgs.push('--pause=yes'); mpvArgs.push('--pause=yes');
} }
const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles) const extraScriptOpts = buildRuntimeExtraScriptOptParts(target, targetKind, options);
? await resolveAniSkipMetadataForFile(target)
: null;
const extraScriptOpts =
targetKind === 'url' &&
isYoutubeTarget(target) &&
options?.disableYoutubeSubtitleAutoLoad === true
? ['subminer-auto_start_pause_until_ready=no']
: [];
const runtimeScriptOpts = options?.runtimePluginConfig const runtimeScriptOpts = options?.runtimePluginConfig
? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath) ? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath)
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`]; : [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel, [ const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, [
...runtimeScriptOpts, ...runtimeScriptOpts,
...extraScriptOpts, ...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(`--script-opts=${scriptOpts}`);
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs)); mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
@@ -1344,11 +1317,7 @@ export function buildMpvEnv(
return env; return env;
} }
delete env.WAYLAND_DISPLAY; return applyX11EnvOverrides(env);
delete env.HYPRLAND_INSTANCE_SIGNATURE;
delete env.SWAYSOCK;
env.XDG_SESSION_TYPE = 'x11';
return env;
} }
export function buildMpvBackendArgs( export function buildMpvBackendArgs(
@@ -1358,7 +1327,7 @@ export function buildMpvBackendArgs(
if (!shouldForceX11MpvBackend(args, baseEnv)) { if (!shouldForceX11MpvBackend(args, baseEnv)) {
return []; return [];
} }
return ['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11']; return [...MPV_X11_BACKEND_ARGS];
} }
export function buildConfiguredMpvDefaultArgs( export function buildConfiguredMpvDefaultArgs(
@@ -1691,13 +1660,7 @@ export function launchMpvIdleDetached(
? buildPluginRuntimeScriptOptParts(runtimePluginConfig, appPath) ? buildPluginRuntimeScriptOptParts(runtimePluginConfig, appPath)
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`]; : [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
mpvArgs.push( mpvArgs.push(
`--script-opts=${buildSubminerScriptOpts( `--script-opts=${buildSubminerScriptOpts(appPath, socketPath, runtimeScriptOpts)}`,
appPath,
socketPath,
null,
args.logLevel,
runtimeScriptOpts,
)}`,
); );
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs)); mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
mpvArgs.push(`--input-ipc-server=${socketPath}`); mpvArgs.push(`--input-ipc-server=${socketPath}`);
+27
View File
@@ -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/,
);
});
+34
View File
@@ -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(',');
}
+4
View File
@@ -582,6 +582,10 @@ test(
assert.equal(result.status, unixSocketDenied ? 3 : 0); assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.equal(Array.isArray(mpvFirstArgs), true); assert.equal(Array.isArray(mpvFirstArgs), true);
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true); assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
assert.match(
(mpvFirstArgs as string[]).find((arg) => arg.startsWith('--script-opts=')) ?? '',
/subminer-auto_start_pause_until_ready_owns_initial_pause=yes/,
);
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i); assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
}); });
}, },
+2 -4
View File
@@ -191,8 +191,6 @@ export interface LauncherMpvConfig {
autoStartSubMiner?: boolean; autoStartSubMiner?: boolean;
pauseUntilOverlayReady?: boolean; pauseUntilOverlayReady?: boolean;
subminerBinaryPath?: string; subminerBinaryPath?: string;
aniskipEnabled?: boolean;
aniskipButtonKey?: string;
} }
export interface LauncherLoggingConfig { export interface LauncherLoggingConfig {
@@ -209,9 +207,9 @@ export interface PluginRuntimeConfig {
autoStart: boolean; autoStart: boolean;
autoStartVisibleOverlay: boolean; autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean; autoStartPauseUntilReady: boolean;
overlayLoadingOsd?: boolean;
osdMessages: boolean;
texthookerEnabled: boolean; texthookerEnabled: boolean;
aniskipEnabled: boolean;
aniskipButtonKey: string;
} }
export interface CommandExecOptions { export interface CommandExecOptions {
+4 -4
View File
File diff suppressed because one or more lines are too long
-758
View File
@@ -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
-150
View File
@@ -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
-3
View File
@@ -56,9 +56,6 @@ function M.init()
ctx.binary = make_lazy_proxy("binary", function() ctx.binary = make_lazy_proxy("binary", function()
return require("binary").create(ctx) return require("binary").create(ctx)
end) end)
ctx.aniskip = make_lazy_proxy("aniskip", function()
return require("aniskip").create(ctx)
end)
ctx.hover = make_lazy_proxy("hover", function() ctx.hover = make_lazy_proxy("hover", function()
return require("hover").create(ctx) return require("hover").create(ctx)
end) end)
+74 -24
View File
@@ -2,6 +2,7 @@ local M = {}
local AUTO_START_SOCKET_RETRY_DELAY_SECONDS = 0.2 local AUTO_START_SOCKET_RETRY_DELAY_SECONDS = 0.2
local AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS = 25 local AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS = 25
local WARM_END_FILE_HIDE_DELAY_SECONDS = 0.25
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
@@ -9,7 +10,6 @@ function M.create(ctx)
local state = ctx.state local state = ctx.state
local options_helper = ctx.options_helper local options_helper = ctx.options_helper
local process = ctx.process local process = ctx.process
local aniskip = ctx.aniskip
local hover = ctx.hover local hover = ctx.hover
local subminer_log = ctx.log.subminer_log local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd local show_osd = ctx.log.show_osd
@@ -51,10 +51,37 @@ function M.create(ctx)
return reason == "reload" or reason == "redirect" return reason == "reload" or reason == "redirect"
end end
local function schedule_aniskip_fetch(trigger_source, delay_seconds) local function clear_pending_visible_overlay_hide()
local delay = tonumber(delay_seconds) or 0 local timer = state.pending_visible_overlay_hide_timer
mp.add_timeout(delay, function() if timer and timer.kill then
aniskip.fetch_aniskip_for_current_media(trigger_source) timer:kill()
end
state.pending_visible_overlay_hide_timer = nil
state.pending_visible_overlay_hide_generation = (state.pending_visible_overlay_hide_generation or 0) + 1
end
local resolve_auto_start_visible_overlay_enabled
local function hide_visible_overlay_after_end_file()
if state.visible_overlay_requested == true and not resolve_auto_start_visible_overlay_enabled() then
return
end
if not state.auto_play_ready_signal_seen then
process.hide_visible_overlay()
return
end
clear_pending_visible_overlay_hide()
local generation = (state.pending_visible_overlay_hide_generation or 0) + 1
state.pending_visible_overlay_hide_generation = generation
state.pending_visible_overlay_hide_timer = mp.add_timeout(WARM_END_FILE_HIDE_DELAY_SECONDS, function()
if state.pending_visible_overlay_hide_generation ~= generation then
return
end
state.pending_visible_overlay_hide_timer = nil
if state.overlay_running then
process.hide_visible_overlay()
end
end) end)
end end
@@ -69,6 +96,22 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_auto_start, false) return options_helper.coerce_bool(raw_auto_start, false)
end end
resolve_auto_start_visible_overlay_enabled = function()
local raw_visible_overlay = opts.auto_start_visible_overlay
if raw_visible_overlay == nil then
raw_visible_overlay = opts["auto-start-visible-overlay"]
end
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() local function next_auto_start_retry_generation()
state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1 state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1
return state.auto_start_retry_generation return state.auto_start_retry_generation
@@ -103,6 +146,19 @@ function M.create(ctx)
return true return true
end end
local function should_rearm_pause_until_ready(same_media_loaded)
return not same_media_loaded
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) local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt)
if generation ~= state.auto_start_retry_generation then if generation ~= state.auto_start_retry_generation then
return return
@@ -111,7 +167,6 @@ function M.create(ctx)
return return
end end
if not resolve_auto_start_enabled() then if not resolve_auto_start_enabled() then
schedule_aniskip_fetch("file-loaded", 0)
return return
end end
@@ -130,20 +185,21 @@ function M.create(ctx)
.. process.describe_mpv_ipc_socket_match(opts.socket_path) .. process.describe_mpv_ipc_socket_match(opts.socket_path)
.. ")" .. ")"
) )
schedule_aniskip_fetch("file-loaded", 0) process.stop_overlay_loading_osd()
return return
end end
process.start_overlay({ process.start_overlay({
auto_start_trigger = true, auto_start_trigger = true,
socket_path = opts.socket_path, socket_path = opts.socket_path,
rearm_pause_until_ready = not same_media_loaded, 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 end
local function on_start_file() 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 if state.pending_reload_media_identity ~= nil then
local media_identity = resolve_media_identity() local media_identity = resolve_media_identity()
if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then
@@ -155,6 +211,7 @@ function M.create(ctx)
end end
local function on_file_loaded() local function on_file_loaded()
clear_pending_visible_overlay_hide()
local media_identity = resolve_media_identity() local media_identity = resolve_media_identity()
local media_title = resolve_media_title() local media_title = resolve_media_title()
local retry_generation = next_auto_start_retry_generation() local retry_generation = next_auto_start_retry_generation()
@@ -196,6 +253,7 @@ function M.create(ctx)
end end
if same_media_reload then if same_media_reload then
process.stop_overlay_loading_osd()
subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload") subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload")
if state.app_managed_playback_active then if state.app_managed_playback_active then
return return
@@ -218,12 +276,12 @@ function M.create(ctx)
local preserve_active_auto_start_gate = ( local preserve_active_auto_start_gate = (
state.overlay_running and state.auto_play_ready_gate_armed and should_auto_start and has_matching_socket 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 if not preserve_active_auto_start_gate then
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
end end
if state.app_managed_playback_active then 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") subminer_log("debug", "lifecycle", "Skipping plugin auto-start for app-managed subtitle preload")
return return
end end
@@ -234,14 +292,15 @@ function M.create(ctx)
end end
refresh_managed_subtitle_autoloading() refresh_managed_subtitle_autoloading()
schedule_aniskip_fetch("file-loaded", 0)
end end
local function on_shutdown() local function on_shutdown()
next_auto_start_retry_generation() next_auto_start_retry_generation()
aniskip.clear_aniskip_state()
hover.clear_hover_overlay() hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate() 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 state.current_media_identity = nil
state.current_media_title = nil state.current_media_title = nil
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
@@ -259,6 +318,7 @@ function M.create(ctx)
hover.clear_hover_overlay() hover.clear_hover_overlay()
end) end)
mp.register_event("end-file", function(event) mp.register_event("end-file", function(event)
process.stop_overlay_loading_osd()
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
hover.clear_hover_overlay() hover.clear_hover_overlay()
local reason = type(event) == "table" and event.reason or nil local reason = type(event) == "table" and event.reason or nil
@@ -277,28 +337,18 @@ function M.create(ctx)
state.app_managed_playback_pending = false state.app_managed_playback_pending = false
state.app_managed_playback_active = false state.app_managed_playback_active = false
if state.overlay_running and reason ~= "quit" then if state.overlay_running and reason ~= "quit" then
process.hide_visible_overlay() hide_visible_overlay_after_end_file()
end end
end) end)
mp.register_event("shutdown", function() mp.register_event("shutdown", function()
hover.clear_hover_overlay() hover.clear_hover_overlay()
end) 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() mp.add_hook("on_unload", 10, function()
hover.clear_hover_overlay() hover.clear_hover_overlay()
aniskip.clear_aniskip_state()
end) end)
mp.observe_property("sub-start", "native", function() mp.observe_property("sub-start", "native", function()
hover.clear_hover_overlay() hover.clear_hover_overlay()
end) end)
mp.observe_property("time-pos", "number", function()
aniskip.update_intro_button_visibility()
end)
end end
return { return {
+2 -2
View File
@@ -43,8 +43,8 @@ function M.create(ctx)
end end
end end
local function show_osd(message) local function show_osd(message, options)
if opts.osd_messages then if opts.osd_messages or (options and options.force == true) then
local payload = "SubMiner: " .. message local payload = "SubMiner: " .. message
local sent = false local sent = false
if type(mp.osd_message) == "function" then if type(mp.osd_message) == "function" then
+6 -7
View File
@@ -2,8 +2,8 @@ local M = {}
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
local opts = ctx.opts
local process = ctx.process local process = ctx.process
local aniskip = ctx.aniskip
local hover = ctx.hover local hover = ctx.hover
local ui = ctx.ui local ui = ctx.ui
local state = ctx.state local state = ctx.state
@@ -43,11 +43,8 @@ function M.create(ctx)
mp.register_script_message("subminer-autoplay-ready", function() mp.register_script_message("subminer-autoplay-ready", function()
process.notify_auto_play_ready() process.notify_auto_play_ready()
end) end)
mp.register_script_message("subminer-aniskip-refresh", function() mp.register_script_message("subminer-overlay-loading-ready", function()
aniskip.fetch_aniskip_for_current_media("script-message") process.stop_overlay_loading_osd()
end)
mp.register_script_message("subminer-skip-intro", function()
aniskip.skip_intro_now()
end) end)
mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json) mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json)
hover.handle_hover_message(payload_json) hover.handle_hover_message(payload_json)
@@ -56,7 +53,9 @@ function M.create(ctx)
hover.handle_hover_message(payload_json) hover.handle_hover_message(payload_json)
end) end)
mp.register_script_message("subminer-stats-toggle", function() 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) end)
mp.register_script_message("subminer-reload-session-bindings", function() mp.register_script_message("subminer-reload-session-bindings", function()
ctx.session_bindings.reload_bindings() ctx.session_bindings.reload_bindings()
+3 -12
View File
@@ -1,5 +1,4 @@
local M = {} local M = {}
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
local function normalize_socket_path_option(socket_path, default_socket_path) local function normalize_socket_path_option(socket_path, default_socket_path)
if type(default_socket_path) ~= "string" then if type(default_socket_path) ~= "string" then
@@ -32,20 +31,12 @@ function M.load(options_lib, default_socket_path)
backend = "auto", backend = "auto",
auto_start = false, auto_start = false,
auto_start_visible_overlay = false, auto_start_visible_overlay = false,
overlay_loading_osd = false,
auto_start_pause_until_ready = true, auto_start_pause_until_ready = true,
auto_start_pause_until_ready_timeout_seconds = 15, auto_start_pause_until_ready_owns_initial_pause = false,
auto_start_pause_until_ready_timeout_seconds = 30,
osd_messages = true, osd_messages = true,
log_level = "info", 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") options_lib.read_options(opts, "subminer")
+145 -13
View File
@@ -4,9 +4,12 @@ local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_START_MAX_ATTEMPTS = 6 local OVERLAY_START_MAX_ATTEMPTS = 6
local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20 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_LOADING_OSD = "Loading subtitle tokenization..."
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" 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 local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25
function M.create(ctx) function M.create(ctx)
@@ -39,6 +42,9 @@ function M.create(ctx)
end end
return "show-visible-overlay" return "show-visible-overlay"
end end
if state.visible_overlay_requested == true then
return nil
end
return "hide-visible-overlay" return "hide-visible-overlay"
end end
@@ -50,6 +56,33 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_pause_until_ready, false) return options_helper.coerce_bool(raw_pause_until_ready, false)
end 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
raw_owns_initial_pause = opts["auto-start-pause-until-ready-owns-initial-pause"]
end
return options_helper.coerce_bool(raw_owns_initial_pause, false)
end
local function consume_pause_until_ready_initial_pause_ownership()
if state.auto_play_ready_initial_pause_ownership_consumed then
return false
end
if not resolve_pause_until_ready_owns_initial_pause() then
return false
end
state.auto_play_ready_initial_pause_ownership_consumed = true
return true
end
local function resolve_texthooker_enabled(override_value) local function resolve_texthooker_enabled(override_value)
if override_value ~= nil then if override_value ~= nil then
return options_helper.coerce_bool(override_value, false) return options_helper.coerce_bool(override_value, false)
@@ -224,6 +257,42 @@ function M.create(ctx)
state.auto_play_ready_osd_timer = nil state.auto_play_ready_osd_timer = nil
end 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 function disarm_auto_play_ready_gate(options)
local should_resume = options == nil or options.resume_playback ~= false local should_resume = options == nil or options.resume_playback ~= false
local was_armed = state.auto_play_ready_gate_armed local was_armed = state.auto_play_ready_gate_armed
@@ -242,8 +311,11 @@ function M.create(ctx)
return false return false
end end
local should_resume_playback = state.auto_play_ready_should_resume_playback == true 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 }) disarm_auto_play_ready_gate({ resume_playback = false })
show_osd(AUTO_PLAY_READY_READY_OSD)
if should_resume_playback then if should_resume_playback then
mp.set_property_native("pause", false) mp.set_property_native("pause", false)
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready")) subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
@@ -260,12 +332,16 @@ function M.create(ctx)
clear_auto_play_ready_osd_timer() clear_auto_play_ready_osd_timer()
end end
if not was_armed then if not was_armed then
state.auto_play_ready_should_resume_playback = mp.get_property_native("pause") ~= true state.auto_play_ready_should_resume_playback = consume_pause_until_ready_initial_pause_ownership()
or mp.get_property_native("pause") ~= true
end end
state.auto_play_ready_gate_armed = true state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true) mp.set_property_native("pause", true)
show_osd(AUTO_PLAY_READY_LOADING_OSD) if resolve_osd_messages_enabled() then
if type(mp.add_periodic_timer) == "function" 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() state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function()
if state.auto_play_ready_gate_armed then if state.auto_play_ready_gate_armed then
show_osd(AUTO_PLAY_READY_LOADING_OSD) show_osd(AUTO_PLAY_READY_LOADING_OSD)
@@ -290,6 +366,7 @@ function M.create(ctx)
end end
local function notify_auto_play_ready() local function notify_auto_play_ready()
state.auto_play_ready_signal_seen = true
local released_ready_gate = release_auto_play_ready_gate("tokenization-ready") local released_ready_gate = release_auto_play_ready_gate("tokenization-ready")
local force_ready_overlay_restore = state.force_ready_overlay_restore == true local force_ready_overlay_restore = state.force_ready_overlay_restore == true
state.force_ready_overlay_restore = false state.force_ready_overlay_restore = false
@@ -351,6 +428,9 @@ function M.create(ctx)
table.insert(args, "--texthooker") table.insert(args, "--texthooker")
end end
end end
if action == "playback-feedback" and type(overrides.message) == "string" and overrides.message ~= "" then
table.insert(args, overrides.message)
end
return args return args
end end
@@ -438,6 +518,27 @@ function M.create(ctx)
end) end)
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) local function wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt)
attempt = attempt or 1 attempt = attempt or 1
run_control_command_async("app-ping", nil, function(_ok, result) run_control_command_async("app-ping", nil, function(_ok, result)
@@ -519,6 +620,7 @@ function M.create(ctx)
if not binary.ensure_binary_available() then if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found") subminer_log("error", "binary", "SubMiner binary not found")
stop_overlay_loading_osd()
show_osd("Error: binary not found") show_osd("Error: binary not found")
return return
end end
@@ -601,7 +703,9 @@ function M.create(ctx)
end end
state.overlay_running = false state.overlay_running = false
state.auto_play_ready_signal_seen = false
subminer_log("error", "process", "Overlay start failed after retries: " .. reason) subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
stop_overlay_loading_osd()
show_osd("Overlay start failed") show_osd("Overlay start failed")
release_auto_play_ready_gate("overlay-start-failed") release_auto_play_ready_gate("overlay-start-failed")
return return
@@ -653,6 +757,8 @@ function M.create(ctx)
state.overlay_running = false state.overlay_running = false
state.texthooker_running = false state.texthooker_running = false
state.auto_play_ready_signal_seen = false
stop_overlay_loading_osd()
disarm_auto_play_ready_gate() disarm_auto_play_ready_gate()
show_osd("Stopped") show_osd("Stopped")
end end
@@ -664,6 +770,7 @@ function M.create(ctx)
return return
end end
state.suppress_ready_overlay_restore = true state.suppress_ready_overlay_restore = true
stop_overlay_loading_osd()
run_control_command_async("hide-visible-overlay", nil, function(ok, result) run_control_command_async("hide-visible-overlay", nil, function(ok, result)
if ok then if ok then
@@ -709,6 +816,14 @@ function M.create(ctx)
end) end)
return return
end end
if not state.overlay_running then
state.suppress_ready_overlay_restore = false
disarm_auto_play_ready_gate({ resume_playback = false })
start_overlay({
show_visible_overlay = true,
})
return
end
state.suppress_ready_overlay_restore = true state.suppress_ready_overlay_restore = true
disarm_auto_play_ready_gate({ resume_playback = false }) disarm_auto_play_ready_gate({ resume_playback = false })
@@ -760,19 +875,28 @@ function M.create(ctx)
return return
end 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...") subminer_log("info", "process", "Restarting overlay...")
show_osd("Restarting...") show_restart_feedback("Restarting...")
run_control_command_async("stop", nil, function(ok, result) run_control_command_async("stop", nil, function(ok, result)
if not ok then if not ok then
local reason = result and result.stderr or "unknown error" local reason = result and result.stderr or "unknown error"
subminer_log("warn", "process", "Restart stop command failed: " .. reason) subminer_log("warn", "process", "Restart stop command failed: " .. reason)
show_osd("Restart failed") stop_overlay_loading_osd()
show_restart_feedback("Restart failed")
return return
end end
state.overlay_running = false state.overlay_running = false
state.texthooker_running = false state.texthooker_running = false
state.auto_play_ready_signal_seen = false
state.suppress_ready_overlay_restore = false state.suppress_ready_overlay_restore = false
state.force_ready_overlay_restore = true state.force_ready_overlay_restore = true
disarm_auto_play_ready_gate({ resume_playback = false }) disarm_auto_play_ready_gate({ resume_playback = false })
@@ -795,19 +919,23 @@ function M.create(ctx)
}, function(success, result, error) }, function(success, result, error)
if not success or (result and result.status ~= 0) then if not success or (result and result.status ~= 0) then
state.overlay_running = false state.overlay_running = false
state.auto_play_ready_signal_seen = false
subminer_log( subminer_log(
"error", "error",
"process", "process",
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error") "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 else
wait_for_app_ping_state(true, "own the single-instance lock", function() wait_for_app_ping_state(true, "own the single-instance lock", function()
run_control_command_async("show-visible-overlay") run_control_command_async("show-visible-overlay", nil, function()
show_osd("Restarted successfully") show_restart_feedback("Restarted successfully")
end)
end, function() end, function()
run_control_command_async("show-visible-overlay") run_control_command_async("show-visible-overlay", nil, function()
show_osd("Restarted successfully") show_restart_feedback("Restarted successfully")
end)
end) end)
end end
end) end)
@@ -816,7 +944,8 @@ function M.create(ctx)
ensure_texthooker_running(function() end) ensure_texthooker_running(function() end)
end end
end, function() end, function()
show_osd("Restart failed") stop_overlay_loading_osd()
show_restart_feedback("Restart failed")
end) end)
end) end)
end end
@@ -841,6 +970,7 @@ function M.create(ctx)
describe_mpv_ipc_socket_match = describe_mpv_ipc_socket_match, describe_mpv_ipc_socket_match = describe_mpv_ipc_socket_match,
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket, has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
run_control_command_async = run_control_command_async, run_control_command_async = run_control_command_async,
notify_playback_feedback = notify_playback_feedback,
record_visible_overlay_visibility = record_visible_overlay_visibility, record_visible_overlay_visibility = record_visible_overlay_visibility,
run_binary_command_async = run_binary_command_async, run_binary_command_async = run_binary_command_async,
parse_start_script_message_overrides = parse_start_script_message_overrides, parse_start_script_message_overrides = parse_start_script_message_overrides,
@@ -857,6 +987,8 @@ function M.create(ctx)
check_binary_available = check_binary_available, check_binary_available = check_binary_available,
notify_auto_play_ready = notify_auto_play_ready, notify_auto_play_ready = notify_auto_play_ready,
disarm_auto_play_ready_gate = disarm_auto_play_ready_gate, 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 end
+7
View File
@@ -24,6 +24,11 @@ local KEY_NAME_MAP = {
BracketLeft = "[", BracketLeft = "[",
BracketRight = "]", BracketRight = "]",
Backquote = "`", Backquote = "`",
MBTN_LEFT = "MBTN_LEFT",
MBTN_MID = "MBTN_MID",
MBTN_RIGHT = "MBTN_RIGHT",
MBTN_BACK = "MBTN_BACK",
MBTN_FORWARD = "MBTN_FORWARD",
} }
local MODIFIER_MAP = { local MODIFIER_MAP = {
@@ -239,6 +244,8 @@ function M.create(ctx)
return { "--toggle-secondary-sub" } return { "--toggle-secondary-sub" }
elseif action_id == "toggleSubtitleSidebar" then elseif action_id == "toggleSubtitleSidebar" then
return { "--toggle-subtitle-sidebar" } return { "--toggle-subtitle-sidebar" }
elseif action_id == "toggleNotificationHistory" then
return { "--session-action", '{"actionId":"toggleNotificationHistory"}' }
elseif action_id == "markAudioCard" then elseif action_id == "markAudioCard" then
return { "--mark-audio-card" } return { "--mark-audio-card" }
elseif action_id == "markWatched" then elseif action_id == "markWatched" then
+7 -11
View File
@@ -18,21 +18,17 @@ function M.new()
clear_timer = nil, clear_timer = nil,
last_hover_update_ts = 0, 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_gate_armed = false,
auto_play_ready_should_resume_playback = false, auto_play_ready_should_resume_playback = false,
auto_play_ready_timeout = nil, auto_play_ready_timeout = nil,
auto_play_ready_osd_timer = 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, suppress_ready_overlay_restore = false,
force_ready_overlay_restore = false, force_ready_overlay_restore = false,
visible_overlay_requested = nil, visible_overlay_requested = nil,
-17
View File
@@ -1,13 +1,9 @@
local M = {} local M = {}
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
local LEGACY_ANISKIP_BUTTON_KEY = "y-k"
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
local input = ctx.input local input = ctx.input
local opts = ctx.opts
local process = ctx.process local process = ctx.process
local aniskip = ctx.aniskip
local subminer_log = ctx.log.subminer_log local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd local show_osd = ctx.log.show_osd
@@ -99,19 +95,6 @@ function M.create(ctx)
end end
process.run_control_command_async("open-session-help") process.run_control_command_async("open-session-help")
end) 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 end
return { return {
-70
View File
@@ -1,70 +0,0 @@
## Highlights
### Breaking Changes
- Subsync: The `subsync.defaultMode` config option has been removed; Subsync now always opens the manual subtitle picker regardless of any previously set default mode.
### Added
- Auto-Updater: Adds tray and `subminer -u` update checks with app update prompts, launcher and Linux rofi theme auto-updates, checksum verification, configurable notifications, and an opt-in prerelease channel via `updates.channel: "prerelease"`.
- Settings Window: New dedicated Settings window via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections; click-to-learn keybinding controls including the AniSkip button key; AnkiConnect-backed deck, field, and note-type pickers that auto-fill from the configured Anki deck; cross-category search; and live save for most options including subtitle CSS, stats keys, logging level, Jimaku, Subsync, and Anki mappings. AI and translation settings remain config-file only.
- Inline Character Portraits: Optional AniList character portraits appear inline for name-matched subtitle text; manual AniList overrides scoped per parent media directory so separate season folders maintain separate character dictionary selections.
- Log Export: Sanitized log ZIP export from the tray menu and via `subminer logs -e`, with home-directory usernames redacted from exported contents.
- Launcher CLI: `subminer --version` / `subminer -v` prints the installed app version; `mpv.profile` config and Settings support passes a named mpv profile to managed launches; bundled mpv plugin startup options are now configurable from SubMiner config.
- First-Run Setup: Optional installer for Bun and the `subminer` CLI on Linux, macOS, and Windows, including a Windows `subminer.cmd` PATH shim so `subminer` works without manually adding `SubMiner.exe` to PATH; setup recognizes existing Homebrew or user PATH installs and avoids writing into Homebrew-owned paths; includes an Open SubMiner Settings button; standalone setup app quits after completing, returning terminal control.
- Primary Subtitle Visibility on Yomitan Popup: New `subtitleStyle.primaryVisibleOnYomitanPopup` option keeps hover-mode primary subtitles visible while a Yomitan popup is open.
### Changed
- Subtitle Appearance Config: Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`; known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`; subtitle font defaults updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`. Existing configs migrate automatically; legacy Anki color keys still accepted with deprecation warnings.
- Subtitle Style Defaults: Stronger outline-style text shadow, thicker JLPT underlines, and frequency `topX` default raised to `10000`.
- Character Dictionary: Entries scoped to the current AniList media for name matching and inline portraits; generates Japanese-only name aliases so raw romanized/English aliases no longer surface as separate results; new `Ctrl/Cmd+D` manager modal to remove, reorder, or override loaded entries; in-app AniList selector waits for an explicit search with the box prefilled from the current filename; `subtitleStyle.nameMatchEnabled` is now the sole switch for dictionary sync and builds.
- Electron Runtime: Updated from 39.8.6 to 42.2.0, returning SubMiner to a supported Electron release line.
- N+1 Highlighting Default: `ankiConnect.nPlusOne.enabled` is no longer implicitly enabled when known-word highlighting is on; existing configs that already had N+1 enabled are unchanged, but new configs must set it explicitly.
- Linux Auto-Update Flow: Linux tray "Check for Updates" now installs the new AppImage automatically, matching macOS and Windows; AppImages managed by a system package (e.g. AUR) and non-AppImage launches still use the GitHub-asset flow.
- Jellyfin Setup: Removed the server presets dropdown; setup now shows a single editable server URL field.
- Jellyfin Cast Identity: Device identity now derived from the OS hostname and always reported as SubMiner; previously configurable identity fields are ignored, preventing multiple installs from sharing a remote-session identity.
- Startup Defaults: Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
- Setup Appearance: Removed the bundled mpv runtime plugin readiness card from the setup flow.
### Fixed
- AniList Progress: Progress updates fire correctly when playback reaches or skips past the watched threshold using fresh mpv timing events; season-specific results preferred for multi-season files with a clear message when the matched season is not in Planning or Watching; repeated missing-token checks no longer exhaust retry attempts or duplicate dead-letter entries.
- Anki Mining: Sentence-audio padding is opt-in by default; animated AVIF freeze-frame duration aligned to word audio length without double-counting; multi-line sentence alignment fixed for repeated subtitle text; Kiku duplicate-card detection, auto-merge, modal acknowledgment race, and field/tag ordering corrected; YouTube playback cards use mpv's resolved stream URLs; sentence cards refresh the secondary subtitle before saving; known-word cache appends correctly with multiple deck field mappings.
- Jellyfin Discovery: Startup, subtitle track selection, and duplicate ready-signal handling all fixed; paused mpv no longer misreported as playing; startup unpause no longer repeats after a manual pause or `y-t` toggle; delayed Japanese subtitle selection, later-loading foreign track hijacking, and long-lived sidebar ffmpeg extractor leaks fixed; resume corrected when a remote play command sends `StartPositionTicks: 0` despite saved progress; picker library discovery kept working regardless of app log level.
- Jellyfin Remote: Tray checkbox stays in sync on Linux after tray, CLI, or startup changes; stale discovery sessions restarted when the server no longer lists the SubMiner cast target; remote controller visibility and progress sync fixed for seeks, stops, startup path changes, and Linux websocket reconnect windows; Play and Resume now behave correctly (Play from beginning, Resume from saved position); final progress reports reuse SubMiner's last known position when mpv resets on stop; Windows setup login flow fixed with an IPC bridge, immediate feedback, and a timeout with inline error for unreachable servers.
- Jellyfin Subtitles and Overlay: Subtitle overlay shown automatically during Jellyfin playback; `y-t` toggle made reliable and sticky across stream redirects; managed subtitle defaults re-armed on redirect; passive Linux/Hyprland overlay shows no longer steal keyboard focus from mpv; subtitle timing improved with preferred embedded streams over external sidecars, correct Japanese-vs-English cue offset handling, per-stream delay shift restoration, and transient track-list read failure tolerance.
- Overlay (macOS): Overlay hides when mpv loses focus, is minimized, or is no longer the foreground app; stable through transient window geometry disappearances from macOS APIs and when clicking from the overlay back into mpv; stats overlay opened inactive so it appears over fullscreen mpv without switching Spaces; passthrough fixed so mpv controls stay clickable before hovering a subtitle bar; window-tracker polling reduced while mpv is stably focused.
- Overlay (Linux / Hyprland): Placement refreshes after leaving fullscreen; overlay stays above mpv after focus changes from clicks or movement; Settings and Yomitan windows promoted above the subtitle overlay instead of opening behind it; overlay hides when the character dictionary modal opens, including during AniList lookup.
- Overlay Lifecycle: First startup subtitle primed before autoplay resumes so the overlay renders text before playback begins; overlay and subtitle stream kept alive after `y-r` restart with correct Linux bounds reapplication; launcher-owned playback quits SubMiner on end while background/tray sessions stay alive; subtitle sync modal fixed on macOS so it no longer flashes on first attempt or leaves stale state; Windows managed mpv launches from a background instance now correctly receive the start command, retarget the new socket, bind to the player window, and receive startup overlay options.
- Yomitan Sidebar: Playback stays paused for sidebar-opened Yomitan popups when auto-pause is enabled; fixed popups not opening when startup races the Yomitan extension load; sidebar mining cards use audio and images from the clicked sidebar line instead of the current primary subtitle.
- Launcher: Warm launches reuse a running background instance, reapply preferred subtitles, and close launcher-owned tray apps after playback ends; videos stay paused until subtitle priming and tokenization readiness complete; `subminer settings` on macOS exits cleanly when the window is closed; `subminer app` on Linux returns terminal control immediately; Linux first-run installs build with a valid Bun shebang; `subminer app --setup` opens the setup flow when SubMiner is already running in background.
- YouTube Playback: Selected subtitles downloaded to local temp files so the primary bar and sidebar read the same source, with cleanup on reload and quit; false load-failure notifications suppressed; tray icon created on launcher-managed playback that attaches to an already-running process; mpv plugin no longer starts a second SubMiner instance for app-owned YouTube playback.
- Shortcuts: Native mpv menu shortcuts disabled during managed macOS playback so configured SubMiner shortcuts work while mpv has focus; custom session shortcuts including `stats.markWatchedKey` wired through mpv; multi-line copy/mine overlay correctly focused so number keys choose the line count on macOS and Windows.
- Controller Bindings: Controller config and debug shortcuts stay closed while controller support is disabled; binding learn mode starts from the edit pencil; remaps saved per controller profile; binding badges also start learn mode; row reset buttons restore individual bindings to defaults.
- Logging: `logging.level` forwarded to launcher-started and Windows shortcut-started mpv sessions covering mpv log verbosity, plugin logging, and plugin-launched app logging; `logging.rotation` (default 7 days) and per-component `logging.files` toggles added with mpv logs disabled by default; repeated IPC socket warning spam suppressed while waiting for mpv to recreate the socket; Windows mpv IPC, subtitle track, and Yomitan diagnostics added.
- Updater: Linux `subminer -u` performs release updates independently of any running tray app using GitHub release metadata; macOS update dialogs from `subminer -u` reliably appear in the foreground with a manual-install message for builds that cannot apply native updates; macOS and Linux `electron-updater` routes through `/usr/bin/curl` to avoid Electron network crashes; Windows automatic updates keep the native NSIS install path while routing updater HTTP through main-process fetch to avoid delayed exit after launch.
- In-Player Stats: Layering fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window; Jellyfin playback stats grouped by item metadata so watched episodes merge with matching local library titles and keep clean display names.
- Tray: Tray stays running when Yomitan settings are closed; settings loading no longer blocks other tray actions; Yomitan extension refreshes serialized at startup; embedded popup preview disabled to prevent renderer hangs during sidebar navigation; Windows "Open SubMiner Setup" action opens the setup window correctly after first-run is complete; session help modal close fixed without mpv running.
- Discord Rich Presence: No longer falls back to Jellyfin stream URLs; Jellyfin playback titles primed before stream loading so presence shows the show/episode title instead of a URL.
- WebSocket Annotations: Annotation spans and token metadata stay on the annotation WebSocket; the regular subtitle WebSocket is plain-text only.
- Subtitle Frequency Highlighting: Frequency annotations kept for determiner-led noun compounds like `その場` while still filtering standalone determiners; fixed for Yomitan single-token compounds with internal particles such as `目の前` while keeping pure grammar/kana helper spans unannotated.
- Subtitle Annotation Prefetching: Cached colored annotations and character images ready sooner for live subtitle changes without delaying raw subtitle display.
- Packaging: macOS compiled mpv window helper correctly built into `dist/scripts` and bundled, preventing fallback to slow Swift source startup; stale Windows helper resource entry removed; one-shot `make clean build install` AppImage flows fixed so install picks up the AppImage built earlier in the same invocation.
- Windows Startup Errors: Fatal startup failures now show a native error dialog and write details to the app log instead of exiting silently.
### Docs
- Documentation Site: Published stable docs at the site root with current development docs under `/main/`; fixed versioned docs navigation, archived page link handling, and local dev version routing; documented all previously undocumented config options including `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options; added Playback Startup Flow and Runtime Sockets diagrams to the architecture docs with cross-reference pointers in the MPV Plugin and Troubleshooting pages.
## 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`.
+102 -2
View File
@@ -488,7 +488,7 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
changedEntries: [{ path: 'src/main-entry.ts', status: 'M' }], changedEntries: [{ path: 'src/main-entry.ts', status: 'M' }],
changedLabels: [], changedLabels: [],
}), }),
/requires a changelog fragment/, /requires a reconciled changelog fragment/,
); );
assert.doesNotThrow(() => assert.doesNotThrow(() =>
@@ -514,7 +514,7 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
], ],
changedLabels: [], changedLabels: [],
}), }),
/requires a changelog fragment/, /requires a reconciled changelog fragment/,
); );
assert.doesNotThrow(() => assert.doesNotThrow(() =>
@@ -526,6 +526,27 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
changedLabels: [], 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 () => { test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => {
@@ -1044,6 +1065,85 @@ 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, /## Whats 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.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, /Whats Changed/);
assert.doesNotMatch(changelog, /New Contributors/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => { test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
const { writeChangelogArtifacts } = await loadModule(); const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('reuse-existing-section'); const workspace = createWorkspace('reuse-existing-section');
+174 -3
View File
@@ -4,6 +4,20 @@ import { execFileSync } from 'node:child_process';
type RunClaude = (input: string, args: string[]) => string; 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 = { type ChangelogFsDeps = {
existsSync?: (candidate: string) => boolean; existsSync?: (candidate: string) => boolean;
mkdirSync?: (candidate: string, options: { recursive: true }) => void; mkdirSync?: (candidate: string, options: { recursive: true }) => void;
@@ -13,6 +27,7 @@ type ChangelogFsDeps = {
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void; writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
log?: (message: string) => void; log?: (message: string) => void;
runClaude?: RunClaude; runClaude?: RunClaude;
resolveContributions?: ResolveContributions;
}; };
type PolishMode = 'changelog' | 'release-notes'; type PolishMode = 'changelog' | 'release-notes';
@@ -296,6 +311,152 @@ 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 renderContributorsSections(contributions: Contribution[]): string[] {
if (contributions.length === 0) {
return [];
}
const lines: string[] = ['## Whats 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( function serializeFragmentsForPrompt(
fragments: ChangeFragment[], fragments: ChangeFragment[],
mode: PolishMode, mode: PolishMode,
@@ -473,6 +634,7 @@ function renderReleaseNotes(
changes: string, changes: string,
options?: { options?: {
disclaimer?: string; disclaimer?: string;
contributions?: Contribution[];
}, },
): string { ): string {
const prefix = options?.disclaimer ? [options.disclaimer, ''] : []; const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
@@ -494,6 +656,7 @@ function renderReleaseNotes(
'', '',
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.', 'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
'', '',
...renderContributorsSections(options?.contributions ?? []),
].join('\n'); ].join('\n');
} }
@@ -504,6 +667,7 @@ function writeReleaseNotesFile(
options?: { options?: {
disclaimer?: string; disclaimer?: string;
outputPath?: string; outputPath?: string;
contributions?: Contribution[];
}, },
): string { ): string {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
@@ -530,6 +694,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
const version = resolveVersion(options ?? {}); const version = resolveVersion(options ?? {});
const date = resolveDate(options?.date); const date = resolveDate(options?.date);
const fragments = readChangeFragments(cwd, options?.deps); const fragments = readChangeFragments(cwd, options?.deps);
const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps);
const existingChangelogPath = path.join(cwd, 'CHANGELOG.md'); const existingChangelogPath = path.join(cwd, 'CHANGELOG.md');
const existingChangelog = existsSync(existingChangelogPath) const existingChangelog = existsSync(existingChangelogPath)
? readFileSync(existingChangelogPath, 'utf8') ? readFileSync(existingChangelogPath, 'utf8')
@@ -547,6 +712,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
cwd, cwd,
stripDetailsBlocks(existingReleaseSection), stripDetailsBlocks(existingReleaseSection),
options?.deps, options?.deps,
{ contributions },
); );
log(`Generated ${releaseNotesPath}`); log(`Generated ${releaseNotesPath}`);
@@ -572,7 +738,9 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
date, date,
deps: options?.deps, deps: options?.deps,
}); });
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps); const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps, {
contributions,
});
log(`Generated ${releaseNotesPath}`); log(`Generated ${releaseNotesPath}`);
for (const fragment of fragments) { for (const fragment of fragments) {
@@ -661,14 +829,15 @@ export function verifyPullRequestChangelog(options: PullRequestChangelogOptions)
return; return;
} }
const hasFragment = normalizedEntries.some( const fragmentEntries = normalizedEntries.filter(
(entry) => entry.status !== 'D' && isFragmentPath(entry.path), (entry) => entry.status !== 'D' && isFragmentPath(entry.path),
); );
const hasFragment = fragmentEntries.length > 0;
const requiresFragment = normalizedEntries.some((entry) => !isIgnoredPullRequestPath(entry.path)); const requiresFragment = normalizedEntries.some((entry) => !isIgnoredPullRequestPath(entry.path));
if (requiresFragment && !hasFragment) { if (requiresFragment && !hasFragment) {
throw new Error( 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.`,
); );
} }
} }
@@ -832,10 +1001,12 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
existingReleaseNotes, existingReleaseNotes,
deps: options?.deps, deps: options?.deps,
}); });
const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps);
return writeReleaseNotesFile(cwd, changes, options?.deps, { return writeReleaseNotesFile(cwd, changes, options?.deps, {
disclaimer: disclaimer:
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
outputPath: PRERELEASE_NOTES_PATH, outputPath: PRERELEASE_NOTES_PATH,
contributions,
}); });
} }
+170
View File
@@ -0,0 +1,170 @@
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 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
callback(true, { status = 0, stdout = "", stderr = "" }, nil)
end
function mp.add_timeout(_, callback)
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
print("plugin restart feedback tests: OK")
+9
View File
@@ -229,6 +229,14 @@ local ctx = {
actionType = "mpv-command", actionType = "mpv-command",
command = { "quit" }, command = { "quit" },
}, },
{
key = {
code = "MBTN_BACK",
modifiers = {},
},
actionType = "mpv-command",
command = { "sub-seek", -1 },
},
{ {
key = { key = {
code = "KeyW", code = "KeyW",
@@ -317,6 +325,7 @@ local expected_mpv_bindings = {
{ keys = "L", command = { "sub-seek", 1 } }, { keys = "L", command = { "sub-seek", 1 } },
{ keys = "q", command = { "quit" } }, { keys = "q", command = { "quit" } },
{ keys = "Ctrl+w", command = { "quit" } }, { keys = "Ctrl+w", command = { "quit" } },
{ keys = "MBTN_BACK", command = { "sub-seek", -1 } },
} }
for _, expected in ipairs(expected_mpv_bindings) do for _, expected in ipairs(expected_mpv_bindings) do
+317 -172
View File
@@ -13,6 +13,7 @@ local function run_plugin_scenario(config)
property_sets = {}, property_sets = {},
periodic_timers = {}, periodic_timers = {},
timeouts = {}, timeouts = {},
timeout_handles = {},
} }
local function make_mp_stub() local function make_mp_stub()
@@ -86,13 +87,6 @@ local function run_plugin_scenario(config)
} }
end end
if args[1] == "curl" then 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 = "" } return { status = 0, stdout = "{}", stderr = "" }
end end
return { status = 0, stdout = "", stderr = "" } return { status = 0, stdout = "", stderr = "" }
@@ -107,15 +101,8 @@ local function run_plugin_scenario(config)
return return
end end
if args[1] == "curl" then if args[1] == "curl" then
local url = args[#args] or "" callback(true, { status = 0, stdout = "{}", stderr = "" }, nil)
if type(url) == "string" and url:find("myanimelist", 1, true) then return
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
end end
for _, value in ipairs(args) do for _, value in ipairs(args) do
if value == "--app-ping" then if value == "--app-ping" then
@@ -139,15 +126,17 @@ local function run_plugin_scenario(config)
recorded.timeouts[#recorded.timeouts + 1] = seconds recorded.timeouts[#recorded.timeouts + 1] = seconds
local timeout = { local timeout = {
killed = false, killed = false,
callback = callback,
} }
function timeout:kill() function timeout:kill()
self.killed = true self.killed = true
end end
local delay = tonumber(seconds) or 0 local delay = tonumber(seconds) or 0
if callback and delay < 5 then if callback and delay < 5 and not config.defer_timeouts then
callback() callback()
end end
recorded.timeout_handles[#recorded.timeout_handles + 1] = timeout
return timeout return timeout
end end
@@ -260,34 +249,6 @@ local function run_plugin_scenario(config)
amount = 125, amount = 125,
}, nil }, nil
end 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 return {}, nil
end end
@@ -308,7 +269,6 @@ local function run_plugin_scenario(config)
package.loaded["process"] = nil package.loaded["process"] = nil
package.loaded["state"] = nil package.loaded["state"] = nil
package.loaded["ui"] = nil package.loaded["ui"] = nil
package.loaded["aniskip"] = nil
_G.__subminer_plugin_bootstrapped = nil _G.__subminer_plugin_bootstrapped = nil
local original_package_config = package.config local original_package_config = package.config
if config.platform == "windows" then if config.platform == "windows" then
@@ -502,33 +462,6 @@ local function has_async_command(async_calls, executable)
return false return false
end 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) local function has_property_set(property_sets, name, value)
for _, call in ipairs(property_sets) do for _, call in ipairs(property_sets) do
if call.name == name and call.value == value then if call.name == name and call.value == value then
@@ -612,6 +545,15 @@ local function fire_event(recorded, name, ...)
end end
end end
local function fire_pending_timeouts(recorded)
for _, timeout in ipairs(recorded.timeout_handles or {}) do
if not timeout.killed and timeout.callback then
timeout.killed = true
timeout.callback()
end
end
end
local function fire_observer(recorded, name, value) local function fire_observer(recorded, name, value)
local listeners = recorded.observers[name] or {} local listeners = recorded.observers[name] or {}
for _, listener in ipairs(listeners) do for _, listener in ipairs(listeners) do
@@ -619,15 +561,6 @@ local function fire_observer(recorded, name, value)
end end
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 binary_path = "/tmp/subminer-binary"
local appimage_path = "/tmp/SubMiner.AppImage" local appimage_path = "/tmp/SubMiner.AppImage"
@@ -647,13 +580,88 @@ do
assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err)) assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err))
assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered") assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered")
recorded.script_messages["subminer-start"]("texthooker=no") recorded.script_messages["subminer-start"]("texthooker=no")
assert_true(find_start_call(recorded.async_calls) ~= nil, "expected cold-start to invoke --start command when process is absent") assert_true(
find_start_call(recorded.async_calls) ~= nil,
"expected cold-start to invoke --start command when process is absent"
)
assert_true( assert_true(
not has_sync_command(recorded.sync_calls, "ps"), not has_sync_command(recorded.sync_calls, "ps"),
"expected cold-start start command to avoid synchronous process list scan" "expected cold-start start command to avoid synchronous process list scan"
) )
end end
do
local scenario = {
process_list = "",
defer_timeouts = true,
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/episode-01.mkv",
media_title = "Episode 1",
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for warm playlist visibility scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
fire_event(recorded, "end-file", { reason = "eof" })
scenario.path = "/media/episode-02.mkv"
scenario.media_title = "Episode 2"
fire_event(recorded, "file-loaded")
fire_pending_timeouts(recorded)
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 0,
"warm playlist advance should cancel the end-file hide before it hides the next video's overlay"
)
assert_true(
count_start_calls(recorded.async_calls) == 1,
"warm playlist visibility reuse should not issue another --start command"
)
end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "no",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/manual-episode-01.mkv",
media_title = "Manual Episode 1",
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for manual warm playlist visibility scenario: " .. tostring(err))
recorded.script_messages["subminer-toggle"]()
recorded.script_messages["subminer-autoplay-ready"]()
fire_event(recorded, "end-file", { reason = "eof" })
scenario.path = "/media/manual-episode-02.mkv"
scenario.media_title = "Manual Episode 2"
fire_event(recorded, "file-loaded")
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 0,
"manual visible overlay should remain visible across warm playlist auto-start reattach"
)
assert_true(
count_start_calls(recorded.async_calls) == 1,
"manual warm playlist visibility reuse should not issue another --start command"
)
end
do do
local scenario = { local scenario = {
process_list = "", process_list = "",
@@ -714,13 +722,13 @@ do
"new media after prior playback should reuse the running overlay" "new media after prior playback should reuse the running overlay"
) )
assert_true( assert_true(
count_property_set(recorded.property_sets, "pause", true) == 2, count_property_set(recorded.property_sets, "pause", true) == 1,
"new media after prior playback should re-arm pause-until-ready" "new media after prior ready playback should not re-arm pause-until-ready"
) )
recorded.script_messages["subminer-autoplay-ready"]() recorded.script_messages["subminer-autoplay-ready"]()
assert_true( assert_true(
count_property_set(recorded.property_sets, "pause", false) == 2, count_property_set(recorded.property_sets, "pause", false) == 1,
"new media after prior playback should resume only after readiness" "new media after prior ready playback should not wait for another readiness signal"
) )
end end
@@ -892,6 +900,31 @@ do
) )
end 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 do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -1238,7 +1271,6 @@ do
auto_start = "yes", auto_start = "yes",
auto_start_visible_overlay = "yes", auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes", auto_start_pause_until_ready = "yes",
aniskip_enabled = "yes",
socket_path = "/tmp/subminer-socket", socket_path = "/tmp/subminer-socket",
}, },
input_ipc_server = "/tmp/subminer-socket", input_ipc_server = "/tmp/subminer-socket",
@@ -1280,7 +1312,6 @@ do
option_overrides = { option_overrides = {
binary_path = binary_path, binary_path = binary_path,
auto_start = "no", auto_start = "no",
aniskip_enabled = "yes",
}, },
files = { files = {
[binary_path] = true, [binary_path] = true,
@@ -1317,14 +1348,11 @@ do
auto_start = "yes", auto_start = "yes",
auto_start_visible_overlay = "yes", auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes", auto_start_pause_until_ready = "yes",
aniskip_enabled = "yes",
socket_path = "/tmp/subminer-socket", socket_path = "/tmp/subminer-socket",
}, },
input_ipc_server = "/tmp/subminer-socket", input_ipc_server = "/tmp/subminer-socket",
path = media_path, path = media_path,
media_title = "Sample Show S01E01", media_title = "Sample Show S01E01",
mal_lookup_stdout = "__MAL_FOUND__",
aniskip_stdout = "__ANISKIP_FOUND__",
files = { files = {
[binary_path] = true, [binary_path] = true,
}, },
@@ -1342,10 +1370,6 @@ do
count_property_set(recorded.property_sets, "pause", true) == 1, count_property_set(recorded.property_sets, "pause", true) == 1,
"same-media reload should not re-arm pause-until-ready" "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 end
do do
@@ -1448,7 +1472,6 @@ do
option_overrides = { option_overrides = {
binary_path = binary_path, binary_path = binary_path,
auto_start = "no", auto_start = "no",
aniskip_enabled = "yes",
}, },
media_title = "Random Movie", media_title = "Random Movie",
files = { files = {
@@ -1458,14 +1481,10 @@ do
assert_true(recorded ~= nil, "plugin failed to load for non-subminer file-load scenario: " .. tostring(err)) assert_true(recorded ~= nil, "plugin failed to load for non-subminer file-load scenario: " .. tostring(err))
fire_event(recorded, "file-loaded") 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, "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( assert_true(
not has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"), not has_async_command(recorded.async_calls, "curl"),
"file-loaded without SubMiner context should skip AniSkip MAL lookup" "file-loaded should not perform plugin-side AniSkip lookups (AniSkip now lives in the app)"
)
assert_true(
not has_async_curl_for(recorded.async_calls, "api.aniskip.com"),
"file-loaded without SubMiner context should skip AniSkip API lookup"
) )
end end
@@ -1487,75 +1506,12 @@ do
[binary_path] = true, [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") fire_event(recorded, "file-loaded")
assert_true(find_start_call(recorded.async_calls) ~= nil, "URL auto-start should still invoke --start command") assert_true(find_start_call(recorded.async_calls) ~= nil, "URL auto-start should still invoke --start command")
assert_true( assert_true(
not has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"), not has_async_command(recorded.async_calls, "curl"),
"URL playback should skip AniSkip MAL lookup even after overlay-start" "URL playback should not trigger plugin-side network lookups"
)
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"
) )
end end
@@ -1608,6 +1564,91 @@ do
) )
end 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 do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -1800,6 +1841,87 @@ do
) )
end 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 launcher-owned pause-until-ready scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
has_property_set(recorded.property_sets, "pause", false),
"launcher-owned initial pause should resume when autoplay-ready arrives"
)
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 = "",
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",
auto_start_pause_until_ready_timeout_seconds = 0.1,
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 launcher-owned pause timeout scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(
has_property_set(recorded.property_sets, "pause", false),
"launcher-owned initial pause should resume when autoplay-ready timeout fires"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -1992,7 +2114,9 @@ do
option_overrides = { option_overrides = {
binary_path = binary_path, binary_path = binary_path,
auto_start = "no", auto_start = "no",
socket_path = "/tmp/subminer-socket",
}, },
input_ipc_server = "/tmp/subminer-socket",
files = { files = {
[binary_path] = true, [binary_path] = true,
}, },
@@ -2000,9 +2124,30 @@ do
assert_true(recorded ~= nil, "plugin failed to load for manual toggle command scenario: " .. tostring(err)) assert_true(recorded ~= nil, "plugin failed to load for manual toggle command scenario: " .. tostring(err))
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered") assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]() recorded.script_messages["subminer-toggle"]()
local start_call = find_start_call(recorded.async_calls)
assert_true( assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1, start_call ~= nil,
"script-message toggle should issue explicit visible-overlay toggle command" "first manual toggle from a stopped overlay should start SubMiner with mpv attachment"
)
assert_true(
call_has_arg(start_call, "--managed-playback"),
"first manual toggle should attach managed playback so subtitles reach the overlay"
)
assert_true(
call_has_arg(start_call, "--socket") and call_has_arg(start_call, "/tmp/subminer-socket"),
"first manual toggle should pass the active mpv socket to SubMiner"
)
assert_true(
call_has_arg(start_call, "--show-visible-overlay"),
"first manual toggle should start directly into visible overlay state"
)
assert_true(
not call_has_arg(start_call, "--hide-visible-overlay"),
"first manual toggle should not start hidden"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"first manual toggle should not issue a bare visible-overlay toggle before mpv is attached"
) )
assert_true( assert_true(
count_control_calls(recorded.async_calls, "--toggle") == 0, count_control_calls(recorded.async_calls, "--toggle") == 0,
+19
View File
@@ -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 () => { 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 { const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> }; client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
+23
View File
@@ -156,6 +156,22 @@ export class AnkiConnectClient {
return (result as number[]) || []; 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[]> { async deckNames(): Promise<string[]> {
const result = await this.invoke('deckNames'); const result = await this.invoke('deckNames');
return Array.isArray(result) return Array.isArray(result)
@@ -231,6 +247,13 @@ export class AnkiConnectClient {
return (result as Record<string, unknown>[]) || []; 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> { async updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void> {
await this.invoke('updateNoteFields', { await this.invoke('updateNoteFields', {
note: { note: {
+190
View File
@@ -7,6 +7,14 @@ import { AnkiIntegration } from './anki-integration';
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
import { AnkiConnectConfig } from './types'; import { AnkiConnectConfig } from './types';
type TestOverlayNotificationPayload = {
title: string;
body?: string;
image?: string;
variant?: string;
actions?: Array<{ id: string; label: string; noteId?: number }>;
};
interface IntegrationTestContext { interface IntegrationTestContext {
integration: AnkiIntegration; integration: AnkiIntegration;
calls: { 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)']); 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 () => { test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => {
const collaborator = createFieldGroupingMergeCollaborator(); const collaborator = createFieldGroupingMergeCollaborator();
+145 -36
View File
@@ -29,6 +29,8 @@ import {
} from './types/anki'; } from './types/anki';
import { AiConfig } from './types/integrations'; import { AiConfig } from './types/integrations';
import { MpvClient } from './types/runtime'; 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 type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle';
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config'; import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
import { 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 { export class AnkiIntegration {
private client: AnkiConnectClient; private client: AnkiConnectClient;
private mediaGenerator: MediaGenerator; private mediaGenerator: MediaGenerator;
@@ -130,6 +141,8 @@ export class AnkiIntegration {
private osdCallback: ((text: string) => void) | null = null; private osdCallback: ((text: string) => void) | null = null;
private notificationCallback: ((title: string, options: NotificationOptions) => void) | null = private notificationCallback: ((title: string, options: NotificationOptions) => void) | null =
null; null;
private overlayNotificationCallback: ((payload: OverlayNotificationPayload) => void) | null =
null;
private updateInProgress = false; private updateInProgress = false;
private uiFeedbackState: UiFeedbackState = createUiFeedbackState(); private uiFeedbackState: UiFeedbackState = createUiFeedbackState();
private parseWarningKeys = new Set<string>(); private parseWarningKeys = new Set<string>();
@@ -166,6 +179,7 @@ export class AnkiIntegration {
knownWordCacheStatePath?: string, knownWordCacheStatePath?: string,
aiConfig: AiConfig = {}, aiConfig: AiConfig = {},
recordCardsMined?: (count: number, noteIds?: number[]) => void, recordCardsMined?: (count: number, noteIds?: number[]) => void,
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
) { ) {
this.config = normalizeAnkiIntegrationConfig(config); this.config = normalizeAnkiIntegrationConfig(config);
this.aiConfig = { ...aiConfig }; this.aiConfig = { ...aiConfig };
@@ -175,6 +189,7 @@ export class AnkiIntegration {
this.mpvClient = mpvClient; this.mpvClient = mpvClient;
this.osdCallback = osdCallback || null; this.osdCallback = osdCallback || null;
this.notificationCallback = notificationCallback || null; this.notificationCallback = notificationCallback || null;
this.overlayNotificationCallback = overlayNotificationCallback || null;
this.fieldGroupingCallback = fieldGroupingCallback || null; this.fieldGroupingCallback = fieldGroupingCallback || null;
this.recordCardsMinedCallback = recordCardsMined ?? null; this.recordCardsMinedCallback = recordCardsMined ?? null;
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath); this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
@@ -335,7 +350,7 @@ export class AnkiIntegration {
options, options,
), ),
}, },
showOsdNotification: (text: string) => this.showOsdNotification(text), showOsdNotification: (text: string) => this.showStatusNotification(text),
showUpdateResult: (message: string, success: boolean) => showUpdateResult: (message: string, success: boolean) =>
this.showUpdateResult(message, success), this.showUpdateResult(message, success),
showStatusNotification: (message: string) => this.showStatusNotification(message), showStatusNotification: (message: string) => this.showStatusNotification(message),
@@ -387,7 +402,7 @@ export class AnkiIntegration {
getDeck: () => this.config.deck, getDeck: () => this.config.deck,
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) => withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) =>
this.withUpdateProgress(initialMessage, action), this.withUpdateProgress(initialMessage, action),
showOsdNotification: (text: string) => this.showOsdNotification(text), showOsdNotification: (text: string) => this.showStatusNotification(text),
findNotes: async (query, options) => findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as number[], (await this.client.findNotes(query, options)) as number[],
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown as NoteInfo[], notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown as NoteInfo[],
@@ -463,7 +478,7 @@ export class AnkiIntegration {
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(), consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId), addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
showNotification: (noteId, label) => this.showNotification(noteId, label), showNotification: (noteId, label) => this.showNotification(noteId, label),
showOsdNotification: (message) => this.showOsdNotification(message), showOsdNotification: (message) => this.showStatusNotification(message),
beginUpdateProgress: (initialMessage) => this.beginUpdateProgress(initialMessage), beginUpdateProgress: (initialMessage) => this.beginUpdateProgress(initialMessage),
endUpdateProgress: () => this.endUpdateProgress(), endUpdateProgress: () => this.endUpdateProgress(),
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)), logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
@@ -510,7 +525,7 @@ export class AnkiIntegration {
}, },
showStatusNotification: (message) => this.showStatusNotification(message), showStatusNotification: (message) => this.showStatusNotification(message),
showNotification: (noteId, label) => this.showNotification(noteId, label), 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)), logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)), logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
truncateSentence: (sentence) => this.truncateSentence(sentence), truncateSentence: (sentence) => this.truncateSentence(sentence),
@@ -525,6 +540,10 @@ export class AnkiIntegration {
return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode; 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 { private isKnownWordCacheEnabled(): boolean {
return ( return (
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true
@@ -860,10 +879,13 @@ export class AnkiIntegration {
private showStatusNotification(message: string): void { private showStatusNotification(message: string): void {
showStatusNotification(message, { showStatusNotification(message, {
getNotificationType: () => this.config.behavior?.notificationType, getNotificationType: () => this.getNotificationType(),
showOsd: (text: string) => { showOsd: (text: string) => {
this.showOsdNotification(text); this.showOsdNotification(text);
}, },
showOverlayNotification: (payload) => {
this.overlayNotificationCallback?.(payload);
},
showSystemNotification: (title: string, options: NotificationOptions) => { showSystemNotification: (title: string, options: NotificationOptions) => {
if (this.notificationCallback) { if (this.notificationCallback) {
this.notificationCallback(title, options); 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 { 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) => { beginUpdateProgress(this.uiFeedbackState, initialMessage, (text: string) => {
this.showOsdNotification(text); this.showOsdNotification(text);
}); });
} }
private endUpdateProgress(): void { private endUpdateProgress(): void {
if (!this.shouldUseOsdNotifications()) {
return;
}
endUpdateProgress(this.uiFeedbackState, (timer) => { endUpdateProgress(this.uiFeedbackState, (timer) => {
clearInterval(timer); clearInterval(timer);
}); });
} }
private clearUpdateProgress(): void { private clearUpdateProgress(): void {
if (!this.shouldUseOsdNotifications()) {
return;
}
clearUpdateProgress(this.uiFeedbackState, (timer) => { clearUpdateProgress(this.uiFeedbackState, (timer) => {
clearInterval(timer); clearInterval(timer);
}); });
@@ -894,6 +948,23 @@ export class AnkiIntegration {
initialMessage: string, initialMessage: string,
action: () => Promise<T>, action: () => Promise<T>,
): 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( return withUpdateProgress(
this.uiFeedbackState, this.uiFeedbackState,
{ {
@@ -1017,51 +1088,89 @@ export class AnkiIntegration {
? `Updated card: ${label} (${errorSuffix})` ? `Updated card: ${label} (${errorSuffix})`
: `Updated card: ${label}`; : `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); this.showUpdateResult(message, errorSuffix === undefined);
} else { } else {
this.clearUpdateProgress(); this.clearUpdateProgress();
} }
if ((type === 'system' || type === 'both') && this.notificationCallback) { const shouldShowOverlayNotification =
let notificationIconPath: string | undefined; (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) { if (shouldShowOverlayNotification && this.overlayNotificationCallback) {
try { this.overlayNotificationCallback({
const timestamp = this.mpvClient.currentTimePos || 0; id: 'anki-update-progress',
const notificationIconSource = await resolveMediaGenerationInputPath( title: 'Anki Card Updated',
this.mpvClient, body: message,
'video', ...(notificationIcon ? { image: notificationIcon.overlayImageSource } : {}),
); variant: errorSuffix === undefined ? 'success' : 'error',
if (!notificationIconSource) { persistent: false,
throw new Error('No media source available for notification icon'); actions: [{ id: OPEN_ANKI_CARD_ACTION_ID, label: 'Open in Anki', noteId }],
} });
const iconBuffer = await this.mediaGenerator.generateNotificationIcon( }
notificationIconSource,
timestamp, if (shouldShowSystemNotification && this.notificationCallback) {
); this.notificationCallback('Anki Card Updated', {
if (iconBuffer && iconBuffer.length > 0) { body: message,
notificationIconPath = this.mediaGenerator.writeNotificationIconToFile( 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, iconBuffer,
noteId, 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;
} }
} catch (err) {
this.notificationCallback('Anki Card Updated', { log.warn('Failed to generate notification icon:', (err as Error).message);
body: message,
icon: notificationIconPath,
});
if (notificationIconPath) {
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIconPath);
}
} }
return undefined;
} }
private showUpdateResult(message: string, success: boolean): void { 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.equal(updatedFields[0]?.Sentence, '一行目 二行目');
assert.match(updatedFields[0]?.Picture ?? '', /^<img src="image_\d+\.jpg">$/); 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, []);
});
-1
View File
@@ -511,7 +511,6 @@ export class CardCreationService {
endTime = startTime + maxMediaDuration; endTime = startTime + maxMediaDuration;
} }
this.deps.showOsdNotification('Creating sentence card...');
try { try {
return await this.deps.withUpdateProgress('Creating sentence card', async () => { return await this.deps.withUpdateProgress('Creating sentence card', async () => {
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video'); const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
+56 -1
View File
@@ -1,9 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test';
import { import {
beginUpdateProgress, beginUpdateProgress,
createUiFeedbackState, createUiFeedbackState,
showProgressTick, showProgressTick,
showStatusNotification,
showUpdateResult, showUpdateResult,
} from './ui-feedback'; } from './ui-feedback';
@@ -65,3 +66,57 @@ test('showUpdateResult renders failed updates with an x marker', () => {
'x Sentence card failed: deck missing', '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']);
});
+23 -5
View File
@@ -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 { export interface UiFeedbackState {
progressDepth: number; progressDepth: number;
@@ -13,8 +14,9 @@ export interface UiFeedbackResult {
} }
export interface UiFeedbackNotificationContext { export interface UiFeedbackNotificationContext {
getNotificationType: () => string | undefined; getNotificationType: () => NotificationType | undefined;
showOsd: (text: string) => void; showOsd: (text: string) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showSystemNotification: (title: string, options: NotificationOptions) => void; showSystemNotification: (title: string, options: NotificationOptions) => void;
} }
@@ -36,13 +38,29 @@ export function showStatusNotification(
message: string, message: string,
context: UiFeedbackNotificationContext, context: UiFeedbackNotificationContext,
): void { ): 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); context.showOsd(message);
} }
if (type === 'system' || type === 'both') { if (type === 'system' || type === 'both' || type === 'osd-system') {
context.showSystemNotification('SubMiner', { body: message }); context.showSystemNotification('SubMiner', { body: message });
} }
} }
+9
View File
@@ -131,6 +131,15 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(shouldStartApp(args), true); assert.equal(shouldStartApp(args), true);
}); });
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', () => { test('parseArgs ignores non-positive numeric session action counts', () => {
const args = parseArgs(['--copy-subtitle-count=0', '--mine-sentence-count', '-1']); const args = parseArgs(['--copy-subtitle-count=0', '--mine-sentence-count', '-1']);
+14 -1
View File
@@ -43,6 +43,7 @@ export interface CliArgs {
playNextSubtitle: boolean; playNextSubtitle: boolean;
shiftSubDelayPrevLine: boolean; shiftSubDelayPrevLine: boolean;
shiftSubDelayNextLine: boolean; shiftSubDelayNextLine: boolean;
playbackFeedback?: string;
cycleRuntimeOptionId?: string; cycleRuntimeOptionId?: string;
cycleRuntimeOptionDirection?: 1 | -1; cycleRuntimeOptionDirection?: 1 | -1;
sessionAction?: SessionActionDispatchRequest; sessionAction?: SessionActionDispatchRequest;
@@ -150,6 +151,7 @@ export function parseArgs(argv: string[]): CliArgs {
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false, shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false, shiftSubDelayNextLine: false,
playbackFeedback: undefined,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,
@@ -296,7 +298,13 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--play-next-subtitle') args.playNextSubtitle = 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-prev-line') args.shiftSubDelayPrevLine = true;
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = 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]); const parsed = parseCycleRuntimeOption(arg.split('=', 2)[1]);
if (parsed) { if (parsed) {
args.cycleRuntimeOptionId = parsed.id; args.cycleRuntimeOptionId = parsed.id;
@@ -556,6 +564,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.playNextSubtitle || args.playNextSubtitle ||
args.shiftSubDelayPrevLine || args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine || args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined || args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined || args.copySubtitleCount !== undefined ||
@@ -631,6 +640,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.playNextSubtitle && !args.playNextSubtitle &&
!args.shiftSubDelayPrevLine && !args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine && !args.shiftSubDelayNextLine &&
args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined && args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined && args.sessionAction === undefined &&
args.copySubtitleCount === undefined && args.copySubtitleCount === undefined &&
@@ -697,6 +707,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.playNextSubtitle || args.playNextSubtitle ||
args.shiftSubDelayPrevLine || args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine || args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined || args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined || args.copySubtitleCount !== undefined ||
@@ -757,6 +768,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.playNextSubtitle && !args.playNextSubtitle &&
!args.shiftSubDelayPrevLine && !args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine && !args.shiftSubDelayNextLine &&
args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined && args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined && args.sessionAction === undefined &&
args.copySubtitleCount === undefined && args.copySubtitleCount === undefined &&
@@ -822,6 +834,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.playNextSubtitle || args.playNextSubtitle ||
args.shiftSubDelayPrevLine || args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine || args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined || args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined || args.copySubtitleCount !== undefined ||
+67 -3
View File
@@ -98,6 +98,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A'); assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D'); assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D');
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash'); assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
assert.equal(config.shortcuts.toggleNotificationHistory, 'CommandOrControl+N');
assert.equal(config.discordPresence.enabled, true); assert.equal(config.discordPresence.enabled, true);
assert.equal(config.discordPresence.updateIntervalMs, 3_000); assert.equal(config.discordPresence.updateIntervalMs, 3_000);
assert.equal(config.subtitleStyle.backgroundColor, 'transparent'); assert.equal(config.subtitleStyle.backgroundColor, 'transparent');
@@ -172,7 +173,7 @@ test('parses updates config and warns on invalid values', () => {
"updates": { "updates": {
"enabled": false, "enabled": false,
"checkIntervalHours": 6, "checkIntervalHours": 6,
"notificationType": "both", "notificationType": "osd-system",
"channel": "prerelease" "channel": "prerelease"
} }
}`, }`,
@@ -182,7 +183,7 @@ test('parses updates config and warns on invalid values', () => {
const validService = new ConfigService(validDir); const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().updates.enabled, false); assert.equal(validService.getConfig().updates.enabled, false);
assert.equal(validService.getConfig().updates.checkIntervalHours, 6); 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'); assert.equal(validService.getConfig().updates.channel, 'prerelease');
const invalidDir = makeTempDir(); 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')); 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', () => { test('throws actionable startup parse error for malformed config at construction time', () => {
const dir = makeTempDir(); const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc'); const configPath = path.join(dir, 'config.jsonc');
@@ -2750,7 +2814,7 @@ test('template generator includes known keys', () => {
); );
assert.match( assert.match(
output, output,
/"notificationType": "system",? \/\/ How SubMiner announces available updates\. Values: system \| osd \| both \| none/, /"notificationType": "system",? \/\/ How SubMiner announces available updates\..*Values: overlay \| system \| both \| none \| osd \| osd-system/,
); );
assert.match( assert.match(
output, output,

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