Compare commits

..
25 Commits
Author SHA1 Message Date
sudacode fa3051f9cb feat(subtitles): add regex subtitle filters with built-in presets (#318)
- Add `subtitleFilter` config with four presets: parentheses, brackets, speaker prefix and music symbols. Custom regex rules run after the presets.
- Filter primary text where it enters the app (live `sub-text` and parsed cues), so the overlay, sidebar, mining and stats all get the same filtered text.
- Changes hot-reload: the current line is read again and the active track is parsed again, using the filter config as the prefetch parse key.
- Add a custom rule editor in Settings under Behavior → Subtitle Filters that checks patterns as you type.
- Update the config example, the configuration docs and the architecture domains doc, and add a changelog fragment.
2026-11-03 00:33:58 -08:00
sudacode 848a6c4021 fix(plugin): keep loading OSD off on warm playlist advance (#317)
* fix(plugin): keep loading OSD off on warm playlist advance

- Track overlay_content_ready_seen when the app sends subminer-overlay-loading-ready
- Skip restarting the "Overlay loading" spinner for an overlay that already reported ready, so it no longer times out into "Overlay did not become ready" (#315)
- Reset the flag on stop, restart, start failure and shutdown
- Add start-gate tests for warm and not-yet-ready playlist advances

* fix(plugin): only report overlay ready once content has loaded

Hide paths dismissed the loading OSD with subminer-overlay-loading-ready,
which the plugin now remembers to skip the spinner on later playlist files.
A hide before the overlay had content would wrongly mark it ready. The app
now sends ready only when the overlay window content is ready, and a new
stop-only subminer-overlay-loading-dismissed message otherwise.

* fix(plugin): resync overlay readiness on dismissal and mpv connect

A dismissal now clears the plugin's readiness flag, since the app sends it
only while its overlay window has no content (e.g. a replaced window).
On mpv (re)connect the app resends subminer-overlay-loading-ready when the
overlay already has content, so a ready signal lost while mpv was away, or
sent before this mpv's plugin existed, still reaches the plugin.

* refactor(main): pass overlay readiness handler directly
2026-11-02 21:47:32 -08:00
YashaNoosandClaude Opus 5.5 b99409bbcd Controller: subtitle line actions, Button Style, keyboard-only mode pause, Alt+C fixes (#316)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-02 21:41:46 -08:00
YashaNoosandClaude Opus 5.5 6d0f37ac21 Controller: layouts for the media timing review and duplicate card dialogs (#313)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 01:38:26 -08:00
sudacode 696462a82e refactor(main): move IPC, CLI and app-ready wiring into assemblies (#311) 2026-11-01 17:59:52 -08:00
YashaNoosandClaude Opus 5.5 655510cf2e Controller: hold to show the secondary subtitle, dictionary entry navigation, standard button names (#312)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:54:47 -08:00
sudacode af44735bb8 refactor(main): move core layers into assemblies (#310)
* refactor(main): move first-run setup into an assembly

* refactor(main): move playback and subtitle pipeline state into an assembly

Moves the mpv runtime composer wiring together with the subtitle processing, prefetch, sidebar playback-state, seek-intent, secondary-subtitle and autoplay-priming state it mutates into createPlaybackAssembly. Replaces the main-wiring regex tests for the moved code with behaviour tests that drive real mpv events through a fake client.

* refactor(main): move overlay windows and visibility into an assembly

* refactor(main): read Electron screen lazily in the overlay assembly deps

* docs: describe overlay, playback, and first-run setup assemblies

* test(playback): clear scheduled prefetch refresh in warm-tokenization tests
2026-10-31 22:04:42 -07:00
sudacode 804fb8933e chore: remove Claude Code workflow and CLAUDE.md symlink
- Delete .github/workflows/claude.yml (@claude mention action)
- Delete CLAUDE.md symlink to AGENTS.md
2026-10-31 21:00:43 -07:00
sudacode 2924f04316 refactor(main): move domains into per-domain assemblies (#309)
* refactor(main): move the YouTube domain into an assembly

createYoutubeAssembly owns the YouTube flow, playback, media cache,
whisper subtitles, primary-subtitle notifications, and the in-app
browser, plus the browser quit-on-close state. main.ts builds it once
where the first YouTube runtime used to be and calls through the result.
Siblings built later are passed as getters or callbacks; tests pin that
construction calls none of them.

* docs(architecture): describe per-domain assemblies

* test(e2e): blank the line before mining the sub-delay cue

The previous test leaves the same cue on screen, so the overlay check
passed before the seek landed and --mine-sentence could read mpv's empty
mid-seek sub-text and skip the card.

* refactor(main): move the Jellyfin domain into an assembly

createJellyfinAssembly owns the Jellyfin composer call, its playback and
remote-progress state, the subtitle cache IO, mpv defaults, and the tray
discovery deps. main.ts builds it where the composer call used to be and
reads state through getActivePlayback/getQuitOnDisconnectArmed.

* refactor(main): move the AniList domain into an assembly

createAnilistAssembly owns the AniList setup and tracking composer calls,
the setup window, the state runtime, and the token, media-guess, in-flight
and attempted-update state they share. main.ts builds it where the setup
composer call used to be and keeps the rate limiter, which the stats
cover-art fetcher also uses.

* refactor(main): move config hot-reload, settings, and sync UI into assemblies

createSettingsAssembly owns config hot-reload and the settings window,
which share the hot-reload-applied handler; main.ts still supplies that
handler's cross-domain deps and the Yomitan deck-name helper.
createSyncAssembly owns the sync window, sync IPC runtime, and auto-sync
scheduler. Both are built where their blocks were; main.ts keeps the
registerHandlers()/start() calls at the same points.

* refactor(main): move dictionary runtimes into assemblies

createDictionariesAssembly owns the JLPT and frequency lookups and their
dictionary-root builders; main.ts still resolves the root paths at the
same point. createCharacterDictionaryAssembly owns the character
dictionary runtime, Yomitan auto-sync, and the portrait and name-candidate
lookups. Both are built where their blocks were; later siblings (Yomitan
profile policy, notifications, OSD, subtitle refresh) come in as getters
or callbacks.

* refactor(main): move overlay modal openers into an assembly

createOverlayModalsAssembly owns the shared open-with-notification helper
and the runtime options, Jimaku, TsukiHime, subtitle generation, session
help, changelog, character dictionary manager, controller, and playlist
browser openers. main.ts builds it where the openers used to live.

* refactor(logger): export the Logger type for assembly deps

The build emits declarations, and the Jellyfin assembly returns the logger
in its tray discovery deps, so tsc needs a nameable Logger type. Assemblies
now Pick from it directly.
2026-10-31 19:39:27 -07:00
sudacode a9a4c4f083 feat(plugins): add JavaScript plugin system with overlay panel (#303) 2026-10-31 19:09:54 -07:00
sudacode 90fc7cf940 refactor(main): fold remaining main-deps builders into their handlers (#308)
* refactor(main): drop remaining pass-through main-deps builders

Jellyfin client-info/setup-window/remote, overlay shortcuts/visibility/
layout/window-factory, config hot-reload, and startup bootstrap factory
builders only forwarded deps. Callers now pass deps to the handlers
directly; createWatchConfigPathHandler moves into config-hot-reload-handlers.

* refactor(main): fold small-logic main-deps builders into their handlers

Tray click, mpv client factory, startup bootstrap exit callbacks, overlay
runtime options, and dictionary roots/runtime builders move their logic
into the owning handler or call site. The two duplicated dictionary-root
builders become one createBuildDictionaryRootsHandler(vendorDirName).

* refactor(main): fold cli command context builders into the factory

createCliCommandContextFactory now adapts appState and main services to
the CLI command context deps directly (socket-path change notification,
texthooker port/websocket URL, browser-open default). The pass-through
cli-command-context-main-deps and cli-command-context-deps layers are
removed; their behavioural tests now run against the factory's context.

* refactor(main): fold subtitle tokenization builder into the mpv composer

The tokenizer deps builder's logic (known-word lookup recording and the
character-dictionary gate on name matching) becomes
buildTokenizerRuntimeOptions in mpv-runtime-composer, tested through the
composer. The MeCab check and dictionary prewarm handlers move to
subtitle-tokenization-warmup.ts, and the unused BuiltMainDeps type is
removed from composer contracts.

* refactor(main): drop redundant tokenizer deps keys and cast

* refactor(main): end the main-deps builder pattern

mpv-main-event-main-deps becomes mpv-main-event-binding-deps
(createMpvMainEventBindingDeps), returning the binding deps directly
instead of a thunk; it owns the subtitle-recording state. The will-quit
cleanup adapter likewise returns its deps directly, and the pass-through
app-lifecycle activate builders are removed.

* docs: drop references to main-deps builders
2026-10-31 14:07:07 -07:00
sudacode 2a0e931325 refactor(main): drop pass-through main-deps builders (#306)
* refactor(main): drop mining/anki main-deps builders

* refactor(anilist): drop main-deps builders

* refactor(jellyfin): drop main-deps builders

* refactor(overlay): drop overlay and shortcut main-deps builders

* refactor(main): drop ipc, tray, and osd main-deps builders

* refactor(startup): drop startup main-deps builders

* test(scripts): ignore stray local requests in AUR download test

Also apply prettier to launcher/mpv.test.ts so format:check:src passes.

* fix(shortcuts): forward overlay numeric selection hooks through the composer

composeShortcutRuntimes only passed the digit callbacks, so the visible-overlay
selection added in #78 never ran from the multi-copy/mine-sentence shortcuts.

* fix(stats): skip mpv auto-connect when stats starts the immersion tracker

The immersion-startup main-deps builder never forwarded shouldAutoConnectMpv,
so background stats and stats CLI startup always connected to mpv. Drop the
builder and pass the deps straight to the handler.

* build(deps): bump follow-redirects override to 1.16.2

Resolves GHSA-mrwp-f3q8-3g9p (high), which fails the quality-gate
bun audit --audit-level high step.

* test(subtitles): cover the null-tokenization settle path in prefetch test

The mock ignored the value the test resolved, so the null case never ran.

* test(e2e): surface mpv crashes and install current mpv on macOS CI

The harness IPC client never handled socket close, so a crashed mpv left
commands pending until the 120s test timeout with an empty mpv.log. Reject
pending commands on close, write mpv's own log via --log-file, and refresh
Homebrew on macOS CI so the installed mpv build no longer depends on the
runner image.

* test(e2e): render mpv through cocoa-cb OpenGL on macOS

mpv 0.41.0_12 with libplacebo 7.387.0 segfaults in gpu-next's MoltenVK path
on GitHub's paravirtual macOS GPU on the first frame, so the E2E session
never started.

* test(e2e): name mpv-stdio.log in the mpv close diagnostic
2026-10-31 00:50:33 -07:00
sudacode 0129e03c0f fix(anki): default fields.audio to SentenceAudio
- Generated sentence audio no longer overwrites Yomitan's ExpressionAudio on default configs
- fields.wordAudio still defaults to ExpressionAudio
- Regenerate config.example.jsonc and update the Anki and configuration docs
- Add changelog fragment
2026-10-28 22:47:54 -07:00
sudacode 586d7950cf fix(appimage): check mount exe links one at a time for uutils
- uutils readlink (Ubuntu 26.04+) aborts at the first unreadable /proc/<pid>/exe, so the keepalive released the mount early
- Add mount_busy helper that readlinks each link individually
- Add changelog fragment
2026-10-28 19:54:04 -07:00
sudacode 33dceabc3e test(appimage): keep straggler named sleep for multicall coreutils
- Name the copied straggler binary `sleep` so uutils/busybox multicall builds dispatch on argv[0] instead of exiting immediately
- Stop waiting on the straggler exec if it dies early, so the drain assertion reports the failure instead of hanging
2026-10-28 19:04:52 -07:00
sudacode ad1abc3268 test(appimage): wait for straggler exec before AppRun exits
- Fixes a flaky keepalive test where, under load, the supervisor could scan /proc while the forked child was still /bin/sh and release the mount too early
- AppRun now polls /proc/$!/exe until it resolves to the straggler binary
2026-10-28 18:27:40 -07:00
e59b78dca0 fix(anilist): add missing list entry on confident match (#305)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Ivan Melck <ivan@spec-kitty.ai>
2026-10-28 17:20:03 -07:00
sudacode 203a61a20f chore(release): v0.21.1 2026-10-26 23:27:24 -07:00
sudacode a681bbaf25 test(launcher): gate Linux AppImage env test with early return
Bun's node:test shim ignores the skip option, so the Linux-only test ran
and failed on macOS.
2026-10-26 23:27:24 -07:00
sudacode 6219a81144 feat(stats): redesign expanded session view with density chart (#304) 2026-10-26 23:08:48 -07:00
sudacode cc9af79d6e fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation (#302) 2026-10-26 22:19:08 -07:00
sudacode d3a94536a8 docs: link Hachidori project page from README and setup docs 2026-10-25 21:42:36 -07:00
sudacode 3068d02f89 docs(readme): refresh anime screenshots from current build
- Replace annotations, one-key-mining, and yomitan-lookup screenshots
- Update README alt text to match the new images
- Add docs changelog fragment
2026-10-25 19:32:25 -07:00
sudacode 9150a60630 fix(overlay): forward mpv input while subtitle sidebar is open (#300) 2026-10-24 23:28:58 -07:00
sudacode 3549e0ed93 chore(build): consolidate packaging inputs and build output layout (#299) 2026-10-24 23:28:38 -07:00
437 changed files with 26531 additions and 12161 deletions

No files matched your search

+6 -1
View File
@@ -56,7 +56,12 @@ jobs:
- name: Install mpv and ffmpeg (macOS)
if: runner.os == 'macOS'
run: brew install mpv ffmpeg
run: |
# Runner images bake in different Homebrew metadata, so without an update the
# installed mpv build depends on which image the job lands on.
brew update
brew install mpv ffmpeg
mpv --version
- name: Install mpv and ffmpeg (Windows)
if: runner.os == 'Windows'
-49
View File
@@ -1,49 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
+17 -17
View File
@@ -67,26 +67,26 @@ jobs:
- name: Build unversioned AppImage
run: |
shopt -s nullglob
appimages=(release/SubMiner-*.AppImage)
appimages=(dist/release/SubMiner-*.AppImage)
if [ "${#appimages[@]}" -eq 0 ]; then
echo "No versioned AppImage found to create unversioned artifact."
ls -la release
ls -la dist/release
exit 1
fi
cp "${appimages[0]}" release/SubMiner.AppImage
cp "${appimages[0]}" dist/release/SubMiner.AppImage
- name: Smoke packaged runtime assets
shell: bash
run: xvfb-run -a bun run test:package "release/linux-unpacked/resources"
run: xvfb-run -a bun run test:package "dist/release/linux-unpacked/resources"
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
with:
name: appimage
path: |
release/*.AppImage
release/latest*.yml
release/*.blockmap
dist/release/*.AppImage
dist/release/latest*.yml
dist/release/*.blockmap
if-no-files-found: error
build-macos:
@@ -159,17 +159,17 @@ jobs:
- name: Smoke packaged runtime assets
shell: bash
run: bun run test:package "release/mac-arm64/SubMiner.app/Contents/Resources"
run: bun run test:package "dist/release/mac-arm64/SubMiner.app/Contents/Resources"
- name: Upload macOS artifacts
uses: actions/upload-artifact@v4
with:
name: macos
path: |
release/*.dmg
release/*.zip
release/latest*.yml
release/*.blockmap
dist/release/*.dmg
dist/release/*.zip
dist/release/latest*.yml
dist/release/*.blockmap
if-no-files-found: error
build-windows:
@@ -224,15 +224,15 @@ jobs:
- name: Smoke packaged runtime assets
shell: bash
run: bun run test:package "release/win-unpacked/resources"
run: bun run test:package "dist/release/win-unpacked/resources"
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
with:
name: windows
path: |
release/*.exe
release/*.zip
release/latest*.yml
release/*.blockmap
dist/release/*.exe
dist/release/*.zip
dist/release/latest*.yml
dist/release/*.blockmap
if-no-files-found: error
+25 -23
View File
@@ -44,19 +44,19 @@ jobs:
uses: actions/download-artifact@v4
with:
name: appimage
path: release
path: dist/release
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: macos
path: release
path: dist/release
- name: Download Windows artifacts
uses: actions/download-artifact@v4
with:
name: windows
path: release
path: dist/release
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -90,13 +90,15 @@ jobs:
- name: Package optional assets bundle
run: |
tar -czf "release/subminer-assets.tar.gz" \
# Keep the archive layout stable for the AUR PKGBUILD.
tar -czf "dist/release/subminer-assets.tar.gz" \
--transform 's,^packaging/bun/licenses,resources/bun/licenses,' \
config.example.jsonc \
plugin/subminer \
plugin/subminer.conf \
assets/themes/subminer.rasi \
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
resources/bun/licenses
packaging/bun/licenses
- name: Package Bun corresponding source
run: bun scripts/package-bun-source.mjs
@@ -104,16 +106,16 @@ jobs:
- name: Generate checksums
run: |
shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
files=(dist/release/*.AppImage dist/release/*.dmg dist/release/*.exe dist/release/*.zip dist/release/*.tar.gz dist/release/latest*.yml dist/release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation."
exit 1
fi
: > release/SHA256SUMS.txt
: > dist/release/SHA256SUMS.txt
for file in "${files[@]}"; do
printf '%s %s\n' \
"$(sha256sum "$file" | awk '{print $1}')" \
"${file##*/}" >> release/SHA256SUMS.txt
"${file##*/}" >> dist/release/SHA256SUMS.txt
done
- name: Get version from tag
@@ -124,12 +126,12 @@ jobs:
env:
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: |
if [ ! -s release/prerelease-notes.md ]; then
echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
if [ ! -s packaging/release-notes/prerelease-notes.md ]; then
echo "::error::packaging/release-notes/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
exit 1
fi
if ! bun run changelog:check-prerelease-notes --version "$RELEASE_VERSION"; then
echo "::error::release/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag."
echo "::error::packaging/release-notes/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag."
exit 1
fi
@@ -142,15 +144,15 @@ jobs:
shopt -s nullglob
artifacts=(
release/*.AppImage
release/*.dmg
release/*.exe
release/*.zip
release/*.tar.gz
release/*.tar.gz.sha256
release/latest*.yml
release/*.blockmap
release/SHA256SUMS.txt
dist/release/*.AppImage
dist/release/*.dmg
dist/release/*.exe
dist/release/*.zip
dist/release/*.tar.gz
dist/release/*.tar.gz.sha256
dist/release/latest*.yml
dist/release/*.blockmap
dist/release/SHA256SUMS.txt
dist/launcher/subminer
dist/launcher/subminer.cmd
)
@@ -165,14 +167,14 @@ jobs:
--draft \
--prerelease \
--title "$RELEASE_VERSION" \
--notes-file release/prerelease-notes.md
--notes-file packaging/release-notes/prerelease-notes.md
else
gh release create "$RELEASE_VERSION" \
--draft \
--latest=false \
--prerelease \
--title "$RELEASE_VERSION" \
--notes-file release/prerelease-notes.md
--notes-file packaging/release-notes/prerelease-notes.md
fi
for asset in "${artifacts[@]}"; do
@@ -183,4 +185,4 @@ jobs:
--draft=false \
--prerelease \
--title "$RELEASE_VERSION" \
--notes-file release/prerelease-notes.md
--notes-file packaging/release-notes/prerelease-notes.md
+21 -19
View File
@@ -45,19 +45,19 @@ jobs:
uses: actions/download-artifact@v4
with:
name: appimage
path: release
path: dist/release
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: macos
path: release
path: dist/release
- name: Download Windows artifacts
uses: actions/download-artifact@v4
with:
name: windows
path: release
path: dist/release
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -91,13 +91,15 @@ jobs:
- name: Package optional assets bundle
run: |
tar -czf "release/subminer-assets.tar.gz" \
# Keep the archive layout stable for the AUR PKGBUILD.
tar -czf "dist/release/subminer-assets.tar.gz" \
--transform 's,^packaging/bun/licenses,resources/bun/licenses,' \
config.example.jsonc \
plugin/subminer \
plugin/subminer.conf \
assets/themes/subminer.rasi \
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
resources/bun/licenses
packaging/bun/licenses
- name: Package Bun corresponding source
run: bun scripts/package-bun-source.mjs
@@ -105,16 +107,16 @@ jobs:
- name: Generate checksums
run: |
shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
files=(dist/release/*.AppImage dist/release/*.dmg dist/release/*.exe dist/release/*.zip dist/release/*.tar.gz dist/release/latest*.yml dist/release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation."
exit 1
fi
: > release/SHA256SUMS.txt
: > dist/release/SHA256SUMS.txt
for file in "${files[@]}"; do
printf '%s %s\n' \
"$(sha256sum "$file" | awk '{print $1}')" \
"${file##*/}" >> release/SHA256SUMS.txt
"${file##*/}" >> dist/release/SHA256SUMS.txt
done
- name: Get version from tag
@@ -152,24 +154,24 @@ jobs:
gh release edit "$RELEASE_VERSION" \
--draft=false \
--title "$RELEASE_VERSION" \
--notes-file release/release-notes.md
--notes-file packaging/release-notes/release-notes.md
else
gh release create "$RELEASE_VERSION" \
--title "$RELEASE_VERSION" \
--notes-file release/release-notes.md
--notes-file packaging/release-notes/release-notes.md
fi
shopt -s nullglob
artifacts=(
release/*.AppImage
release/*.dmg
release/*.exe
release/*.zip
release/*.tar.gz
release/*.tar.gz.sha256
release/latest*.yml
release/*.blockmap
release/SHA256SUMS.txt
dist/release/*.AppImage
dist/release/*.dmg
dist/release/*.exe
dist/release/*.zip
dist/release/*.tar.gz
dist/release/*.tar.gz.sha256
dist/release/latest*.yml
dist/release/*.blockmap
dist/release/SHA256SUMS.txt
dist/launcher/subminer
dist/launcher/subminer.cmd
)
+1 -7
View File
@@ -8,11 +8,6 @@ node_modules/
# Electron build output
out/
dist/
release/*
!release/
!release/release-notes.md
!release/prerelease-notes.md
build/yomitan/
coverage/
# Launcher build artifact (produced by make build-launcher)
@@ -20,8 +15,6 @@ coverage/
/main-entry.js
/main-entry.js.map
/build/hachidori/
# Logs
*.log
npm-debug.log*
@@ -43,6 +36,7 @@ environment.toml
.vitepress/dist/
docs/.vitepress/cache/
docs/.vitepress/dist/
# Local test media and scripts
tests/*
.worktrees/
.tmp/
+3 -1
View File
@@ -1,7 +1,9 @@
node_modules
dist
release
coverage
vendor
*.log
src/core/services/tokenizer/__fixtures__/golden/*.json
# Reviewed release text and vendored license texts; never reformat
packaging/release-notes
packaging/bun/licenses
-5
View File
@@ -1,5 +0,0 @@
{
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all"
}
+23
View File
@@ -1,5 +1,28 @@
# Changelog
## v0.21.1 (2026-10-26)
### Changed
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
- A reading density chart of known and unknown words replaces the cumulative chart.
- Yomitan lookups and mined cards now appear in their own lane below the chart.
### Fixed
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
### Docs
- **README**: Refreshed the anime screenshots using the current build.
<details>
<summary>Internal changes</summary>
### Internal
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
</details>
## v0.21.0 (2026-10-24)
### Added
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+8 -17
View File
@@ -21,11 +21,10 @@ MACOS_DATA_DIR ?= $(HOME)/Library/Application Support/SubMiner
MACOS_APP_DIR ?= $(HOME)/Applications
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
# If building from source, the AppImage will typically land in release/.
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
PRERELEASE_NOTES := release/prerelease-notes.md
# If building from source, the AppImage will typically land in dist/release/.
APPIMAGE_SRC = $(firstword $(wildcard dist/release/SubMiner-*.AppImage))
MACOS_APP_SRC = $(firstword $(wildcard dist/release/*.app dist/release/*/*.app))
MACOS_ZIP_SRC = $(firstword $(wildcard dist/release/SubMiner-*.zip))
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
ifeq ($(OS),Windows_NT)
@@ -57,7 +56,7 @@ help:
" build-linux Build Linux AppImage" \
" build-macos Build macOS DMG/ZIP (signed if configured)" \
" build-macos-unsigned Build macOS DMG/ZIP without signing/notarization" \
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \
" clean Remove build artifacts (dist/, AppImage, binary)" \
" dev-start Build and launch local Electron app" \
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \
" dev-watch Start fast watch loop (tsc + renderer + Electron dev app)" \
@@ -165,15 +164,7 @@ build-launcher:
clean:
@printf '%s\n' "[INFO] Removing build artifacts"
@if [ -f "$(PRERELEASE_NOTES)" ]; then \
PRERELEASE_NOTES_BACKUP="$$(mktemp -t subminer-prerelease-notes.XXXXXX)" && \
cp "$(PRERELEASE_NOTES)" "$$PRERELEASE_NOTES_BACKUP" && \
rm -rf dist release && \
install -d release && \
mv "$$PRERELEASE_NOTES_BACKUP" "$(PRERELEASE_NOTES)"; \
else \
rm -rf dist release; \
fi
@rm -rf dist
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
generate-config: ensure-bun
@@ -229,7 +220,7 @@ install-linux: build-launcher
@if [ -n "$(APPIMAGE_SRC)" ]; then \
install -m 0755 "$(APPIMAGE_SRC)" "$(BINDIR)/SubMiner.AppImage"; \
else \
printf '%s\n' "[WARN] No release/SubMiner-*.AppImage found; skipping AppImage install"; \
printf '%s\n' "[WARN] No dist/release/SubMiner-*.AppImage found; skipping AppImage install"; \
printf '%s\n' " Build one with: make build"; \
fi
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
@@ -252,7 +243,7 @@ install-macos: build-launcher
ditto -x -k "$(MACOS_ZIP_SRC)" "$(MACOS_APP_DIR)"; \
printf '%s\n' "[INFO] Installed app bundle from $(MACOS_ZIP_SRC)"; \
else \
printf '%s\n' "[WARN] No macOS app bundle or zip found in release/; skipping app install"; \
printf '%s\n' "[WARN] No macOS app bundle or zip found in dist/release/; skipping app install"; \
printf '%s\n' " Build one with: make build"; \
fi
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
+8 -4
View File
@@ -25,7 +25,7 @@ Look up words with Yomitan or Hachidori, mine them to Anki, and track your immer
Hover over any word in the subtitles to open the full Yomitan popup with definitions, pitch accent, and frequency data. SubMiner bundles its own Yomitan, separate from any browser install.
Yomitan remains the default. Select the bundled Hachidori backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
Yomitan remains the default. Select the bundled [Hachidori](https://github.com/bee-san/hachidori) backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
<div align="center">
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
@@ -38,7 +38,7 @@ Yomitan remains the default. Select the bundled Hachidori backend with `dictiona
Create an Anki card from the exact playback moment with one key press, click, or controller input. SubMiner fills in the sentence, an audio clip, and a screenshot or animated image.
<div align="center">
<img src="docs-site/public/screenshots/one-key-mining.png" width="800" alt="Anki card created from SubMiner with sentence, audio, and screenshot">
<img src="docs-site/public/screenshots/one-key-mining.png" width="800" alt="Yomitan lookup over an anime scene with Anki mining controls">
</div>
<br>
@@ -48,7 +48,7 @@ Create an Anki card from the exact playback moment with one key press, click, or
Subtitles are annotated as they play with frequency highlighting, JLPT tags, N+1 targeting, and character names from a generated dictionary. Particles and grammar-only tokens stay plain so the words worth learning stand out.
<div align="center">
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency coloring, JLPT underlines, and N+1 targets">
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency colors, character portraits, and JLPT underlines">
</div>
<br>
@@ -102,6 +102,10 @@ A stats dashboard tracks watch time, vocabulary growth, mining throughput, sessi
<td><b>WebSocket</b></td>
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
</tr>
<tr>
<td><b>Plugins</b></td>
<td>Load your own JavaScript at startup to react to playback, run timers, add key-bound actions, and show results in an overlay panel. The bundled example explains the current line with Codex. <a href="https://docs.subminer.moe/plugins">Plugin guide</a></td>
</tr>
</table>
<br>
@@ -267,7 +271,7 @@ SubMiner builds on the work of these open-source projects:
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
+134
View File
@@ -0,0 +1,134 @@
// Sends the current subtitle line, with a few lines of context, to `codex exec` and shows the
// answer in the SubMiner plugin panel. Registers two actions: `explain` and `translate`.
const { mkdtemp, readFile, rm } = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const DEFAULT_SETTINGS = {
codexPath: 'codex',
model: '',
// Passed as -c model_reasoning_effort; low keeps answers to a few seconds. Empty uses your Codex config.
reasoningEffort: 'low',
before: 2,
after: 1,
timeoutMs: 120_000,
pause: true,
prompts: {},
};
const ACTIONS = {
explain: {
title: 'Grammar',
prompt:
'You are a Japanese tutor. Explain the line marked >>> for an intermediate learner. ' +
'Use the other lines only as context. Reply in English markdown: a natural translation, ' +
'then a bullet list of the grammar points and notable vocabulary. Be concise.',
},
translate: {
title: 'Translation',
prompt:
'Translate the line marked >>> into natural English. Use the other lines only as context. ' +
'Reply with the translation, then one short note only if the line is ambiguous.',
},
};
function activate(ctx) {
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
for (const [name, action] of Object.entries(ACTIONS)) {
const prompt = settings.prompts?.[name] ?? action.prompt;
ctx.actions.register(name, () => ask(ctx, settings, action.title, prompt));
}
}
function quote(text) {
return text
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
}
// Subtitles wrap across lines; flatten each cue so the >>> marker covers all of it.
function oneLine(text) {
return text
.split('\n')
.map((part) => part.trim())
.filter(Boolean)
.join(' ');
}
function tail(text, lineCount) {
return text.trim().split('\n').slice(-lineCount).join('\n');
}
async function ask(ctx, settings, title, instructions) {
const { before, current, after } = ctx.subtitles.context({
before: settings.before,
after: settings.after,
});
if (!current) {
ctx.notify('No subtitle line to send.', { variant: 'warning' });
return;
}
if (settings.pause) ctx.mpv.command(['set', 'pause', 'yes']);
const header = quote(current.text);
const panel = ctx.ui.panel({ title, markdown: `${header}\n\n_Asking Codex..._` });
const prompt = [
instructions,
'',
'Subtitles:',
...before.map((line) => oneLine(line.text)),
`>>> ${oneLine(current.text)}`,
...after.map((line) => oneLine(line.text)),
].join('\n');
// Codex runs in an empty read-only directory so any command it tries cannot touch real files.
const workDir = await mkdtemp(path.join(os.tmpdir(), 'subminer-ai-explain-'));
const answerPath = path.join(workDir, 'answer.md');
const args = [
'exec',
'--ephemeral',
'--skip-git-repo-check',
'--sandbox',
'read-only',
'--color',
'never',
'-C',
workDir,
'-o',
answerPath,
];
if (settings.model) args.push('-m', settings.model);
if (settings.reasoningEffort) {
args.push('-c', `model_reasoning_effort=${JSON.stringify(settings.reasoningEffort)}`);
}
args.push('-');
try {
const result = await ctx.exec(settings.codexPath, args, {
stdin: prompt,
timeoutMs: settings.timeoutMs,
signal: panel.signal,
});
if (result.exitCode !== 0) {
throw new Error(tail(result.stderr, 6) || `codex exited with code ${result.exitCode}`);
}
const answer = (await readFile(answerPath, 'utf8')).trim();
panel.update({ markdown: `${header}\n\n${answer || '_Codex returned an empty answer._'}` });
} catch (error) {
// Closing the panel aborts the request; there is nobody left to show the error to.
if (panel.signal.aborted) return;
const message =
error?.code === 'ENOENT'
? `Cannot find \`${settings.codexPath}\`. Set \`plugins.ai-explain.settings.codexPath\` to its full path.`
: String(error?.message ?? error);
ctx.log.warn(`Codex request failed: ${message}`);
panel.update({
markdown: `${header}\n\n**Codex request failed.**\n\n\`\`\`\n${message}\n\`\`\``,
});
} finally {
await rm(workDir, { recursive: true, force: true });
}
}
module.exports = { activate };
+6
View File
@@ -0,0 +1,6 @@
{
"name": "AI Explain",
"version": "1.0.0",
"apiVersion": 1,
"main": "main.js"
}
+61
View File
@@ -0,0 +1,61 @@
// Runs AnkiConnect's `sync` (AnkiWeb) after you mine cards. A burst of mining becomes one sync:
// it waits until no card has been mined for `delayMs`, and syncs right away when playback ends.
// Also registers a `sync` action for a manual sync.
const DEFAULT_SETTINGS = {
delayMs: 30_000,
syncOnEnd: true,
timeoutMs: 60_000,
notify: true,
};
const END_REASONS = new Set(['eof', 'stop', 'quit']);
function activate(ctx) {
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
// Cards mined since the last sync started.
let dirty = false;
let syncing = false;
let cancelTimer = null;
function schedule() {
cancelTimer?.();
cancelTimer = ctx.after(settings.delayMs, flush);
}
async function flush() {
cancelTimer?.();
cancelTimer = null;
// A sync already running picks up new cards when it finishes.
if (!dirty || syncing) return;
dirty = false;
syncing = true;
try {
await ctx.anki.invoke('sync', {}, { timeoutMs: settings.timeoutMs });
ctx.log.info('Synced Anki.');
if (settings.notify) ctx.notify('Anki synced');
} catch (error) {
ctx.log.warn(`Anki sync failed: ${error.message}`);
ctx.notify(`Anki sync failed: ${error.message}`, { variant: 'error' });
} finally {
syncing = false;
if (dirty) schedule();
}
}
ctx.anki.on('card-mined', () => {
dirty = true;
schedule();
});
ctx.mpv.on('end-file', ({ reason }) => {
if (settings.syncOnEnd && END_REASONS.has(reason)) return flush();
});
ctx.actions.register('sync', () => {
dirty = true;
return flush();
});
}
module.exports = { activate };
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Anki Sync",
"version": "1.0.0",
"apiVersion": 1,
"main": "main.js"
}
+51
View File
@@ -0,0 +1,51 @@
// Opens the Jimaku subtitle search when a file starts without a Japanese subtitle track.
// It waits a few seconds after the file loads so external subtitles mpv or SubMiner add count.
const DEFAULT_SETTINGS = {
// Built-in action to run, e.g. `openTsukihime` or `openSubtitleSelection`.
action: 'openJimaku',
// Track `lang` codes that count as Japanese. Track titles with "Japanese" or 日本語 count too.
languages: ['ja', 'jp', 'jpn'],
delayMs: 3_000,
// Also check http(s) streams; Jimaku can only search by name, which streams rarely have.
streams: false,
};
const JAPANESE_TITLE = /japanese|日本語/i;
function activate(ctx) {
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
const languages = new Set(settings.languages.map((lang) => lang.toLowerCase()));
let cancelCheck = null;
let playbackId = null;
function hasJapaneseSubtitle(trackList) {
return trackList.some(
(track) =>
track?.type === 'sub' &&
(languages.has(String(track.lang ?? '').toLowerCase()) ||
JAPANESE_TITLE.test(String(track.title ?? ''))),
);
}
async function check(media) {
cancelCheck = null;
const trackList = await ctx.mpv.getProperty('track-list');
if (ctx.media.current()?.playbackId !== media.playbackId || !Array.isArray(trackList)) return;
if (hasJapaneseSubtitle(trackList)) return;
ctx.log.info(`No Japanese subtitles for ${media.path}; running ${settings.action}.`);
await ctx.actions.run(settings.action);
}
ctx.media.on('change', (media) => {
if (media?.playbackId === playbackId) return;
playbackId = media?.playbackId ?? null;
cancelCheck?.();
cancelCheck = null;
if (!media) return;
if (!settings.streams && /^[a-z]+:\/\//i.test(media.path)) return;
cancelCheck = ctx.after(settings.delayMs, () => check(media));
});
}
module.exports = { activate };
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Auto Jimaku",
"version": "1.0.0",
"apiVersion": 1,
"main": "main.js"
}
+110
View File
@@ -0,0 +1,110 @@
// Tags each mined card with the show and episode it came from, e.g. `show::Frieren::S01E07`.
// The show and episode are parsed from the file name, or from mpv's media title for streams.
const path = require('node:path');
const DEFAULT_SETTINGS = {
// Placeholders: {show}, {season}, {episode}, and {ep} (S01E07, or E07 without a season).
// A tag stops before the first `::` level whose placeholder has no value.
tags: ['show::{show}::{ep}'],
notify: false,
};
// Tried in order; the first match wins. Runs on names with brackets and dots already removed.
const EPISODE_PATTERNS = [
/^(?<show>.*?)\s*S(?<season>\d{1,2})\s?E(?<episode>\d{1,4})\b/i,
/^(?<show>.*?)\s*(?:Season\s?(?<season>\d{1,2})\s*)?(?:Episode|Ep)\s?(?<episode>\d{1,4})\b/i,
/^(?<show>.*?)\s+-\s+(?<episode>\d{1,4})(?:v\d)?\b/,
/^(?<show>.*?)\s+(?<episode>\d{1,4})(?:v\d)?$/,
/^(?<show>.*?)\s*第(?<episode>\d{1,4})話/,
];
// `[Group] Show Name - 07 (1080p).mkv` -> { show: 'Show Name', season: null, episode: '07' }
function parseMediaName(name) {
const cleaned = name
.replace(/\.[a-z0-9]{2,4}$/i, '')
.replace(/\[[^\]]*\]|\([^)]*\)|【[^】]*】/g, ' ')
.replace(/[._]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
for (const pattern of EPISODE_PATTERNS) {
const groups = cleaned.match(pattern)?.groups;
const show = groups?.show.replace(/[\s-]+$/, '');
if (!show) continue;
return { show, season: pad(groups.season), episode: pad(groups.episode) };
}
return { show: cleaned || null, season: null, episode: null };
}
function pad(number) {
return number ? String(Number(number)).padStart(2, '0') : null;
}
// Anki tags can't hold spaces, and a `::` in a value would add a hierarchy level.
function tagValue(value) {
return value.replace(/::?/g, ' ').trim().replace(/\s+/g, '_').replace(/"/g, '');
}
function renderTag(template, values) {
const levels = [];
for (const level of template.split('::')) {
let missing = false;
const rendered = level.replace(/\{(\w+)\}/g, (_, key) => {
const value = values[key];
if (!value) missing = true;
return value ? tagValue(value) : '';
});
if (missing) break;
levels.push(rendered);
}
return levels.join('::');
}
function mediaName(mediaPath, title) {
// A stream URL's last segment is usually an id, so streams use the title mpv shows.
if (typeof mediaPath === 'string' && mediaPath && !/^[a-z]+:\/\//i.test(mediaPath)) {
return path.basename(mediaPath);
}
return typeof title === 'string' && title ? title : null;
}
function renderTags(templates, name) {
const { show, season, episode } = parseMediaName(name);
const ep = episode ? `${season ? `S${season}` : ''}E${episode}` : null;
const values = { show, season, episode, ep };
return [...new Set(templates.map((template) => renderTag(template, values)))].filter(Boolean);
}
function activate(ctx) {
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
ctx.anki.on('card-mined', async ({ notes }) => {
// Notes from the same file share one addTags call.
const notesByName = new Map();
for (const { noteId, provenance } of notes) {
const name = mediaName(provenance?.media?.path, provenance?.media?.title);
if (!name) {
ctx.log.debug(`Note ${noteId} has no known media source; not tagging it.`);
continue;
}
if (!notesByName.has(name)) notesByName.set(name, new Set());
notesByName.get(name).add(noteId);
}
for (const [name, notes] of notesByName) {
const tags = renderTags(settings.tags, name);
if (tags.length === 0) {
ctx.log.debug(`No tags for "${name}".`);
continue;
}
try {
await ctx.anki.invoke('addTags', { notes: [...notes], tags: tags.join(' ') });
ctx.log.info(`Tagged ${notes.size} note(s): ${tags.join(' ')}`);
if (settings.notify) ctx.notify(`Tagged ${tags.join(' ')}`);
} catch (error) {
ctx.log.warn(`Tagging failed: ${error.message}`);
ctx.notify(`Episode tagging failed: ${error.message}`, { variant: 'error' });
}
}
});
}
module.exports = { activate };
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Episode Tags",
"version": "1.0.0",
"apiVersion": 1,
"main": "main.js"
}
+2 -2
View File
@@ -37,7 +37,7 @@
"brace-expansion": "5.0.11",
"electron-builder-squirrel-windows": "26.16.1",
"fast-uri": "3.1.7",
"follow-redirects": "1.16.1",
"follow-redirects": "1.16.2",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.2",
@@ -351,7 +351,7 @@
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"follow-redirects": ["follow-redirects@1.16.1", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="],
"follow-redirects": ["follow-redirects@1.16.2", "", {}, "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow=="],
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
+2 -2
View File
@@ -44,11 +44,11 @@ How fragments turn into a release:
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
- The polished `CHANGELOG.md` and `packaging/release-notes/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
Prerelease notes:
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `packaging/release-notes/prerelease-notes.md`
- from the second prerelease of a base version onward, the notes also open with a `## Changes since <previous tag>` section generated from the fragment diff against the previous beta/RC tag; keep fragment edits meaningful. Editorial-only rewording is filtered out of that section, while genuinely changed behavior and deleted fragments (reverted changes) are reported
- existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: overlay
- The controller settings window (`Alt+C`) no longer edits a controller it cannot see. A newly opened window cannot see any controller until you press one of its buttons (browsers hide a pad from a new window until then); it used to show and edit the global defaults meanwhile, and its status could stay stuck on "No controllers detected". Now it shows the bindings of the controller the overlay is using (or the saved one), labelled as a preview, and Learn, Clear, Reset and the button style are locked until the press.
- With several controllers connected, `Alt+C` now selects the one you just pressed, and Learn listens to that controller.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: anilist
- AniList progress sync now adds a show to your Watching list (or Completed on the final episode) when it is not on your list yet, instead of failing with "AniList update not possible". This only happens when the file title exactly matches an AniList title or the entry is pinned with the character dictionary override; loose matches still require adding the show yourself.
- Entries marked Completed, Paused, Dropped, or Rewatching are still never changed.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- `ankiConnect.fields.audio` now defaults to `SentenceAudio` instead of `ExpressionAudio`, so generated sentence audio no longer overwrites Yomitan's word audio on default configs. `fields.wordAudio` still defaults to `ExpressionAudio`. If you relied on the old default, set `fields.audio` to `ExpressionAudio` explicitly.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: linux
- Fixed AppImage builds on Ubuntu 26.04+ (uutils coreutils) releasing their mount before Chromium child processes finished shutting down, which brought back "Service Crash" notifications on quit.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Controller remaps made in `Alt+C` now apply right away; before, the overlay kept the old bindings until SubMiner restarted.
@@ -0,0 +1,4 @@
type: added
area: overlay
- `Alt+C` now warns when two controller actions share an input, as the dialog layouts already did. Both rows show "⚠ Same as …", including a button on the D-pad while a **(D-pad)** row under **Navigation** is still on, and a stick direction used by a stick action.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: overlay
- `Alt+C` has a **Button Style** choice (Xbox, Nintendo or PlayStation), saved per controller. PlayStation is new: buttons and the review and duplicate card shortcuts show ╳ ○ □ △. `controller.faceButtonLabels` accepts `playstation` and stays the default for controllers without their own style.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Clicking a row in `Alt+C` shows its **Learn**, **Clear** and **Reset** buttons again. The panel opened but was squeezed to nothing once the list was taller than the window, so **Clear** (set a binding to None) could not be reached.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: overlay
- The duplicate card (field grouping) dialog has a controller layout: the D-pad picks the original or new card, `A` continues and confirms the merge, `B` cancels or goes back from the preview, `X` turns "Delete duplicate" on or off, `Y` switches Compact / Full, and the D-pad scrolls the preview. Each control shows its shortcut, and the layout can be changed per controller in the new **Duplicate card (field grouping)** group of `Alt+C`.
@@ -0,0 +1,5 @@
type: added
area: overlay
- Added a controller layout for the media timing review dialog covering every button, with L1 / R1 + D-pad combos for the clip start and end, and D-pad actions that repeat while held. Each button shows its controller shortcut while controller support is on, and the first press in a newly opened dialog already counts. The controller's usual actions pause while the dialog is open, including in the hidden subtitle overlay behind it.
- Added `controller.faceButtonLabels` (`xbox` or `nintendo`) so fixed controller layouts and the `Alt+C` button names follow the letters printed on the pad. Use `nintendo` for controllers with A on the right and B at the bottom.
@@ -0,0 +1,4 @@
type: changed
area: overlay
- The controller's **Toggle MPV Pause** button now works without keyboard-only mode, so you can pause while just watching.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: overlay
- The media timing review controller layout can now be changed in the new **Review media timing** group of `Alt+C`. Learn records a single button or a combo (hold one button, press another), any button can be a hold button, changes are saved per controller, rows warn about clashing inputs, and **Reset all** restores the default layout. The review's button hints follow your bindings.
+5
View File
@@ -0,0 +1,5 @@
type: added
area: overlay
- Added a controller binding that shows the secondary subtitle only while a button is held (`controller.bindings.holdSecondarySub`). It is unbound by default; assign it with Learn in the `Alt+C` controller modal. It works with or without keyboard-only mode and does not change the saved secondary subtitle mode.
- Added controller bindings to select the previous or next word entry in the open Yomitan popup (`controller.bindings.previousEntry` / `nextEntry`). Mine Card mines the selected entry. Both are unbound by default; assign them with Learn in the `Alt+C` controller modal.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- The `Alt+C` controller screen now shows the right button names for controllers that report the W3C standard layout (most Xbox, PlayStation, Switch and 8BitDo pads). Previously Select and Start showed as `L3` and `R3`, and the triggers as `Back` and `Start`. Bindings themselves were unaffected.
@@ -0,0 +1,4 @@
type: added
area: overlay
- New controller actions for subtitle lines, in the **Subtitles** group of `Alt+C`: **Previous Subtitle**, **Next Subtitle**, **Replay Subtitle** and **Play Next Subtitle**, matching `Shift+H`, `Shift+L`, `Ctrl+Shift+H` and `Ctrl+Shift+L`. They are unbound by default and work with or without keyboard-only mode.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: overlay
- New **Pause Video In Keyboard-Only Mode** setting (`subtitleStyle.autoPauseVideoOnKeyboardMode`, off by default): turning keyboard-only mode on, from the keyboard or a controller, pauses the video, and turning it off resumes it. A video that was already paused, or that you played or paused yourself in the meantime, is left as it is.
@@ -0,0 +1,4 @@
type: changed
area: overlay
- Turning keyboard-only mode off, from the keyboard or a controller, now also closes the dictionary popup.
@@ -0,0 +1,4 @@
type: fixed
area: mpv-plugin
- Fixed the "Overlay loading" message staying on screen when mpv moves to the next file in a playlist, then turning into "Overlay did not become ready; check SubMiner logs" after 30 seconds even though the overlay was working. The overlay window is reused between files, so the plugin no longer restarts the loading message once the overlay has reported ready.
+10
View File
@@ -0,0 +1,10 @@
type: added
area: plugins
- Added JavaScript plugins loaded at startup from `<config dir>/plugins/<id>/` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:<id>/<action>` keybindings or run with `--plugin-action`, run built-in actions, react to mined Anki cards and call AnkiConnect, start mpv, run programs, keep state in a per-plugin data folder, and show results in an overlay panel or their own window.
- Added an example `ai-explain` plugin that sends the current line and its neighbors to `codex exec` and shows a translation or grammar explanation.
- Added an example `anki-sync` plugin that syncs Anki to AnkiWeb after you mine cards, once mining goes quiet or playback ends.
- Added an example `episode-tags` plugin that tags mined cards with the show and episode, such as `show::Frieren::S01E07`.
- Added an example `auto-jimaku` plugin that opens the Jimaku search when a file starts without a Japanese subtitle track.
- Added a versioned plugin API with standalone authoring types, cached playback snapshots, lifecycle cancellation and cleanup, and awaitable mpv commands. Async activation is bounded and actions wait only for their own plugin. Shutdown skips pending loads, runs each deactivation once, and blocks calls from disposed plugins.
- Mining events preserve original and kept note ids with the media and subtitle captured when mining starts, so delayed enrichment and merges into older notes keep the source episode. Stats-dashboard cards do not trigger plugin events. Plugin subtitle lookups and mining provenance account for subtitle delay and return timing on the subtitle file's timeline.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Moving between words with the keyboard or a controller right after closing the dictionary popup no longer reopens it. A popup that closed under the mouse pointer was still treated as open.
+5
View File
@@ -0,0 +1,5 @@
type: added
area: subtitles
- Added subtitle filters (`subtitleFilter`) that remove unwanted text from primary subtitles before it reaches the overlay, sidebar, mined sentences and stats. Built-in presets cover text in parentheses (speaker names, readings, notes; whole-line inner thoughts are kept), square-bracket sound descriptions, speaker names before a colon, and music notes. Custom regex rules with a replacement can be added after the presets. Lines left with no words are hidden.
- Configure them in the settings window under Behavior → Subtitle Filters; changes apply live to the current subtitle file.
+331 -2
View File
@@ -108,6 +108,7 @@
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
"faceButtonLabels": "xbox", // Face button style of your controller: xbox (A at the bottom), nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or playstation (╳ ○ □ △, laid out like xbox). Places the A/B/X/Y of the dialog layouts and names buttons in Alt+C and the dialog hints. Alt+C sets it per controller (controller.profiles.<id>.faceButtonLabels); this is the default for the others. Values: xbox | nintendo | playstation
"repeatDelayMs": 320, // Delay before repeating held controller actions.
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
"buttonIndices": {
@@ -158,7 +159,28 @@
"toggleMpvPause": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"holdSecondarySub": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"previousEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"nextEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"previousSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"nextSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"replaySubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"playNextSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action.
@@ -180,6 +202,288 @@
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
"reviewBindings": {
"confirm": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Use this timing.
"cancel": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Cancel.
"play": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Play selection.
"resetTiming": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Reset timing.
"showEarlier": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Earlier −2s. Repeats while held.
"showLater": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Later +2s. Repeats while held.
"framePrevious": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Previous screenshot frame. Repeats while held.
"frameNext": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Next screenshot frame. Repeats while held.
"frameReset": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Reset screenshot frame.
"startBack": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Start −0.1s. Repeats while held.
"startForward": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Start +0.1s. Repeats while held.
"previousLineAdd": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Add previous line. Repeats while held.
"previousLineRemove": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Remove previous line. Repeats while held.
"endBack": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: End −0.1s. Repeats while held.
"endForward": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: End +0.1s. Repeats while held.
"nextLineAdd": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Add next line. Repeats while held.
"nextLineRemove": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Remove next line. Repeats while held.
"useOriginal": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Use original timing (after cancel).
"keepEditing": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Keep editing (after cancel).
"skipMedia": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Without media (after cancel).
"discard": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
} // Review dialog: Discard card (after cancel).
}, // Controller layout for the media timing review dialog, one entry per action. Set it with the Review media timing group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.
"fieldGroupingBindings": {
"selectOriginal": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Original card.
"selectNew": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: New card.
"toggleDeleteDuplicate": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Delete duplicate on/off.
"continue": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Continue.
"cancel": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Cancel.
"confirmMerge": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Confirm merge (merge preview).
"back": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Back (merge preview).
"cancelMerge": {
"kind": "none" // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
}, // Duplicate card dialog: Cancel (merge preview).
"toggleView": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Compact / Full (merge preview).
"scrollUp": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Scroll up (merge preview). Repeats while held.
"scrollDown": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
} // Duplicate card dialog: Scroll down (merge preview). Repeats while held.
}, // Controller layout for the duplicate card (field grouping) dialog, one entry per action. Set it with the Duplicate card (field grouping) group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
@@ -218,6 +522,14 @@
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior.
// ==========================================
// Plugins
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
// Restart SubMiner after changing plugins.
// ==========================================
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
@@ -462,6 +774,7 @@
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"autoPauseVideoOnKeyboardMode": false, // Pause mpv when keyboard-only mode turns on (from the keyboard or a controller) and resume when it turns off. Leaving the mode resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, is left as it is. Values: true | false
"primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false
"nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
@@ -541,6 +854,22 @@
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// ==========================================
// Subtitle Filters
// Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
// Applies to the overlay, sidebar, mined sentences and stats.
// Hot-reload: filter changes apply to the current subtitle file immediately.
// ==========================================
"subtitleFilter": {
"presets": {
"parentheses": false, // Remove text in () and (), such as speaker names, readings and notes. A line wrapped entirely in parentheses, usually inner thoughts, is kept. Values: true | false
"brackets": false, // Remove text in [], [], 【】 and 〔〕, such as sound effects and music cues. Values: true | false
"speakerPrefix": false, // Remove a short name followed by a colon at the start of a line, as in 山田:元気? Values: true | false
"musicSymbols": false // Remove ♪, ♫ and ♬. A line left with only symbols such as ~ is removed. Values: true | false
}, // Built-in filters. Each removes its matches and is off by default.
"customRules": [] // Your own regex rules, applied in order after the presets. Each rule is { name, pattern, replacement, enabled }; patterns use the gmu flags, so ^ and $ match per line. Leave replacement empty to remove the match.
}, // Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
// ==========================================
// Shared AI Provider
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
@@ -578,7 +907,7 @@
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
"fields": {
"word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
"audio": "SentenceAudio", // Card field that receives generated sentence audio.
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
"image": "Picture", // Card field that receives the captured screenshot or animated image.
"sentence": "Sentence", // Card field that receives the source sentence text.
+1
View File
@@ -221,6 +221,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'AniList', link: '/anilist-integration' },
{ text: 'AniSkip', link: '/aniskip-integration' },
{ text: 'Character Dictionary', link: '/character-dictionary' },
{ text: 'Plugins', link: '/plugins' },
],
},
{
+2 -2
View File
@@ -29,7 +29,7 @@ An episode counts as watched after 85% of its length and at least 10 minutes of
2. Finds the matching AniList entry. For season 2 and later, it follows the show's sequels.
3. Sets your progress to that episode and marks the entry Watching, or Completed on the final episode.
The show must already be on your Planning or Watching list. SubMiner does not add new entries, and it never lowers your progress.
If the show is not on your list yet, SubMiner adds it as Watching, but only when the file title exactly matches an AniList title or you pinned the entry with the character dictionary's AniList override. For a looser match, add the show to Planning or Watching yourself. SubMiner never changes entries marked Completed, Paused, Dropped, or Rewatching, and it never lowers your progress.
Failed updates are saved and retried in the background, up to 8 times with growing delays. The queue survives restarts.
@@ -61,7 +61,7 @@ Character dictionary settings live under `anilist.characterDictionary` and are c
**No update after an episode.** Check that `anilist.enabled` is `true` and that you watched at least 85% of the episode.
**"AniList update not possible."** Add the show to your Planning or Watching list, then mark the episode watched again.
**"AniList update not possible."** The show is marked Completed, Paused, Dropped, or Rewatching, or it is not on your list and the title match was not exact. Add the show to your Planning or Watching list, or pin it with the character dictionary's AniList override, then mark the episode watched again.
**Wrong show or episode.** Install guessit and make sure it is on your `PATH`. Unusual file names parse poorly without it.
+44 -1
View File
@@ -103,7 +103,7 @@ Field names are matched case-insensitively. A mapped field that is missing from
Hachidori prepares downloadable word audio before it saves a note through the proxy, so the animated image delay works on the first mine. Set a downloadable pronunciation source in Hachidori's Audio settings. Browser speech cannot be saved to Anki. Without word audio, Hachidori shows a warning and the card gets no word-audio hold.
`fields.audio` gets sentence audio, not word audio. Yomitan writes its own dictionary audio into your note, so point `fields.audio` at a separate field such as `SentenceAudio`. The default, `ExpressionAudio`, is the field many note types use for Yomitan's word audio, so leaving it would overwrite that audio.
`fields.audio` gets sentence audio, not word audio. Yomitan writes its own dictionary audio into your note (often `ExpressionAudio`), so keep `fields.audio` pointed at a separate field such as the default `SentenceAudio`. Pointing it at Yomitan's word-audio field would overwrite that audio.
`ankiConnect.tags` adds tags to every mined or updated card. Set it to `[]` to add none.
@@ -160,6 +160,34 @@ The confirmed range is used as is, with no extra padding. Added lines go into th
When you cancel, you can go back to editing, keep the original timing, create the card without media, or discard it. Discard deletes the Yomitan note or audio card, and skips creation for a sentence card.
#### With a controller
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each button shows its controller shortcut. It works with or without keyboard-only mode, and the controller's usual actions pause until the dialog closes. Buttons that were already held when the dialog opened, such as the mine button, wait for a fresh press.
`A`, `B`, `X` and `Y` mean the letters printed on your controller. Pick your controller's **Button Style** in `Alt+C`: choose Nintendo if A is on the right and B at the bottom (Switch-style and many 8BitDo pads). On PlayStation, `A` is ╳, `B` ○, `X` □ and `Y` △, and the shortcuts on screen show those symbols.
Hold `L1` or `R1` and press the D-pad for the combos. `L1` covers the clip start and the lines before it, and `R1` the clip end and the lines after it. D-pad actions repeat while held.
This is the default layout:
| Controller | Editing | After Cancel |
| ----------------------- | -------------------------------- | ------------------- |
| `A` | Use this timing | Use original timing |
| `B` | Cancel | Keep editing |
| `X` | Play selection | Without media |
| `Y` | Reset timing | Discard |
| D-pad left / right | Earlier −2s / Later +2s | |
| D-pad up / down | Previous / next screenshot frame | |
| `L1` + D-pad left/right | Start −0.1s / +0.1s | |
| `L1` + D-pad up/down | Add / remove a previous line | |
| `R1` + D-pad left/right | End −0.1s / +0.1s | |
| `R1` + D-pad up/down | Add / remove a next line | |
| `L1` + `R1` | Reset the screenshot frame | |
To change it, open `Alt+C` and expand **Review media timing**. Click an action's pencil, then press a button, or hold one button and press another to make a combo; the first button becomes the one you hold. Any button can be a hold button. Changes are saved per controller, and **Reset all** brings back the default layout.
Nudges, Earlier/Later, lines and frames repeat while held whichever buttons they are on. A row warns you when two actions on the same step share an input, or when an action uses a button alone that other actions hold.
### Update behavior
| Key | What it does |
@@ -237,6 +265,21 @@ The manual dialog cancels itself after 90 seconds. Identical entries are not ded
| `Backspace` | Back from the merge preview |
| `Esc` | Cancel and leave both cards unchanged |
### With a controller {#field-grouping-with-a-controller}
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each control shows its controller shortcut. Like the [media timing review](#with-a-controller), it works with or without keyboard-only mode, `A`/`B`/`X`/`Y` are the printed letters (set by **Button Style** in `Alt+C`), and a button already held when the dialog opens waits for a fresh press.
| Controller | Choosing the card | Merge preview |
| ------------------ | --------------------------------- | ----------------------- |
| D-pad left / right | Original card / New card | |
| D-pad up / down | | Scroll (repeats) |
| `A` | Continue | Confirm merge |
| `B` | Cancel | Back to the card choice |
| `X` | Turn "Delete duplicate" on or off | |
| `Y` | | Compact / Full |
To cancel from the preview, press `B` twice, or bind the preview's **Cancel** row (unbound by default). Change any of it in the **Duplicate card (field grouping)** group of `Alt+C`, the same way as the review layout.
## Config validation
Invalid `ankiConnect` values produce a warning and fall back to the default. Use JSON booleans (`true`, not `"true"`) and a positive number for `pollingRate`.
+2 -1
View File
@@ -24,6 +24,7 @@ src/
preload*.ts # preload bridges (overlay, settings, stats, sync, Jellyfin setup)
main/ # main-process runtime modules and IPC/CLI wiring
boot/ # pre-ready boot helpers
assemblies/ # per-domain assemblies main.ts builds once (startup, overlay, playback, mining, stats, Yomitan, shortcuts, tray, first-run setup, YouTube, Jellyfin, AniList, settings, sync, dictionaries, overlay modals)
runtime/composers/ # larger runtime clusters assembled for main.ts
runtime/domains/ # domain barrels (startup, overlay, mpv, ipc, shortcuts, anilist, jellyfin, mining)
core/services/ # focused services: mpv client, overlay, tokenizer, mining, integrations, stats
@@ -138,7 +139,7 @@ Runtime code uses dependency injection:
2. Build its runtime dependencies in a `src/main/` module. Pass simple dependencies inline; extract an adapter only when it adds behavior or gets reused.
3. Call the service from lifecycle or command wiring.
`main.ts` gets domain handlers through `createMainRuntimeRegistry()` (`src/main/runtime/registry.ts`), which exposes the barrels in `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. Many handlers take a `*MainDeps` object built by a `createBuild*MainDepsHandler` builder, which keeps side effects out of the unit under test.
Domain handlers live in the barrels under `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. A domain whose runtimes share state, such as app startup, the overlay windows, mpv playback and subtitle state, card mining, stats, YouTube, Jellyfin, AniList, config settings, sync, or the character dictionary, is grouped into one assembly in `src/main/assemblies/` that owns that state and returns its runtimes. An assembly that answers renderer IPC or CLI commands also returns typed `ipc`/`cli` slices, and `main.ts` registers the handlers by combining those slices. Handlers take their dependencies as a plain object passed straight to the handler factory, which keeps side effects out of the unit under test.
Composers declare their inputs with `ComposerInputs<T>` and results with `ComposerOutputs<T>` from `src/main/runtime/composers/contracts.ts`. A missing dependency then fails at compile time.
+23
View File
@@ -1,5 +1,28 @@
# Changelog
## v0.21.1 (2026-10-26)
**Changed**
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
- A reading density chart of known and unknown words replaces the cumulative chart.
- Yomitan lookups and mined cards now appear in their own lane below the chart.
**Fixed**
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
**Docs**
- **README**: Refreshed the anime screenshots using the current build.
<details>
<summary>Internal changes</summary>
**Internal**
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
</details>
## v0.21.0 (2026-10-24)
**Added**
+82 -48
View File
@@ -52,7 +52,7 @@ SubMiner watches the config file while running. When it changes, live settings a
These apply live:
- `subtitleStyle`, `subtitleSidebar`, `subtitleSelection`, `keybindings`, `shortcuts`
- `subtitleStyle`, `subtitleSidebar`, `subtitleSelection`, `subtitleFilter`, `keybindings`, `shortcuts`
- `logging.level`, `logging.rotation`, `logging.files`
- `secondarySub.defaultMode`, `youtube.primarySubLanguages`, `youtube.subtitleSource`
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`, `stats.toggleKey`, `stats.markWatchedKey`
@@ -106,6 +106,15 @@ Overlay notifications are also kept in a session-only history panel. Toggle it w
Mining and startup status notifications use `ankiConnect.behavior.notificationType` (see [AnkiConnect](#ankiconnect)).
### Plugins
`plugins` enables [plugins](/plugins) from the `plugins/` folder next to this config file. Each key is a plugin folder name. Changes apply after a restart.
| Key | Default | What it does |
| ----------------------- | ------- | -------------------------------------- |
| `plugins.<id>.enabled` | `false` | Load the plugin at startup |
| `plugins.<id>.settings` | `{}` | Passed to the plugin as `ctx.settings` |
### Auto-start overlay
When mpv is started by SubMiner or the `subminer` launcher, the launcher passes these settings to the bundled mpv plugin. There is no separate plugin config file. `mpv.autoStartSubMiner` and `mpv.pauseUntilOverlayReady` (see [MPV launcher](#mpv-launcher)) control the background start and the initial pause.
@@ -166,30 +175,31 @@ Controls how primary and secondary subtitles look and which annotations they sho
}
```
| Key | Default | What it does |
| ------------------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------- |
| `subtitleStyle.primaryDefaultMode` | `"visible"` | Primary bar at startup: `hidden`, `visible`, or `hover` |
| `subtitleStyle.css` | see example | CSS for primary subtitles (font, size `35px`, color, shadow, and so on) |
| `subtitleStyle.secondary.css` | see example | CSS for secondary subtitles (size `24px`) |
| `subtitleStyle.preserveLineBreaks` | `false` | Keep line breaks as mpv shows them instead of one line |
| `subtitleStyle.autoPauseVideoOnHover` | `true` | Pause while the mouse is over subtitle text |
| `subtitleStyle.autoPauseVideoOnYomitanPopup` | `true` | Pause while a Yomitan popup is open |
| `subtitleStyle.primaryVisibleOnYomitanPopup` | `true` | In hover mode, keep the primary bar visible while a popup is open |
| `subtitleStyle.knownWordColor` | `#a6da95` | Known-word highlight color |
| `subtitleStyle.knownWordMaturityColors` | see example | `new`, `learning`, `young`, `mature` colors, used when `ankiConnect.knownWords.maturityEnabled` is on |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | N+1 target word color |
| `subtitleStyle.enableJlpt` | `false` | Underline words by JLPT level |
| `subtitleStyle.jlptColors` | see example | Underline colors for `N1` to `N5` |
| `subtitleStyle.nameMatchEnabled` | `false` | Sync the character dictionary and color character names |
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small character portraits next to matched names |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Character-name color |
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Color words by frequency rank |
| `subtitleStyle.frequencyDictionary.sourcePath` | `""` | Folder with `term_meta_bank_*.json` files. Empty searches the default locations |
| `subtitleStyle.frequencyDictionary.topX` | `10000` | Only color words ranked at or below this |
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `single` (one color) or `banded` (five colors, common to rare) |
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | Look up by `headword` (dictionary form) or `surface` (text as shown) |
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for `single` mode |
| `subtitleStyle.frequencyDictionary.bandedColors` | see example | Five colors for `banded` mode |
| Key | Default | What it does |
| ------------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `subtitleStyle.primaryDefaultMode` | `"visible"` | Primary bar at startup: `hidden`, `visible`, or `hover` |
| `subtitleStyle.css` | see example | CSS for primary subtitles (font, size `35px`, color, shadow, and so on) |
| `subtitleStyle.secondary.css` | see example | CSS for secondary subtitles (size `24px`) |
| `subtitleStyle.preserveLineBreaks` | `false` | Keep line breaks as mpv shows them instead of one line |
| `subtitleStyle.autoPauseVideoOnHover` | `true` | Pause while the mouse is over subtitle text |
| `subtitleStyle.autoPauseVideoOnYomitanPopup` | `true` | Pause while a Yomitan popup is open |
| `subtitleStyle.autoPauseVideoOnKeyboardMode` | `false` | Pause while keyboard-only mode is on (keyboard or controller toggle). Leaving resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, stays as it is |
| `subtitleStyle.primaryVisibleOnYomitanPopup` | `true` | In hover mode, keep the primary bar visible while a popup is open |
| `subtitleStyle.knownWordColor` | `#a6da95` | Known-word highlight color |
| `subtitleStyle.knownWordMaturityColors` | see example | `new`, `learning`, `young`, `mature` colors, used when `ankiConnect.knownWords.maturityEnabled` is on |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | N+1 target word color |
| `subtitleStyle.enableJlpt` | `false` | Underline words by JLPT level |
| `subtitleStyle.jlptColors` | see example | Underline colors for `N1` to `N5` |
| `subtitleStyle.nameMatchEnabled` | `false` | Sync the character dictionary and color character names |
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small character portraits next to matched names |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Character-name color |
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Color words by frequency rank |
| `subtitleStyle.frequencyDictionary.sourcePath` | `""` | Folder with `term_meta_bank_*.json` files. Empty searches the default locations |
| `subtitleStyle.frequencyDictionary.topX` | `10000` | Only color words ranked at or below this |
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `single` (one color) or `banded` (five colors, common to rare) |
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | Look up by `headword` (dictionary form) or `surface` (text as shown) |
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for `single` mode |
| `subtitleStyle.frequencyDictionary.bandedColors` | see example | Five colors for `banded` mode |
Two CSS custom properties style the hovered word: `--subtitle-hover-token-color` (`#f4dbd6`) and `--subtitle-hover-token-background-color` (`transparent`). Set them inside `subtitleStyle.css`.
@@ -211,6 +221,29 @@ Sidebar custom properties: `--subtitle-sidebar-max-width` (`420px`), `--subtitle
If `embedded` layout places the video oddly on your system, switch back to `overlay`.
### Subtitle filters {#subtitle-filters}
Removes unwanted text from primary subtitles, such as speaker names, readings and sound descriptions. Filtering happens as the line arrives, so the overlay, sidebar, mined sentences and stats all see the filtered text. A line left with no words is hidden. In the settings window, find it under **Behavior → Subtitle Filters**.
| Key | Default | What it does |
| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------ |
| `subtitleFilter.presets.parentheses` | `false` | Remove `(…)` and `(…)` anywhere in a line. A line wrapped entirely in parentheses (inner thoughts) is kept |
| `subtitleFilter.presets.brackets` | `false` | Remove `[…]`, `[…]`, `【…】` and `〔…〕`, such as sound effects |
| `subtitleFilter.presets.speakerPrefix` | `false` | Remove a short name before a colon at the start of a line, as in `山田:` |
| `subtitleFilter.presets.musicSymbols` | `false` | Remove `♪`, `♫` and `♬` |
| `subtitleFilter.customRules` | `[]` | Your own regex rules, applied in order after the presets |
Each custom rule is `{ "name", "pattern", "replacement", "enabled" }`. Patterns are JavaScript regular expressions compiled with the `gmu` flags, so `^` and `$` match each line. An empty `replacement` removes the match.
```jsonc
{
"subtitleFilter": {
"presets": { "parentheses": true },
"customRules": [{ "name": "Join lines", "pattern": "\\n", "replacement": "", "enabled": true }],
},
}
```
### Subtitle position
You can also drag subtitles with `Right-click + drag` while watching.
@@ -268,7 +301,7 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`. Scroll wheel keys are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`.
- `command` is any mpv JSON IPC command array. Set it to `null` to disable a default.
- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, and `__runtime-option-cycle:<id>[:next|prev]`.
- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, `__runtime-option-cycle:<id>[:next|prev]`, and `__plugin-action:<plugin id>/<action>` (see [Plugins](/plugins)).
- Unused single-key, mouse button, and scroll wheel bindings from your mpv config also work in the overlay. Your SubMiner bindings win on conflicts.
### Shortcuts configuration
@@ -281,21 +314,22 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
### Controller support
Gamepad input for the overlay, through the browser Gamepad API. It only works while keyboard-only mode is on. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are on [Keyboard shortcuts](/shortcuts).
Gamepad input for the overlay, through the browser Gamepad API. Most actions only work while keyboard-only mode is on; the toggle for that mode, the secondary subtitle hold (`controller.bindings.holdSecondarySub`), pause (`toggleMpvPause`) and the subtitle line actions (`previousSubtitle`, `nextSubtitle`, `replaySubtitle`, `playNextSubtitle`) work either way. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are on [Keyboard shortcuts](/shortcuts).
| Key | Default | What it does |
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------- |
| `controller.enabled` | `false` | Enable controller support. The `Alt+C` and `Alt+Shift+C` modals stay closed while off |
| `controller.smoothScroll` | `true` | Smooth popup scrolling |
| `controller.scrollPixelsPerSecond` | `900` | Popup scroll speed |
| `controller.horizontalJumpPixels` | `160` | Popup page-jump distance |
| `controller.stickDeadzone` | `0.2` | Stick deadzone |
| `controller.triggerInputMode` | `"auto"` | `auto`, `digital`, or `analog`. Use `analog` if your L2/R2 report analog values |
| `controller.triggerDeadzone` | `0.5` | Trigger threshold for `auto` and `analog` |
| `controller.repeatDelayMs` | `320` | Delay before a held button repeats |
| `controller.repeatIntervalMs` | `120` | Repeat interval for held buttons |
| Key | Default | What it does |
| ---------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `controller.enabled` | `false` | Enable controller support. The `Alt+C` and `Alt+Shift+C` modals stay closed while off |
| `controller.smoothScroll` | `true` | Smooth popup scrolling |
| `controller.scrollPixelsPerSecond` | `900` | Popup scroll speed |
| `controller.horizontalJumpPixels` | `160` | Popup page-jump distance |
| `controller.stickDeadzone` | `0.2` | Stick deadzone |
| `controller.triggerInputMode` | `"auto"` | `auto`, `digital`, or `analog`. Use `analog` if your L2/R2 report analog values |
| `controller.triggerDeadzone` | `0.5` | Trigger threshold for `auto` and `analog` |
| `controller.faceButtonLabels` | `"xbox"` | Face button style: `xbox`, `nintendo` (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or `playstation` (╳ ○ □ △). **Button Style** in `Alt+C` sets it per controller; this is the default for the others |
| `controller.repeatDelayMs` | `320` | Delay before a held button repeats |
| `controller.repeatIntervalMs` | `120` | Repeat interval for held buttons |
Bindings are set with `Alt+C` learn mode, which saves them per controller.
Bindings, including the media timing review and duplicate card dialog layouts, are set with `Alt+C` learn mode, which saves them per controller.
## Anki integration
@@ -329,15 +363,15 @@ Creates and updates Anki cards with sentence, audio, and screenshot. Needs the [
**Fields**
| Key | Default | What it does |
| ------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `ankiConnect.fields.word` | `"Expression"` | Word field |
| `ankiConnect.fields.audio` | `"ExpressionAudio"` | Field that receives sentence audio. Set a separate field such as `SentenceAudio` so it does not overwrite Yomitan's word audio |
| `ankiConnect.fields.wordAudio` | `"ExpressionAudio"` | Existing word-audio field, read only to time animated images |
| `ankiConnect.fields.image` | `"Picture"` | Screenshot field |
| `ankiConnect.fields.sentence` | `"Sentence"` | Sentence field |
| `ankiConnect.fields.miscInfo` | `"MiscInfo"` | Metadata field. `null` disables |
| `ankiConnect.metadata.pattern` | `"[SubMiner] %f (%t)"` | MiscInfo template: `%f` filename, `%F` filename with extension, `%t` time, `%T` time with ms, `<br>` newline |
| Key | Default | What it does |
| ------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `ankiConnect.fields.word` | `"Expression"` | Word field |
| `ankiConnect.fields.audio` | `"SentenceAudio"` | Field that receives sentence audio. Keep it separate from Yomitan's word-audio field so it does not overwrite that audio |
| `ankiConnect.fields.wordAudio` | `"ExpressionAudio"` | Existing word-audio field, read only to time animated images |
| `ankiConnect.fields.image` | `"Picture"` | Screenshot field |
| `ankiConnect.fields.sentence` | `"Sentence"` | Sentence field |
| `ankiConnect.fields.miscInfo` | `"MiscInfo"` | Metadata field. `null` disables |
| `ankiConnect.metadata.pattern` | `"[SubMiner] %f (%t)"` | MiscInfo template: `%f` filename, `%F` filename with extension, `%t` time, `%T` time with ms, `<br>` newline |
**Media**
+1 -1
View File
@@ -70,7 +70,7 @@ Charts for watch time, cards, words, and sessions per day or month, running tota
### Sessions
Session history with new-word activity and pause, seek, and card markers. The **↗** button on a row opens that show's detail view.
Session history. Expand a session for its pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words over the session with Yomitan lookup ticks and clickable card-mined markers underneath. The **↗** button on a row opens that show's detail view.
![Stats Sessions](/screenshots/stats-sessions.png)
+1 -1
View File
@@ -226,7 +226,7 @@ make deps
make build-macos
```
The `.dmg` and `.zip` land in `release/`. For an unsigned local build, run `bun run build:mac:unsigned`.
The `.dmg` and `.zip` land in `dist/release/`. For an unsigned local build, run `bun run build:mac:unsigned`.
</details>
+1 -1
View File
@@ -124,7 +124,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
`log-level` sets SubMiner's log verbosity. Do not use `--debug` for this; it turns on the app's dev mode.
The plugin also handles messages the SubMiner app sends it (`subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, `subminer-reload-session-bindings`). You do not need to send these yourself. The AniSkip messages are listed on the [AniSkip page](/aniskip-integration#triggering-from-mpv).
The plugin also handles messages the SubMiner app sends it (`subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, `subminer-overlay-loading-dismissed`, `subminer-reload-session-bindings`). You do not need to send these yourself. The AniSkip messages are listed on the [AniSkip page](/aniskip-integration#triggering-from-mpv).
## Auto-start behavior
+199
View File
@@ -0,0 +1,199 @@
# Plugins
Plugins are JavaScript files that SubMiner loads at startup. A plugin can react to playback, run code on a timer, add actions you bind to keys, run programs, and show results in an overlay panel, all without rebuilding SubMiner.
::: warning Only run plugins you trust
Plugins run inside SubMiner with the same access to your files and system as SubMiner itself. There is no sandbox.
:::
These are SubMiner app plugins. The Lua script that runs inside mpv is the separate [mpv plugin](/mpv-plugin).
## Install a plugin
1. Put the plugin in its own folder in `plugins/` next to your [config file](/configuration#configuration-file), for example `~/.config/SubMiner/plugins/ai-explain/`. The folder name is the plugin id.
2. Enable it in `config.jsonc`. `settings` is passed to the plugin as-is:
```jsonc
{
"plugins": {
"ai-explain": { "enabled": true, "settings": { "reasoningEffort": "medium" } },
},
}
```
3. Bind its actions to keys with the `__plugin-action:<plugin id>/<action>` command:
```jsonc
{
"keybindings": [
{ "key": "Ctrl+KeyE", "command": ["__plugin-action:ai-explain/explain"] },
{ "key": "Ctrl+KeyT", "command": ["__plugin-action:ai-explain/translate"] },
],
}
```
To run an action from a script or a window-manager shortcut, use `subminer app --plugin-action ai-explain/explain`. It goes to the running SubMiner, or starts one if none is running.
4. Restart SubMiner. Plugin changes apply on the next start; keybinding changes apply immediately.
Load errors and plugin log output go to the app log with a `plugin:<id>` scope.
## Example: explain the current line with Codex
The repository ships [`assets/plugins/ai-explain`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/ai-explain). It sends the current line, plus the lines around it, to [`codex exec`](https://github.com/openai/codex) and shows the answer in a panel. It needs a logged-in Codex CLI.
| Action | What it does |
| ----------- | --------------------------------------------------------------- |
| `explain` | Translation plus a breakdown of the grammar and vocabulary |
| `translate` | Natural English translation, with a note only when it's unclear |
| Setting | Default | What it does |
| ----------------- | --------- | ----------------------------------------------------------------- |
| `codexPath` | `"codex"` | Codex binary. Use a full path if SubMiner can't find it on `PATH` |
| `model` | `""` | Model passed to `codex exec -m`; empty uses your Codex config |
| `reasoningEffort` | `"low"` | Codex reasoning effort; empty uses your Codex config |
| `before`, `after` | `2`, `1` | Lines of context sent before and after the current line |
| `pause` | `true` | Pause playback when the panel opens |
| `timeoutMs` | `120000` | Give up after this long |
| `prompts` | `{}` | Replace an action's instructions, e.g. `{ "translate": "..." }` |
Press `Esc` to close the panel. Closing it, or starting another request, stops the running Codex process.
## Example: sync Anki after mining
[`assets/plugins/anki-sync`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/anki-sync) runs AnkiConnect's `sync` after you mine cards, so they reach AnkiWeb without opening Anki. It waits until you stop mining for a while, so a burst of cards becomes one sync. It also adds a `sync` action you can bind to a key.
| Setting | Default | What it does |
| ----------- | ------- | ------------------------------------------------------- |
| `delayMs` | `30000` | Sync after no card has been mined for this long |
| `syncOnEnd` | `true` | Sync right away when playback ends or mpv quits |
| `timeoutMs` | `60000` | Give up on a sync after this long |
| `notify` | `true` | Show a notification after each sync. Failures always do |
Anki must be running and logged in to AnkiWeb. Cards mined from the stats dashboard don't trigger a sync.
## Example: tag cards with the episode
[`assets/plugins/episode-tags`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/episode-tags) tags each mined card with the show and episode it came from, such as `show::Sousou_no_Frieren::E07`. It reads them from the file name (`[Group] Show - 07 (1080p).mkv`, `Show.S01E07.mkv`, `Show 第7話.mkv`), or from the title mpv shows for streams. It uses the source captured when mining started, so delayed media and field grouping into an older note keep the correct episode. Notes with unknown provenance, including cards discovered by polling, are left alone. Spaces become `_`.
| Setting | Default | What it does |
| -------- | ------------------------ | ----------------------------------------------- |
| `tags` | `["show::{show}::{ep}"]` | Tags to add, one template each |
| `notify` | `false` | Show a notification with the tags for each card |
Templates can use `{show}`, `{season}`, `{episode}`, and `{ep}` (`S01E07`, or `E07` when there is no season). When a placeholder has no value, the tag stops before that `::` level, so a movie gets `show::<title>`. The parsing is a guess from the name: a title ending in a number, like `Mob Psycho 100`, reads that number as the episode.
## Example: open Jimaku when a file has no Japanese subtitles
[`assets/plugins/auto-jimaku`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/auto-jimaku) opens the Jimaku search a few seconds after a file starts if none of its subtitle tracks is Japanese. A track counts as Japanese when its language code is in `languages`, or its title contains "Japanese" or 日本語.
| Setting | Default | What it does |
| ----------- | --------------------- | --------------------------------------------------------------- |
| `action` | `"openJimaku"` | Built-in action to run, e.g. `openTsukihime` |
| `languages` | `["ja", "jp", "jpn"]` | Track language codes that count as Japanese |
| `delayMs` | `3000` | Wait this long after the file starts, so external subs load |
| `streams` | `false` | Also check `http(s)` streams, which Jimaku usually can't search |
## Write a plugin
A plugin folder needs a `plugin.json`:
```json
{ "name": "My Plugin", "version": "1.0.0", "apiVersion": 1, "main": "main.js" }
```
`apiVersion` declares the required SubMiner plugin API version. The current version is `1`; omitted values also mean v1 for existing plugins. An unsupported version is rejected before the plugin's entry module runs. `version` is the plugin's own version and does not control compatibility.
`main` points to a CommonJS module (`.js`) or an ES module (`.mjs`) that exports `activate(ctx)`. Activation may be asynchronous and has a 10-second timeout. A plugin's actions wait for its own activation, so a slow plugin does not hold up other plugins. If activation fails or times out, its registrations are removed and its signal aborts. Synchronous code still shares the app's main thread.
Activation may return a cleanup function, or the module can export `deactivate()`. On quit, SubMiner aborts `ctx.signal` first and waits up to 2 seconds in total for loading and deactivation. A plugin still reading its files is skipped. Everything a plugin registers through `ctx` is cleaned up automatically. A late activation cannot leave new listeners, timers, or actions behind, and its returned cleanup still runs once. Deactivation can call Anki, mpv, built-in actions, and notifications until disposal. After disposal, those calls throw or reject, including work that resumes after an activation or deactivation timeout.
```js
function activate(ctx) {
ctx.media.on('change', (media) => {
if (media) ctx.log.info(`Now playing ${media.path}`);
});
ctx.every(60_000, () => ctx.log.info('Still watching'));
ctx.actions.register('hello', () => {
const line = ctx.subtitles.current();
ctx.ui.panel({ title: 'Hello', markdown: line ? `> ${line.text}` : '_No subtitle_' });
});
}
module.exports = { activate };
```
An error thrown by a plugin is logged and does not crash SubMiner. A plugin that blocks with a long synchronous loop still freezes the app, so keep slow work asynchronous.
### Types and cleanup
The public types live in [`src/plugin-api.ts`](https://github.com/ksyasuda/SubMiner/blob/main/src/plugin-api.ts). A build emits `dist/plugin-api.d.ts`; copy that file into your plugin as `subminer.d.ts`, or copy the source file with that name. It has no imports from SubMiner internals. Electron's types must be available to your editor because `ui.openWindow` returns a `BrowserWindow`.
JavaScript plugins can use the types through JSDoc:
```js
/** @param {import('./subminer').PluginContext} ctx */
function activate(ctx) {
const { ipcMain } = require('electron');
ipcMain.handle(`${ctx.id}:info`, () => ({ media: ctx.media.current() }));
ctx.onDispose(() => ipcMain.removeHandler(`${ctx.id}:info`));
}
module.exports = { activate };
```
Use `ctx.signal` with `fetch` or other cancellable work. It aborts when unloading starts, before `deactivate` runs. `ctx.onDispose(fn)` registers synchronous cleanup for resources outside `ctx`, such as file watchers and Electron IPC handlers. The returned function runs the cleanup early, at most once. Registering cleanup after unloading starts runs it immediately. Timers and listeners registered after that point are removed immediately too. Use the returned activation cleanup or `deactivate` for asynchronous teardown, within the shared quit timeout.
### `ctx` reference
| Member | What it does |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `id`, `dir`, `settings` | Plugin id, plugin folder path, and `plugins.<id>.settings` |
| `apiVersion` | The host's plugin API version, currently `1` |
| `signal`, `onDispose(fn)` | Lifetime cancellation and cleanup for resources created outside `ctx` |
| `dataDir` | `plugin-data/<id>/` next to your config file, for caches and state. Exists before `activate` runs |
| `log.debug/info/warn/error(message)` | Write to the app log |
| `every(ms, fn)`, `after(ms, fn)` | Repeating and one-shot timers. Return a function that cancels them |
| `mpv.on(event, fn)` | Listen to mpv events (below). Returns an unsubscribe function |
| `mpv.command(args)` | Send an mpv command, e.g. `['set', 'pause', 'yes']` |
| `mpv.request(args)` | Await an mpv command result; rejects on command error, disconnect, or timeout |
| `mpv.getProperty(name)` | Read an mpv property (Promise) |
| `mpv.connected` | Whether mpv is connected |
| `mpv.ensureRunning()` | Connect to mpv, starting an idle mpv if none is running. Resolves `false` if that fails |
| `media.current()` | Cached `{ playbackId, path, title }`, or `null` when there is no connected media |
| `media.on('change', fn, options)` | Subscribe to playback snapshots; immediately delivers the current snapshot unless `emitCurrent: false` |
| `anki.on(event, fn)` | Listen to Anki events (below). Returns an unsubscribe function |
| `anki.invoke(action, params, options)` | Send an AnkiConnect action to `ankiConnect.url`, e.g. `('sync')`. Resolves its result (below) |
| `subtitles.current()` | The displayed line as `{ text, startTime, endTime }`, or `null` |
| `subtitles.context({ before, after })` | `{ current, before, after }`. Neighbors come from the loaded subtitle file, or recent lines for streams |
| `actions.register(name, fn)` | Add an action, bindable as `__plugin-action:<id>/<name>` |
| `actions.run(actionId, payload)` | Run a built-in action, the same ids keybindings use, e.g. `openJimaku` or `mineSentence` |
| `notify(message, { variant })` | Overlay notification. `variant` is `info`, `warning`, or `error` |
| `ui.panel({ title, markdown })` | Open the plugin panel. Returns `{ update(content), close(), closed, signal }` |
| `ui.openWindow({ html, preload })` | Open a window from the plugin folder (below). Returns the Electron `BrowserWindow` |
| `exec(command, args, options)` | Run a program without a shell (Windows `.cmd`/`.bat` shims go through `cmd.exe`). Resolves `{ exitCode, stdout, stderr }` |
| `unstable` | SubMiner internals. These change between releases without notice |
A panel's `signal` aborts when the user closes it or another panel replaces it. Pass it to `exec` to stop work nobody will see. Closing or replacing a panel also cancels pending open requests, including while its window loads. Only one panel is open at a time. Panel markdown supports headings, lists, quotes, code, bold, and italics; HTML is shown as plain text.
`exec` options are `stdin`, `cwd`, `env`, `timeoutMs`, and `signal`. It resolves for any exit code and rejects when the program can't start, times out, or is aborted.
`ui.openWindow` takes `html` and an optional `preload`, both relative to the plugin folder, plus `title`, `width`, and `height`. Opening the same `html` again focuses the open window. The page runs with context isolation and no Node access. The preload is sandboxed too: it can `require('electron')` but nothing else, so bundle anything more into it. Expose what the page needs from the preload with `contextBridge`, and handle its messages with `ipcMain` from `require('electron')` in the plugin. Prefix channel names with the plugin id. Plugin windows close when SubMiner quits.
`media` snapshots are immutable. `playbackId` changes on a new load, including reopening the same path. A title may arrive later with the same id. Disconnecting or ending playback delivers `null`. Use the id to discard stale asynchronous results. Snapshots describe cached playback state; they do not promise that external subtitle discovery has finished.
`mpv.request` accepts string, number, and boolean arguments, e.g. `await ctx.mpv.request(['set_property', 'pause', true])`. It returns mpv's result data and rejects after 4 seconds. A timeout does not cancel the command in mpv, so a slow command may still complete. `mpv.command` remains available for commands whose result you do not need.
mpv events: `connection-change`, `subtitle-change`, `subtitle-timing`, `time-pos-change`, `duration-change`, `pause-change`, `fullscreen-change`, `media-path-change`, `media-title-change`, `subtitle-track-change`, `subtitle-track-list-change`, `secondary-subtitle-change`, `secondary-subtitle-track-change`, `secondary-subtitle-delay-change`, `secondary-subtitle-visibility`, `client-message`, `file-loaded`, and `end-file`. `file-loaded` has no payload. `end-file` gives `{ reason, playlistEntryId, error }`, where `reason` is `eof`, `stop`, `quit`, `error`, `redirect`, or `unknown`.
Anki events: `card-mined` gives `{ noteIds, notes }` once SubMiner has finished filling in a mined card (sentence, audio, image, and field grouping). For YouTube cards whose media waits on the download, it fires once that media is added or the download fails. With `ankiConnect.behavior.autoUpdateNewCards` off, it fires as soon as SubMiner sees the card. If field grouping merged the card into an older one, `noteIds` contains the older card's id.
Each `notes` entry has `{ originalNoteId, noteId, provenance }`. `originalNoteId` identifies the note before merges; `noteId` is the note that kept its content. `provenance` is `{ minedAt, media, subtitle }`, captured before asynchronous creation or the AnkiConnect proxy's upstream request. `minedAt` is Unix time in milliseconds. `media` uses the snapshot format above, and `subtitle` uses `{ text, startTime, endTime }`. Either may be `null`. Polling cannot establish the original source, so its provenance is `null` rather than the currently playing file.
`subtitles.current()` resolves parsed cues using the current subtitle delay and returns their times on the subtitle file's timeline.
Sentence-card provenance also uses the subtitle file's timeline. Multi-line mining converts the history range using the subtitle delay when mining starts; changing the delay between those lines can affect the reported range.
Several original notes can merge into one kept note. `notes` preserves each source, while `noteIds` contains unique kept ids. Existing listeners using only `noteIds` continue to work. Cards mined from the stats dashboard do not emit this event.
`anki.invoke` takes `{ timeoutMs }` (default 10 seconds), rejects on an Anki error, and never retries, so an action like `sync` or `addNote` runs at most once.
+331 -2
View File
@@ -108,6 +108,7 @@
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
"faceButtonLabels": "xbox", // Face button style of your controller: xbox (A at the bottom), nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or playstation (╳ ○ □ △, laid out like xbox). Places the A/B/X/Y of the dialog layouts and names buttons in Alt+C and the dialog hints. Alt+C sets it per controller (controller.profiles.<id>.faceButtonLabels); this is the default for the others. Values: xbox | nintendo | playstation
"repeatDelayMs": 320, // Delay before repeating held controller actions.
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
"buttonIndices": {
@@ -158,7 +159,28 @@
"toggleMpvPause": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"holdSecondarySub": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"previousEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"nextEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"previousSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"nextSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"replaySubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"playNextSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action.
@@ -180,6 +202,288 @@
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
"reviewBindings": {
"confirm": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Use this timing.
"cancel": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Cancel.
"play": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Play selection.
"resetTiming": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Reset timing.
"showEarlier": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Earlier −2s. Repeats while held.
"showLater": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Later +2s. Repeats while held.
"framePrevious": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Previous screenshot frame. Repeats while held.
"frameNext": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Next screenshot frame. Repeats while held.
"frameReset": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Reset screenshot frame.
"startBack": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Start −0.1s. Repeats while held.
"startForward": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Start +0.1s. Repeats while held.
"previousLineAdd": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Add previous line. Repeats while held.
"previousLineRemove": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Remove previous line. Repeats while held.
"endBack": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: End −0.1s. Repeats while held.
"endForward": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: End +0.1s. Repeats while held.
"nextLineAdd": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Add next line. Repeats while held.
"nextLineRemove": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Remove next line. Repeats while held.
"useOriginal": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Use original timing (after cancel).
"keepEditing": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Keep editing (after cancel).
"skipMedia": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Without media (after cancel).
"discard": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
} // Review dialog: Discard card (after cancel).
}, // Controller layout for the media timing review dialog, one entry per action. Set it with the Review media timing group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.
"fieldGroupingBindings": {
"selectOriginal": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Original card.
"selectNew": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: New card.
"toggleDeleteDuplicate": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Delete duplicate on/off.
"continue": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Continue.
"cancel": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Cancel.
"confirmMerge": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Confirm merge (merge preview).
"back": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Back (merge preview).
"cancelMerge": {
"kind": "none" // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
}, // Duplicate card dialog: Cancel (merge preview).
"toggleView": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Compact / Full (merge preview).
"scrollUp": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Scroll up (merge preview). Repeats while held.
"scrollDown": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
} // Duplicate card dialog: Scroll down (merge preview). Repeats while held.
}, // Controller layout for the duplicate card (field grouping) dialog, one entry per action. Set it with the Duplicate card (field grouping) group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
@@ -218,6 +522,14 @@
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior.
// ==========================================
// Plugins
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
// Restart SubMiner after changing plugins.
// ==========================================
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
@@ -462,6 +774,7 @@
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"autoPauseVideoOnKeyboardMode": false, // Pause mpv when keyboard-only mode turns on (from the keyboard or a controller) and resume when it turns off. Leaving the mode resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, is left as it is. Values: true | false
"primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false
"nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
@@ -541,6 +854,22 @@
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// ==========================================
// Subtitle Filters
// Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
// Applies to the overlay, sidebar, mined sentences and stats.
// Hot-reload: filter changes apply to the current subtitle file immediately.
// ==========================================
"subtitleFilter": {
"presets": {
"parentheses": false, // Remove text in () and (), such as speaker names, readings and notes. A line wrapped entirely in parentheses, usually inner thoughts, is kept. Values: true | false
"brackets": false, // Remove text in [], [], 【】 and 〔〕, such as sound effects and music cues. Values: true | false
"speakerPrefix": false, // Remove a short name followed by a colon at the start of a line, as in 山田:元気? Values: true | false
"musicSymbols": false // Remove ♪, ♫ and ♬. A line left with only symbols such as ~ is removed. Values: true | false
}, // Built-in filters. Each removes its matches and is off by default.
"customRules": [] // Your own regex rules, applied in order after the presets. Each rule is { name, pattern, replacement, enabled }; patterns use the gmu flags, so ^ and $ match per line. Leave replacement empty to remove the match.
}, // Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
// ==========================================
// Shared AI Provider
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
@@ -578,7 +907,7 @@
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
"fields": {
"word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
"audio": "SentenceAudio", // Card field that receives generated sentence audio.
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
"image": "Picture", // Card field that receives the captured screenshot or animated image.
"sentence": "Sentence", // Card field that receives the source sentence text.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 614 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

+2
View File
@@ -138,6 +138,8 @@ The plugin's `v` replaces mpv's own subtitle visibility toggle. When the overlay
}
```
To run a [plugin](/plugins) action, use `["__plugin-action:<plugin id>/<action>"]` as the command.
Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. Scroll wheel names are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, and `WHEEL_RIGHT`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference.
## Automatic mpv bindings
+1 -1
View File
@@ -86,7 +86,7 @@ SubMiner detected that a previously non-empty Yomitan profile suddenly appears e
## "Yomitan extension not found in any search path"
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `build/yomitan`.
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `dist/yomitan`.
## "MeCab not found on system"
+35 -16
View File
@@ -22,7 +22,7 @@ The bundled Yomitan is separate from any Yomitan in your browser. It has its own
### Hachidori setup
Hachidori is an alternative lookup backend. Set `dictionaryBackend` to `"hachidori"` in settings or `config.jsonc`, then restart SubMiner. Set it back to `"yomitan"` and restart to switch back.
[Hachidori](https://github.com/bee-san/hachidori) is an alternative lookup backend. Set `dictionaryBackend` to `"hachidori"` in settings or `config.jsonc`, then restart SubMiner. Set it back to `"yomitan"` and restart to switch back.
Open Hachidori settings with `Alt+Shift+Y`, the tray's **Open Hachidori Settings**, or `subminer app --hachidori`. Import dictionary ZIPs or use Hachidori's recommended dictionary installer, then set up its Anki template (SubMiner [fills in what it can](/anki-integration#hachidori-settings-from-subminer)). Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not carry over. Subtitle annotations and lookups wait while Hachidori imports a dictionary, so large imports pause them until the import finishes.
@@ -74,6 +74,10 @@ See [Launcher script](/launcher-script#video-picker) for picker and history deta
Hovering subtitle text pauses mpv, and moving away resumes it. An open dictionary popup also keeps playback paused. Turn these off with `subtitleStyle.autoPauseVideoOnHover` and `subtitleStyle.autoPauseVideoOnYomitanPopup`.
**Pause Video In Keyboard-Only Mode** (`subtitleStyle.autoPauseVideoOnKeyboardMode`, off by default) pauses mpv when keyboard-only mode turns on, from the keyboard or a controller, and resumes it when the mode turns off. It only resumes a pause it made: if the video was already paused, or you played or paused it yourself in the meantime, leaving the mode leaves playback as it is.
Turning keyboard-only mode off also closes the dictionary popup, whether or not this setting is on.
You can drop files onto the overlay:
- A video replaces what is playing. Hold `Shift` to add it to the playlist instead.
@@ -104,6 +108,7 @@ subminer doctor # check dependencies, config, and the mpv soc
subminer generate-subs video.mkv # make Japanese subtitles from the audio
subminer logs -e # export a log ZIP for bug reports
subminer app --setup # reopen first-run setup
subminer app --plugin-action <id>/<name> # run a plugin action
subminer -u # update SubMiner
```
@@ -156,25 +161,39 @@ You can drive the overlay with a gamepad.
1. Set `controller.enabled` to `true` in your config.
2. Connect a controller. SubMiner uses the first one it sees.
3. Press `Y` on the controller to turn on keyboard-only mode. The controller only works in this mode.
3. Press `Y` on the controller to turn on keyboard-only mode. Most controller actions only work in this mode.
4. Move between words with the left stick, press `A` to look one up, and `X` to mine it.
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want. `Alt+Shift+C` shows raw input values for unusual pads.
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want. Set **Button Style** to Xbox, Nintendo or PlayStation so buttons are named as printed on your controller; it is saved per controller. Changes apply right away. When two actions share an input, both fire, so their rows show a ⚠ warning naming the other action; this includes a button on the D-pad while a **(D-pad)** row under **Navigation** is still on, and a stick direction used by a stick action. The window only sees a controller after you press one of its buttons; until then it shows the bindings of the controller in use as a preview, and editing stays locked. With several controllers connected, it selects the one you pressed last. `Alt+Shift+C` shows raw input values for unusual pads.
| Button | Action |
| --------------------- | ------------------------------------ |
| `A` (South) | Look up the selected word |
| `B` (East) | Close the lookup |
| `X` (West) | Mine a card |
| `Y` (North) | Toggle keyboard-only mode |
| `L1` | Play the current Yomitan audio |
| `R1` | Next Yomitan audio source |
| `L3` | Pause or resume mpv |
| `Select` / `Minus` | Quit mpv |
| Left stick | Move between words, scroll the popup |
| Right stick (up/down) | Jump through the popup |
| Button | Action |
| --------------------- | -------------------------------------- |
| `A` (South) | Look up the selected word |
| `B` (East) | Close the lookup |
| `X` (West) | Mine a card |
| `Y` (North) | Toggle keyboard-only mode |
| `L1` | Play the current Yomitan audio |
| `R1` | Next Yomitan audio source |
| `L3` | Pause or resume mpv (any mode) |
| `Select` / `Minus` | Quit mpv |
| Left stick | Move between words, scroll the popup |
| Right stick (up/down) | Jump through the popup |
| Unbound by default | Show the secondary subtitle while held |
| Unbound by default | Previous word entry in the popup |
| Unbound by default | Next word entry in the popup |
| Unbound by default | Previous / next subtitle line |
| Unbound by default | Replay the current line, then pause |
| Unbound by default | Play the next line, then pause |
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`. Remap it with `Alt+C`. All options are in [Configuration](/configuration#controller-support).
The secondary subtitle hold works with or without keyboard-only mode. Assign it with **Learn** on the **Show Secondary Subtitle (Hold)** row in `Alt+C`. While held, the secondary subtitle shows as if its mode were `visible`; releasing the button returns it to your chosen mode.
The **Subtitles** rows in `Alt+C` do the same as the keyboard shortcuts: **Previous Subtitle** and **Next Subtitle** jump a line like `Shift+H` / `Shift+L` (a paused video stays paused), **Replay Subtitle** is `Ctrl+Shift+H` and **Play Next Subtitle** is `Ctrl+Shift+L`. Like the secondary subtitle hold and pause, they work with or without keyboard-only mode. If you put them on the D-pad, click **Clear** on the matching **(D-pad)** row under **Navigation** too (both rows warn until you do), or in keyboard-only mode the D-pad will also move between words or scroll the popup.
**Previous Entry** and **Next Entry** move one word entry at a time in the open popup, and Mine Card then mines the selected entry. They send Yomitan's default `Alt+Up` / `Alt+Down` entry shortcuts, so they stop working if you rebind those in Yomitan's keyboard settings. If you assign them to the D-pad, click **Clear** on the **Popup Scroll (D-pad)** row too, or the D-pad will also scroll the popup.
The media timing review and duplicate card (field grouping) dialogs have their own controller layouts, which you can change in the **Review media timing** and **Duplicate card (field grouping)** groups of `Alt+C`; see [Review media timing](/anki-integration#with-a-controller) and [Field grouping](/anki-integration#field-grouping-with-a-controller).
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`, and the default pause button on `Start` instead of `L3`. The default right-stick popup jump does nothing on these controllers; click **Learn** on **Popup Jump (Stick)** and move the right stick up or down to set it. Remap anything else with `Alt+C`, which names buttons according to the layout your controller reports. All options are in [Configuration](/configuration#controller-support).
## Development profiles
+2 -2
View File
@@ -206,7 +206,7 @@ The same pattern works for n8n or Zapier workflows, Discord notifiers, or append
## mpv script messages
SubMiner has no in-app plugin SDK. Besides the streams above, you can drive it from other mpv scripts and from the [launcher CLI](/launcher-script).
SubMiner also supports [JavaScript app plugins](/plugins) for playback events, mining events, actions, and custom UI. Besides the streams above, you can drive it from other mpv scripts and from the [launcher CLI](/launcher-script).
The mpv plugin accepts these script messages:
@@ -227,7 +227,7 @@ The mpv plugin accepts these script messages:
script-message subminer-start backend=hyprland socket=/custom/path texthooker=no log-level=debug
```
The plugin also registers `subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, and `subminer-reload-session-bindings`. The SubMiner app sends these to keep the plugin in sync, so do not send them from your own scripts.
The plugin also registers `subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, `subminer-overlay-loading-dismissed`, and `subminer-reload-session-bindings`. The SubMiner app sends these to keep the plugin in sync, so do not send them from your own scripts.
While the app is connected to mpv, it also handles two AniSkip messages over the mpv IPC socket: `subminer-skip-intro` skips the intro, and `subminer-aniskip-refresh` reloads intro data, for example after your script changes title or episode metadata.
+10 -10
View File
@@ -54,10 +54,10 @@ files before signing and generating updater hashes, never from a signed app.
4. Bump `package.json` to the release version.
5. Build release metadata before tagging (this calls `claude -p` locally):
`bun run changelog:build --version <version> --date <yyyy-mm-dd>`
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed
- The polished `CHANGELOG.md` and `packaging/release-notes/release-notes.md` are committed
before tagging. Release CI no longer auto-builds the changelog; it fails
fast if `changes/*.md` fragments are still present on a tag-based run.
6. Review `CHANGELOG.md` and `release/release-notes.md`. Edit by hand if Claude
6. Review `CHANGELOG.md` and `packaging/release-notes/release-notes.md`. Edit by hand if Claude
missed something — the committed Markdown is what ships.
7. Run release gate locally:
`bun run changelog:check --version <version>`
@@ -73,9 +73,9 @@ files before signing and generating updater hashes, never from a signed app.
`bun-v<version>-source.tar.gz` plus its `.sha256` file. The script
fails if Bun's CMake dependency pins, WebKit pin, source checksums, patch
inputs, or collected license files differ from
`build/bun-source-manifest.json`.
`packaging/bun/bun-source-manifest.json`.
When validating auto-update metadata, also run the relevant platform package
build and confirm `release/` contains the generated updater metadata
build and confirm `dist/release/` contains the generated updater metadata
(`latest*.yml`) and blockmaps (`*.blockmap`).
8. If `docs-site/` changed, also run:
`bun run docs:test`
@@ -103,9 +103,9 @@ files before signing and generating updater hashes, never from a signed app.
source archive. A missing source repository or license file fails the
release instead of publishing only the executable.
When validating packaged updater output, confirm the platform build writes
`latest*.yml` and `*.blockmap` files under `release/`.
`latest*.yml` and `*.blockmap` files under `dist/release/`.
5. Commit the prerelease prep (package.json version bump + the generated
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
`packaging/release-notes/prerelease-notes.md`). CI does not regenerate notes — it uses the
committed file — so review it before committing. Rerun
`bun run changelog:prerelease-notes --version <version>` for every later
beta/RC, even if no fragments changed: the notes carry a hidden
@@ -128,14 +128,14 @@ Notes:
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
- `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 `packaging/release-notes/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.
- From the second prerelease of a base version onward, the notes open with a `## Changes since <previous tag>` section above the cumulative `## Highlights`. The generator locates the newest preceding beta/RC tag for the same base version (semver order: all betas before all RCs), diffs `changes/*.md` between that tag and the working tree, and asks Claude to describe only the behavioral beta-to-beta differences — added fragments as new changes, modified fragments by their before/after difference (editorial-only edits are dropped), deleted fragments as removed/reverted changes. If no fragments changed (for example a packaging-only rebuild), the section states that explicitly without a Claude call. The delta section carries no separate contributor attribution; `## What's Changed` stays cumulative like `## Highlights`.
- `changelog:check-prerelease-notes --version <version>` verifies the committed notes' `prerelease-version` marker matches the version being tagged; the prerelease workflow runs it and fails the release on stale notes.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
- `changelog:build` generates `CHANGELOG.md` + `packaging/release-notes/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.
- `packaging/release-notes/release-notes.md` (and `packaging/release-notes/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
- Do not tag while `changes/*.md` fragments still exist.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
- CI, stable release, and prerelease workflows call the shared `.github/workflows/quality-gate.yml`; update that reusable workflow when changing common test coverage. Its environment suite includes the Lua mpv plugin tests.
+2
View File
@@ -37,10 +37,12 @@ Update checks and startup launcher migration share a serialized update-state sto
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them first in the `will-quit` cleanup, before any teardown. Plugins run in the main process with no sandbox. `src/plugin-api.ts` is the standalone public v1 type contract; internal mpv event and session-action types derive from it. Manifests may declare `apiVersion`, which is checked before loading code. Async activation is bounded and actions wait only for their own plugin. The host owns cached media snapshots and playback ids, including repeated loads of the same path. `ctx.signal` aborts as unloading starts; `ctx.onDispose` manages outside resources and late registrations are disposed immediately. Awaitable `ctx.mpv.request` uses the existing request/response transport. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` as `ctx.dataDir`. `ctx.anki` carries the `card-mined` event with unique kept `noteIds` and per-original-note records. Sentence-card creation snapshots media and subtitle context before asynchronous work; the proxy snapshots before forwarding an upstream request. `AnkiIntegration` keeps provenance under original ids through merges and queued YouTube jobs; polling uses null provenance. The event is fed by `AnkiIntegration.setCardsReadyCallback`: polling, the AnkiConnect proxy, and sentence-card creation report a note once its enrichment finishes (or when they see it, if auto-update is off); `AnkiIntegration` holds a sentence card's report until field grouping finishes, and the pending YouTube media queue reports notes whose media it is still waiting on (directly or through the note field grouping merged them into), which is later than `recordCardsMined` (stats counts at add time). Stats-dashboard mining does not emit it. `ctx.anki.invoke` sends one action to the effective `ankiConnect.url` with no retries. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
- `src/renderer/` owns overlay rendering and input behavior.
- `src/config/` owns config definitions, defaults, loading, and resolution.
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
- `src/main/runtime/composers/` owns larger domain compositions.
- `src/main/assemblies/` owns per-domain assemblies that `src/main.ts` builds once (see [Layering](./layering.md)).
- `src/main.ts` call sites invoke configured runtime handlers and runtime-object methods directly;
do not add local pass-through wrappers around them.
+27 -5
View File
@@ -3,7 +3,7 @@
# Domain Ownership
Status: active
Last verified: 2026-07-15
Last verified: 2026-10-31
Owner: Kyle Yasuda
Read when: you need to find the owner module for a behavior or test surface
@@ -19,21 +19,43 @@ Read when: you need to find the owner module for a behavior or test surface
- Config system: `src/config/`; Anki resolution is composed by
`src/config/resolve/anki-connect.ts` from focused resolvers in
`src/config/resolve/anki-connect/`
Config hot-reload and the settings window: `src/main/assemblies/settings.ts`
- App startup: app-ready sequence, headless known-word refresh, overlay startup prerequisites and
overlay runtime bootstrap: `src/main/assemblies/startup.ts`
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
Overlay windows, visibility, Linux fullscreen handling, and the overlay-window IPC handlers
(modal open/close, pointer interaction, content measurement): `src/main/assemblies/overlay.ts`
Overlay-hosted modal openers: `src/main/assemblies/overlay-modals.ts`
- MPV runtime and protocol: `src/core/services/mpv*.ts`
mpv client creation, mpv event wiring, AniSkip, subtitle prefetch, and subtitle sidebar state: `src/main/assemblies/playback.ts`
- Keyboard input outside the renderer (overlay, global and numeric shortcuts, session bindings):
`src/main/assemblies/shortcuts.ts`
- System tray: `src/main/assemblies/tray.ts`
- Yomitan/Hachidori extension loading, parser runtime deps, and dictionary settings windows:
`src/main/assemblies/yomitan.ts`
Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows.
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
JLPT and frequency annotation lookups: `src/main/jlpt-runtime.ts`, `src/main/frequency-dictionary-runtime.ts`, assembled by `src/main/assemblies/dictionaries.ts`
Subtitle text filters (`subtitleFilter`): `src/shared/subtitle-text-filter.ts`. Primary text is filtered at its two entry points, live `sub-text` in `mpv-protocol.ts` and parsed cues in `playback.ts`, so every consumer sees the same text and live/cue matching still works. A config change re-reads `sub-text` and re-parses the active track (the prefetch controller's parse key).
- First-run setup: `src/main/runtime/first-run-setup-*.ts`, assembled by `src/main/assemblies/first-run-setup.ts`
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
Card mining handlers, the pending lookup context, field grouping, and media timing review wiring:
`src/main/assemblies/mining.ts`
- Media timing review: `src/main/runtime/media-timing-review.ts` owns the review and hidden `MediaTimingPreviewSession`. The session waits for mpv readiness and acknowledges playback commands; its `time-pos` observations reach the modal through `media-timing-review:preview-position`. The renderer places the cursor from those timestamps and uses an inactivity timeout instead of timing the clip itself.
- Immersion tracking: `src/core/services/immersion-tracker/`
- Immersion tracking: `src/core/services/immersion-tracker/`; tracker startup, the stats server,
the stats overlay window options, and the `--stats` command are assembled by `src/main/assemblies/stats.ts`
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions.
`delete-maintenance-scheduler.ts` coalesces and serializes stats deletes; the expensive work runs in `delete-maintenance-worker-thread.ts` while the tracker queues playback writes. Each batch uses one transaction, lexical update, rollup refresh, and incremental lifetime subtraction (`planLifetimeRemovals`/`applyLifetimeRemovals` in `lifetime.ts`). Merges, moves, AniList reassignments, and `stats cleanup -l` use `repairLifetimeSummariesFromMedia` (recompute from the per-video media ledger). The full lifetime rebuild survives only as the empty-table bootstrap; anywhere else it would collapse lifetime totals to the session retention window.
- Immersion sync: `src/core/services/stats-sync/`, bound by `src/main/sync-cli.ts`.
- Immersion sync: `src/core/services/stats-sync/`, bound by `src/main/sync-cli.ts`. The sync window and auto-sync scheduler live in `src/main/assemblies/sync.ts`.
`snapshot-transfer.ts` selects compressed rsync or scp. `transfer-cache.ts` atomically retains the last successfully received snapshot per hashed peer/database identity under the config directory's `sync-transfer-cache/`. Cache copies seed isolated transfer directories; rsync verifies reconstructed files before the existing merge engine runs. The `--make-temp` / `--remove-temp` helpers accept an internal `--transfer-cache` key, with a fallback for older peers that do not recognize it.
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*` (called by `src/main/assemblies/anilist.ts`), `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`, assembled by `src/main/assemblies/character-dictionary.ts`
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`,
assembled by `src/main/assemblies/jellyfin.ts`
- YouTube playback, subtitles, media cache, and in-app browser: `src/core/services/youtube/`,
`src/main/runtime/youtube-*`, assembled by `src/main/assemblies/youtube.ts`
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/main/runtime/plugin-window-runtime.ts` (plugin windows), `src/renderer/modals/plugin-panel*.ts`; examples in `assets/plugins/`
- Window trackers: `src/window-trackers/`
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
in `src/core/services/stats-server/`
+18 -1
View File
@@ -3,7 +3,7 @@
# Layering Rules
Status: active
Last verified: 2026-05-23
Last verified: 2026-10-31
Owner: Kyle Yasuda
Read when: deciding whether a dependency direction is acceptable
@@ -19,6 +19,23 @@ Renderer, launcher, plugin, and stats each keep their own local layering and sho
## Rules
- Keep `src/main.ts` thin; wire, do not implement.
- Pass deps straight to a handler factory (`createXHandler({...})`). Do not add
deps builders (`createBuild*MainDepsHandler`, `*-main-deps.ts`) that re-wrap deps.
Adaptation logic (defaults, appState getters/setters, shared state) belongs in the
handler or in a named adapter that returns the deps directly.
- Group a domain's runtimes in one assembly under `src/main/assemblies/` (for example
`createYoutubeAssembly(deps)`). It owns the domain's state, takes an explicit deps
object, and returns its runtimes; `main.ts` builds it once and calls through the result.
Siblings built later in `main.ts` are passed as getters or callbacks, and construction must
not call them.
- An assembly that answers renderer IPC or CLI commands returns them as typed slices
(`IpcSlice<...>` / `CliSlice<...>` from `src/main/assemblies/slices.ts`). `main.ts`
spreads the slices into the registration and checks the result with
`satisfies Required<...>`, so a missing handler fails typecheck. Only app-level handlers
with no owning assembly stay inline in `main.ts`.
- Electron modules that value-import named Electron APIs (`dialog`, `screen`, `shell`,
`clipboard`, `globalShortcut`, `Tray`, ...) cannot load under `bun test`. Pass those APIs
into an assembly as deps instead of importing them there.
- Prefer injecting dependencies from `src/main/` instead of reaching outward from core services.
- Keep side effects explicit and close to composition boundaries.
- Put reusable business logic in focused services, not in top-level lifecycle files.
+6 -2
View File
@@ -21,8 +21,8 @@ good test looks like here.
## Do Not Write
- Pass-through tests: a builder or handler that only forwards deps (`*-main-deps.ts`,
`*-runtime-handlers.ts`) needs no test. TypeScript already checks the shape.
- Pass-through tests: a handler or adapter that only forwards deps
(`*-runtime-handlers.ts`) needs no test. TypeScript already checks the shape.
- Source-text tests: reading `.ts`, `.css`, `.html`, `.lua`, `.swift`, or workflow YAML
and asserting with regexes. If the invariant matters, extract the logic and run it
(see `src/preload-ipc-listeners.ts`). Policy lints over workflow YAML are the
@@ -47,3 +47,7 @@ good test looks like here.
- `src/main/main-wiring.test.ts` and `src/renderer/renderer-init-order.test.ts` still
regex `main.ts` / `renderer.ts` to guard startup ordering. Replace each test with a
behavioral one as the logic moves out of `main.ts` closures.
- `src/main/runtime/overlay-geometry-runtime.ts` and
`src/main/runtime/visible-overlay-interaction-runtime.ts` value-import Electron, so they
cannot load under `bun test` and stay outside `src/main/assemblies/overlay.ts`. Inject
`screen`/`dialog` to make them testable and movable.
+5 -3
View File
@@ -62,7 +62,7 @@ bun run docs:build
then `bun run test:e2e` (see [Headless End-to-End Harness](#headless-end-to-end-harness))
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
On headless Linux: `xvfb-run -a bun run test:package release/linux-unpacked/resources`.
On headless Linux: `xvfb-run -a bun run test:package dist/release/linux-unpacked/resources`.
Content checks run inside the electron-builder afterPack hook. See the
[release guide](../RELEASING.md#package-contents-checks) for the
installed-app verification checklist.
@@ -123,9 +123,11 @@ whenever the behavior does not depend on a specific compositor.
per-window screenshots), mpv over its JSON IPC socket, and app commands through the
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through
`startE2eSession()`. Pass `config` to override settings for a scenario file.
`startE2eSession()`. Pass `config` to override settings for a scenario file, and `files`
to write files such as plugins into the isolated config dir before launch.
- On a failed startup the temp dir is kept and its path is in the error. Set
`SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`).
`SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`,
`mpv-stdio.log`).
For exploratory checks, keep one session up and poke at it:
+35 -5
View File
@@ -37,10 +37,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
// A recording AnkiConnect stand-in: an empty collection with one deck and one
// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes.
// A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type.
// Added notes (with later `updateNoteFields` writes) are echoed back by `notesInfo`
// so the app can resolve their fields; scenarios still assert on `requests`.
export async function startFakeAnki(): Promise<FakeAnki> {
const requests: AnkiRequest[] = [];
const addedNotes = new Map<number, Record<string, unknown>>();
let nextNoteId = 1000;
const handle = (body: unknown): unknown => {
@@ -59,10 +61,38 @@ export async function startFakeAnki(): Promise<FakeAnki> {
return [FAKE_ANKI_MODEL];
case 'modelFieldNames':
return MODEL_FIELDS;
case 'addNote':
return nextNoteId++;
case 'addNote': {
const noteId = nextNoteId++;
const note = isRecord(params.note) ? params.note : {};
addedNotes.set(noteId, isRecord(note.fields) ? { ...note.fields } : {});
return noteId;
}
case 'updateNoteFields': {
const note = isRecord(params.note) ? params.note : {};
const fields = typeof note.id === 'number' ? addedNotes.get(note.id) : undefined;
if (fields && isRecord(note.fields)) Object.assign(fields, note.fields);
return null;
}
case 'notesInfo': {
// Like AnkiConnect: one entry per requested id, `{}` for unknown notes.
const ids = Array.isArray(params.notes) ? params.notes : [];
return ids.map((id) => {
const fields = typeof id === 'number' ? addedNotes.get(id) : undefined;
if (!fields) return {};
return {
noteId: id,
modelName: FAKE_ANKI_MODEL,
tags: [],
fields: Object.fromEntries(
MODEL_FIELDS.map((name, order) => [
name,
{ value: String(fields[name] ?? ''), order },
]),
),
};
});
}
case 'findNotes':
case 'notesInfo':
return [];
case 'storeMediaFile':
return params.filename ?? null;
+39 -4
View File
@@ -15,25 +15,38 @@ export type MpvClient = {
type MpvReply = { request_id?: number; error?: string; data?: unknown };
function videoOutputArgs(display: E2eDisplay): string[] {
// Xvfb has no GPU; the software X11 output always works there.
if (display.kind === 'xvfb') return ['--vo=x11'];
// gpu-next's Vulkan path (libplacebo over MoltenVK) segfaults on the first frame
// on GitHub's paravirtual macOS GPU with libplacebo 7.387. `--vo=libmpv` is mpv's
// cocoa-cb OpenGL output, which avoids it and still opens the window the overlay tracks.
if (process.platform === 'darwin') return ['--vo=libmpv'];
return [];
}
export async function startMpv(options: {
socketPath: string;
mediaPath: string;
subtitlePath: string;
/** mpv's own verbose log (`--log-file`); `--no-terminal` silences its console output. */
logPath: string;
/** Process stdout/stderr, which still catches crash output from mpv's libraries. */
stdioLogPath: string;
display: E2eDisplay;
}): Promise<ChildProcess> {
const log = fs.openSync(options.logPath, 'w');
const log = fs.openSync(options.stdioLogPath, 'w');
const child = spawn(
'mpv',
[
'--no-config',
'--no-terminal',
`--log-file=${options.logPath}`,
'--pause',
'--keep-open=yes',
'--ao=null',
`--geometry=${E2E_SCREEN.width}x${E2E_SCREEN.height}+0+0`,
// Xvfb has no GPU; the software X11 output always works there.
...(options.display.kind === 'xvfb' ? ['--vo=x11'] : []),
...videoOutputArgs(options.display),
`--input-ipc-server=${options.socketPath}`,
`--sub-file=${options.subtitlePath}`,
options.mediaPath,
@@ -58,13 +71,31 @@ function tryConnect(socketPath: string): Promise<net.Socket | null> {
});
}
export async function connectMpv(socketPath: string): Promise<MpvClient> {
/**
* Connects to mpv's JSON IPC socket. Once the socket closes (mpv exited or
* crashed), pending and later commands reject instead of hanging until the test
* timeout; `describeClose` adds context such as mpv's exit status.
*/
export async function connectMpv(
socketPath: string,
describeClose: () => string = () => 'mpv IPC connection closed',
): Promise<MpvClient> {
const socket = await waitUntil(() => tryConnect(socketPath), {
description: `mpv IPC socket ${socketPath}`,
});
const pending = new Map<number, (reply: MpvReply) => void>();
let nextRequestId = 1;
let buffer = '';
let closedError: Error | null = null;
const failPending = () => {
closedError ??= new Error(describeClose());
for (const settle of pending.values()) settle({ error: closedError.message });
pending.clear();
};
socket.on('close', failPending);
// 'close' always follows 'error'; listening here keeps the error from being thrown.
socket.on('error', () => {});
socket.setEncoding('utf8');
socket.on('data', (chunk: string) => {
@@ -83,6 +114,10 @@ export async function connectMpv(socketPath: string): Promise<MpvClient> {
return {
command: (...args) =>
new Promise((resolve, reject) => {
if (closedError) {
reject(new Error(`mpv ${JSON.stringify(args)} failed: ${closedError.message}`));
return;
}
const requestId = nextRequestId++;
pending.set(requestId, (reply) => {
if (reply.error === 'success') resolve(reply.data);
+51 -23
View File
@@ -28,6 +28,8 @@ type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
export type E2eSessionOptions = {
/** Deep-merged over the harness config, for scenarios that need other settings. */
config?: JsonObject;
/** Files written before launch, keyed by path relative to the config dir (e.g. plugins). */
files?: Record<string, string>;
};
export type AppTarget = {
@@ -57,6 +59,8 @@ export type E2eSession = {
mainProcess: () => Promise<CdpPage>;
/** Runs a CLI command (e.g. `--mine-sentence`) against the running instance. */
runAppCommand: (...args: string[]) => Promise<void>;
/** After loading a file: waits for the app's autoplay resume, then leaves mpv paused. */
settleAfterResume: () => Promise<void>;
dispose: () => Promise<void>;
};
@@ -155,6 +159,30 @@ async function openPage(cdpPort: number, urlPart: string): Promise<CdpPage> {
return page;
}
// mpv starts paused and the app resumes each newly loaded file once the overlay
// and tokenizer are ready, so that resume marks the file as ready. Scenarios
// expect a paused player, and the app retries its resume for a moment, so keep
// pausing until the pause has held for longer than that retry window.
async function settleAfterResume(mpv: MpvClient): Promise<void> {
await waitUntil(async () => (await mpv.command('get_property', 'pause')) === false, {
description: 'SubMiner to resume playback once the file is ready',
timeoutMs: 30_000,
});
let pausedSince: number | null = null;
await waitUntil(
async () => {
if ((await mpv.command('get_property', 'pause')) !== true) {
pausedSince = null;
await mpv.command('set_property', 'pause', true);
return false;
}
pausedSince ??= Date.now();
return Date.now() - pausedSince >= 1_500;
},
{ description: 'playback to stay paused after the resume', timeoutMs: 40_000 },
);
}
export async function startE2eSession(options: E2eSessionOptions = {}): Promise<E2eSession> {
if (!fs.existsSync(path.join(repoRoot, 'dist', 'main-entry.js'))) {
throw new Error('dist/ is missing. Run `bun run build` before the e2e harness.');
@@ -193,6 +221,15 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
for (const [relativePath, content] of Object.entries(options.files ?? {})) {
const filePath = path.resolve(userDataDir, relativePath);
const inside = path.relative(userDataDir, filePath);
if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) {
throw new Error(`E2E file path must stay inside the config dir: ${relativePath}`);
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
const env: EnvDelta = {
set: {
@@ -265,39 +302,29 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
settings.close();
await stopProcess(seeder.child);
const mpvLogPath = path.join(root, 'mpv.log');
const mpvStdioLogPath = path.join(root, 'mpv-stdio.log');
const mpvProcess = await startMpv({
socketPath: mpvSocketPath,
mediaPath,
subtitlePath,
logPath: path.join(root, 'mpv.log'),
logPath: mpvLogPath,
stdioLogPath: mpvStdioLogPath,
display,
});
teardown.push(() => stopProcess(mpvProcess));
const mpv = await connectMpv(mpvSocketPath);
const mpv = await connectMpv(mpvSocketPath, () => {
const { exitCode, signalCode } = mpvProcess;
const status =
exitCode === null && signalCode === null
? 'mpv IPC connection closed'
: `mpv exited (code ${String(exitCode)}, signal ${String(signalCode)})`;
return `${status}; see ${mpvLogPath} and ${mpvStdioLogPath}`;
});
teardown.push(mpv.close);
const app = await launchApp(['--start']);
// mpv starts paused and the app resumes it once the overlay and tokenizer
// are ready, so that resume marks the end of startup.
await waitUntil(async () => (await mpv.command('get_property', 'pause')) === false, {
description: 'SubMiner to resume playback after overlay startup',
timeoutMs: 30_000,
});
// Hand scenarios a paused player. The app retries its resume for a moment,
// so keep pausing until the pause has held for longer than that retry window.
let pausedSince: number | null = null;
await waitUntil(
async () => {
if ((await mpv.command('get_property', 'pause')) !== true) {
pausedSince = null;
await mpv.command('set_property', 'pause', true);
return false;
}
pausedSince ??= Date.now();
return Date.now() - pausedSince >= 1_500;
},
{ description: 'playback to stay paused after startup', timeoutMs: 40_000 },
);
await settleAfterResume(mpv);
const pages: CdpPage[] = [];
teardown.push(() => pages.forEach((page) => page.close()));
@@ -321,6 +348,7 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
return page;
},
runAppCommand: (...args) => runAppCommand(target, args),
settleAfterResume: () => settleAfterResume(mpv),
dispose,
};
} catch (error) {
+93 -4
View File
@@ -91,6 +91,14 @@ async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToke
});
}
// Card-kind flags are written before media, so wait for the update carrying audio.
function findMediaUpdate(session: E2eSession) {
return session.anki.requests.find(
(request) =>
request.action === 'updateNoteFields' && JSON.stringify(request.params).includes('[sound:'),
);
}
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
return tokens.some((token) => token.surface === surface && token.headword === headword);
}
@@ -126,10 +134,10 @@ test(
session.anki.requests.length = 0;
await session.runAppCommand('--mine-sentence');
const update = await waitUntil(
() => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
{ description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
);
const update = await waitUntil(() => findMediaUpdate(session), {
description: 'media fields to be written to the mined note',
timeoutMs: 30_000,
});
const added = session.anki.requests.filter((request) => request.action === 'addNote');
assert.equal(added.length, 1);
@@ -156,6 +164,44 @@ test(
},
);
test(
'mining a line retimed with sub-delay clips it where mpv plays it',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
const cue = FIXTURE_CUES[1];
const delay = 2;
await session.mpv.command('set_property', 'sub-delay', delay);
try {
// The previous test leaves this cue on screen, so blank the line first; otherwise the wait
// below passes before the seek lands and mining can catch mpv's empty mid-seek sub-text.
await session.mpv.command('seek', FIXTURE_CUES[0].start / 2, 'absolute+exact');
await overlay.waitFor(`document.getElementById('subtitleRoot').textContent === ''`, {
description: 'overlay to clear the line before the first delayed cue',
});
// Past the cue's own end, so the line is only on screen because of the delay.
await session.mpv.command('seek', cue.end + delay / 2, 'absolute+exact');
await overlay.waitFor(
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
{ description: 'overlay to show the delayed line' },
);
session.anki.requests.length = 0;
await session.runAppCommand('--mine-sentence');
await waitUntil(() => findMediaUpdate(session), {
description: 'media fields to be written to the mined note',
timeoutMs: 30_000,
});
// MiscInfo stamps the clip start: the cue's start on the video clock.
const written = JSON.stringify(session.anki.requests);
assert.ok(written.includes(`(00:00:0${cue.start + delay})`), written);
} finally {
await session.mpv.command('set_property', 'sub-delay', 0);
}
},
);
test(
'open subtitle sidebar lists the parsed cues and follows playback',
{ timeout: TEST_TIMEOUT_MS },
@@ -184,6 +230,49 @@ test(
},
);
test(
'subtitle sidebar follows and seeks a sub-delay retimed track',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
const delay = 2;
await session.mpv.command('set_property', 'sub-delay', delay);
try {
// At 9s cue 2 starts on the file's clock, but mpv is still showing cue 1.
await session.mpv.command('seek', FIXTURE_CUES[2].start, 'absolute+exact');
await overlay.waitFor(
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(FIXTURE_CUES[1].text)}`,
{ description: 'overlay to show the delayed line' },
);
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '1'`,
{ description: 'sidebar to highlight the delayed line' },
);
await overlay.evaluate(
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item[data-index="2"]').click()`,
);
const target = FIXTURE_CUES[2].start + delay;
await waitUntil(
async () => {
const time = Number(await session.mpv.command('get_property', 'time-pos'));
return time >= target && time < target + 0.5;
},
{ description: `click to seek to cue 2 at ${target}s on the video clock` },
);
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
{ description: 'sidebar to close' },
);
} finally {
await session.mpv.command('set_property', 'sub-delay', 0);
}
},
);
// Runs last: it leaves the lookup popup open over the overlay.
test(
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
+346
View File
@@ -0,0 +1,346 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test, { after } from 'node:test';
import type { CdpPage } from './harness/cdp';
import { FIXTURE_CUES } from './harness/fixtures';
import { OVERLAY_PAGE, startE2eSession, type E2eSession } from './harness/session';
import { waitUntil } from './harness/wait';
// Bun caps node:test hooks at 5s whatever their timeout option says, so the
// session boots inside whichever test runs first and every test budgets for it.
const TEST_TIMEOUT_MS = 120_000;
// Opens a panel describing the subtitle context and a child process result, and
// logs when the panel's signal aborts so the test can see the close reach main.
const PROBE_PLUGIN = `
const { ipcMain } = require('electron');
exports.activate = (ctx) => {
ipcMain.handle('e2e-probe:info', () => ({ dataDir: ctx.dataDir, greeting: ctx.settings.greeting }));
ctx.onDispose(() => ipcMain.removeHandler('e2e-probe:info'));
ctx.media.on('change', (media) => ctx.log.info('probe media ' + JSON.stringify(media)));
ctx.anki.on('card-mined', ({ notes }) => ctx.log.info('probe mined ' + JSON.stringify(notes)));
ctx.actions.register('subtitle', () => ctx.log.info('probe subtitle ' + JSON.stringify(ctx.subtitles.current())));
ctx.actions.register('request', async () => {
const title = await ctx.mpv.request(['get_property', 'media-title']);
ctx.log.info('probe request ' + JSON.stringify(title));
try { await ctx.mpv.request(['subminer-no-such-command']); }
catch { ctx.log.info('probe request rejected'); }
});
ctx.actions.register('window', () => {
ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', title: 'E2E Probe Window' });
});
ctx.mpv.on('end-file', ({ reason }) => ctx.log.info('probe end-file ' + reason));
ctx.actions.register('show', () => {
const { before, current, after } = ctx.subtitles.context({ before: 1, after: 1 });
const panel = ctx.ui.panel({ title: 'Probe', markdown: '_working_' });
panel.signal.addEventListener('abort', () => ctx.log.info('probe panel aborted'));
ctx
.exec(process.execPath, ['-e', 'process.stdout.write("child-ok")'], {
env: { ELECTRON_RUN_AS_NODE: '1' },
signal: panel.signal,
})
.then((result) =>
panel.update({
markdown: [
'## ' + ctx.settings.greeting,
'',
'- before: ' + before.map((line) => line.text).join('|'),
'- current: **' + (current ? current.text : '') + '**',
'- after: ' + after.map((line) => line.text).join('|'),
'- exec: ' + result.stdout,
].join('\\n'),
}),
)
.catch((error) => panel.update({ markdown: 'exec failed: ' + error.message }));
});
};
`;
const PROBE_PRELOAD = `
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('probe', { info: () => ipcRenderer.invoke('e2e-probe:info') });
`;
const PROBE_WINDOW_HTML = `<!doctype html>
<html>
<head><title>E2E Probe Window</title></head>
<body>
<pre id="out">loading</pre>
<script>
window.probe.info().then((info) => {
document.getElementById('out').textContent = JSON.stringify(info);
});
</script>
</body>
</html>
`;
type Started = { session: E2eSession; overlay: CdpPage };
let started: Promise<Started> | undefined;
function useSession(): Promise<Started> {
started ??= (async () => {
const session = await startE2eSession({
config: { plugins: { 'e2e-probe': { enabled: true, settings: { greeting: 'hello' } } } },
files: {
'plugins/e2e-probe/plugin.json': JSON.stringify({ name: 'E2E Probe', main: 'main.cjs' }),
'plugins/e2e-probe/main.cjs': PROBE_PLUGIN,
'plugins/e2e-probe/preload.js': PROBE_PRELOAD,
'plugins/e2e-probe/ui/index.html': PROBE_WINDOW_HTML,
},
});
try {
return { session, overlay: await session.openPage(OVERLAY_PAGE) };
} catch (error) {
await session.dispose();
throw error;
}
})();
return started;
}
after(async () => {
const running = await started?.catch(() => undefined);
await running?.session.dispose();
});
type PanelState = {
title: string;
heading: string;
current: string;
items: string[];
};
test(
'a plugin action opens the plugin panel with subtitle context and child process output',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
const [previous, cue, next] = FIXTURE_CUES;
await session.mpv.command('seek', cue.at, 'absolute+exact');
await overlay.waitFor(
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
{ description: `overlay to show "${cue.text}"` },
);
await session.runAppCommand(
'--session-action',
JSON.stringify({
actionId: 'runPluginAction',
payload: { pluginActionId: 'e2e-probe/show' },
}),
);
const modal = await session.openPage('renderer/index.html?layer=modal');
const panel = await modal
.waitFor<PanelState | false>(
`(() => {
const root = document.getElementById('pluginPanelModal');
const body = document.getElementById('pluginPanelBody');
if (root.classList.contains('hidden') || !/exec[: ]/.test(body.textContent)) return false;
return {
title: document.getElementById('pluginPanelTitle').textContent,
heading: body.querySelector('h4')?.textContent ?? '',
current: body.querySelector('strong')?.textContent ?? '',
items: Array.from(body.querySelectorAll('li'), (item) => item.textContent),
};
})()`,
{ description: 'plugin panel to show the finished probe result' },
)
.catch(async (error: unknown) => {
const body = await modal.evaluate<string>(
`document.getElementById('pluginPanelBody').textContent`,
);
throw new Error(`Plugin panel body: ${JSON.stringify(body)}`, { cause: error });
});
assert.deepEqual(panel, {
title: 'Probe',
heading: 'hello',
current: cue.text,
items: [
`before: ${previous.text}`,
`current: ${cue.text}`,
`after: ${next.text}`,
'exec: child-ok',
],
});
// Closing hides the modal window (Linux destroys it), often before Chromium
// acknowledges the key event, so the send is not awaited. The plugin's log line
// proves the close reached main and aborted the handle.
void modal
.send('Input.dispatchKeyEvent', {
type: 'rawKeyDown',
key: 'Escape',
code: 'Escape',
windowsVirtualKeyCode: 27,
})
.catch(() => {});
await waitUntil(
() =>
fs.readFileSync(path.join(session.root, 'app.log'), 'utf8').includes('probe panel aborted'),
{ description: 'closing the panel to abort the plugin handle in main' },
);
},
);
test(
'a CLI plugin action opens a plugin window whose preload talks to the plugin',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session } = await useSession();
await session.runAppCommand('--plugin-action', 'e2e-probe/window');
const page = await session.openPage('plugins/e2e-probe/ui/index.html');
const info = await page.waitFor<{ dataDir: string; greeting: string } | false>(
`(() => {
const text = document.getElementById('out')?.textContent;
return text && text !== 'loading' ? JSON.parse(text) : false;
})()`,
{ description: 'plugin window to read plugin info over IPC' },
);
assert.equal(info.greeting, 'hello');
assert.equal(path.basename(info.dataDir), 'e2e-probe');
assert.equal(path.basename(path.dirname(info.dataDir)), 'plugin-data');
assert.equal(fs.statSync(info.dataDir).isDirectory(), true);
},
);
test('plugins receive mpv end-file events', { timeout: TEST_TIMEOUT_MS }, async () => {
const { session } = await useSession();
const mediaPath = await session.mpv.command('get_property', 'path');
// Replacing the playing file ends it with reason "stop".
await session.mpv.command('loadfile', String(mediaPath), 'replace');
await waitUntil(
() =>
fs.readFileSync(path.join(session.root, 'app.log'), 'utf8').includes('probe end-file stop'),
{ description: 'the plugin to log the end-file reason' },
);
// Later tests need the replacement loaded and paused: a sub-add during the reload lands on
// the outgoing file, and the app's autoplay resume would keep cues moving under them.
await session.settleAfterResume();
});
test(
'plugin subtitle lookup uses the subtitle clock for repeated retimed lines',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
// Unlike any fixture cue, so the overlay showing it proves main received this line rather
// than still displaying one from an earlier test.
const text = '今日は猫が好きです。';
const subtitlePath = path.join(session.root, 'repeated.srt');
fs.writeFileSync(
subtitlePath,
`1\n00:00:01,000 --> 00:00:03,000\n${text}\n\n2\n00:00:06,000 --> 00:00:08,000\n${text}\n`,
);
const originalSid = await session.mpv.command('get_property', 'sid');
// Runs the probe action and returns the cue it logged on its nth run.
const reportSubtitle = async (run: number): Promise<unknown> => {
await session.runAppCommand('--plugin-action', 'e2e-probe/subtitle');
const line = await waitUntil(
() =>
fs
.readFileSync(path.join(session.root, 'app.log'), 'utf8')
.split('\n')
.filter((entry) => entry.includes('probe subtitle '))[run - 1],
{ description: `the plugin to report its current subtitle (run ${run})` },
);
assert.ok(line);
return JSON.parse(line.slice(line.indexOf('probe subtitle ') + 'probe subtitle '.length));
};
const seekToCue = async (time: number, cueIndex: number): Promise<void> => {
await session.mpv.command('seek', time, 'absolute+exact');
// time-pos can activate the sidebar while mpv is still rendering the seek with
// sub-text cleared. Wait for the seek, then for main to receive the live text.
await waitUntil(
async () => (await session.mpv.command('get_property', 'seeking')) === false,
{
description: `the seek to ${time}s to finish`,
},
);
await overlay.waitFor(
`(async () => {
const text = await window.electronAPI.getCurrentSubtitleRaw();
return text === ${JSON.stringify(text)} &&
document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item')[${cueIndex}]?.classList.contains('active') === true;
})()`,
{ description: `delayed cue ${cueIndex + 1} to be active with live subtitle text` },
);
};
try {
await session.mpv.command('seek', 0, 'absolute+exact');
await session.mpv.command('sub-add', subtitlePath, 'select');
await session.mpv.command('set_property', 'sub-delay', 5);
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item').length === 2`,
{ description: 'the repeated subtitle cues to be parsed' },
);
await seekToCue(7, 0);
assert.deepEqual(await reportSubtitle(1), { text, startTime: 1, endTime: 3 });
// Same text again at 6-8 on the subtitle clock, so only the timings tell the cues apart.
await seekToCue(12, 1);
assert.deepEqual(await reportSubtitle(2), { text, startTime: 6, endTime: 8 });
} finally {
await session.mpv.command('set_property', 'sub-delay', 0);
await session.mpv.command('set_property', 'sid', originalSid);
await session.runAppCommand('--toggle-subtitle-sidebar');
}
},
);
test(
'plugin requests report command failures and mined notes carry the captured source',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
const cue = FIXTURE_CUES[1];
await session.mpv.command('seek', cue.at, 'absolute+exact');
await overlay.waitFor(
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
{ description: 'the mined subtitle to be visible' },
);
await session.runAppCommand('--plugin-action', 'e2e-probe/request');
await waitUntil(
() =>
fs
.readFileSync(path.join(session.root, 'app.log'), 'utf8')
.includes('probe request rejected'),
{ description: 'mpv to reject an invalid plugin request' },
);
await session.runAppCommand('--mine-sentence');
const log = await waitUntil(
() => {
const text = fs.readFileSync(path.join(session.root, 'app.log'), 'utf8');
return text.includes('probe mined ') ? text : null;
},
{ description: 'the mining event to reach the plugin' },
);
assert.ok(log);
const line = log.split('\n').find((entry) => entry.includes('probe mined '));
assert.ok(line);
const notes: unknown = JSON.parse(
line.slice(line.indexOf('probe mined ') + 'probe mined '.length),
);
assert.ok(Array.isArray(notes));
assert.equal(notes.length, 1);
const note: unknown = notes[0];
assert.partialDeepStrictEqual(note, {
provenance: {
subtitle: { text: cue.text },
media: { path: await session.mpv.command('get_property', 'path') },
},
});
assert.ok(note && typeof note === 'object' && 'originalNoteId' in note && 'noteId' in note);
assert.equal(note.originalNoteId, note.noteId);
},
);
+22 -24
View File
@@ -172,32 +172,30 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
});
});
test(
'runAppCommandCaptureOutput transports Linux AppImage args through environment',
{ skip: process.platform !== 'linux' },
async () => {
await withTempCase(({ dir }) => {
const appPath = writeFakeApp(
dir,
[
'printf "args:%s\\n" "$*"',
'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
'printf "arg1:%s\\n" "$SUBMINER_APP_ARG_1"',
],
'SubMiner.AppImage',
);
test('runAppCommandCaptureOutput transports Linux AppImage args through environment', async () => {
// Bun's node:test shim ignores the `skip` option, so gate the platform here.
if (process.platform !== 'linux') return;
await withTempCase(({ dir }) => {
const appPath = writeFakeApp(
dir,
[
'printf "args:%s\\n" "$*"',
'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
'printf "arg1:%s\\n" "$SUBMINER_APP_ARG_1"',
],
'SubMiner.AppImage',
);
const result = runAppCommandCaptureOutput(appPath, ['--app-ping', '--socket']);
const result = runAppCommandCaptureOutput(appPath, ['--app-ping', '--socket']);
assert.equal(result.status, 0);
assert.match(result.stdout, /^args:\n/m);
assert.match(result.stdout, /^argc:2\n/m);
assert.match(result.stdout, /^arg0:--app-ping\n/m);
assert.match(result.stdout, /^arg1:--socket\n/m);
});
},
);
assert.equal(result.status, 0);
assert.match(result.stdout, /^args:\n/m);
assert.match(result.stdout, /^argc:2\n/m);
assert.match(result.stdout, /^arg0:--app-ping\n/m);
assert.match(result.stdout, /^arg1:--socket\n/m);
});
});
test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', async () => {
await withTempCase(({ dir }) => {
+15 -8
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.21.0",
"version": "0.21.1",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -93,7 +93,7 @@
"brace-expansion": "5.0.11",
"electron-builder-squirrel-windows": "26.16.1",
"fast-uri": "3.1.7",
"follow-redirects": "1.16.1",
"follow-redirects": "1.16.2",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.2",
@@ -146,7 +146,8 @@
"afterPack": "scripts/electron-builder-after-pack.cjs",
"icon": "assets/SubMiner-square.png",
"directories": {
"output": "release"
"buildResources": "packaging/electron-builder",
"output": "dist/release"
},
"publish": [
{
@@ -178,8 +179,8 @@
"category": "public.app-category.video",
"icon": "assets/SubMiner-square.png",
"hardenedRuntime": true,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist",
"entitlements": "packaging/electron-builder/entitlements.mac.plist",
"entitlementsInherit": "packaging/electron-builder/entitlements.mac.plist",
"extraResources": [
{
"from": "dist/scripts/get-mpv-window-macos",
@@ -211,7 +212,7 @@
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"include": "build/installer.nsh"
"include": "packaging/electron-builder/installer.nsh"
},
"files": [
"dist/**/*",
@@ -225,17 +226,18 @@
"!**/{test,tests,__tests__,fixture,fixtures,__fixtures__}{,/**/*}",
"!dist/launcher{,/**/*}",
"!dist/scripts{,/**/*}",
"!dist/{yomitan,hachidori,release}{,/**/*}",
"!dist/{renderer,settings,syncui}/fonts{,/**/*}",
"!node_modules/koffi/{src,vendor,doc}{,/**/*}",
"!node_modules/@libsql/linux-x64-musl{,/**/*}"
],
"extraResources": [
{
"from": "build/yomitan",
"from": "dist/yomitan",
"to": "yomitan"
},
{
"from": "build/hachidori",
"from": "dist/hachidori",
"to": "hachidori"
},
{
@@ -277,6 +279,11 @@
}
]
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all"
},
"patchedDependencies": {
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
}
@@ -26,6 +26,6 @@
}
},
"licenseInventoryStatus": "source-and-notices-pinned-to-binary-revision",
"sourceManifest": "build/bun-source-manifest.json",
"sourceManifest": "packaging/bun/bun-source-manifest.json",
"correspondingSourceAsset": "bun-v1.3.5-source.tar.gz"
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -10,7 +10,7 @@ The SubMiner GitHub release containing this application also contains `bun-v1.3.
- every external source repository registered by that Bun revision's CMake build, at its exact commit
- Bun's dependency patches, build scripts, lockfiles, collected third-party license files, and instructions for rebuilding Bun against a modified JavaScriptCore
The machine-readable inventory lives at `SOURCE-INVENTORY.json` inside the source archive and at `build/bun-source-manifest.json` in SubMiner's source repository.
The machine-readable inventory lives at `SOURCE-INVENTORY.json` inside the source archive and at `packaging/bun/bun-source-manifest.json` in SubMiner's source repository.
The archive vendors the source repositories linked through Bun's CMake build. It preserves Bun's package-manager lockfiles and lol-html's `Cargo.lock`, but it does not vendor npm packages, crates.io packages, compilers, SDKs, or other build tools. Rebuilding needs network access for those package-manager and toolchain inputs. The archive's rebuild README records the known tool versions and the remaining unpinned Rust nightly input.
@@ -1,6 +1,6 @@
# Bun 1.3.5 third-party notices
This file collects the license texts shipped by the external source repositories used by Bun 1.3.5 at the revisions in build/bun-source-manifest.json. The corresponding-source release asset also preserves license notices embedded in individual Bun and WebKit source files. Bun’s own LICENSE.md lists additional embedded libraries and JavaScript polyfills whose source and per-file notices are included in that asset.
This file collects the license texts shipped by the external source repositories used by Bun 1.3.5 at the revisions in packaging/bun/bun-source-manifest.json. The corresponding-source release asset also preserves license notices embedded in individual Bun and WebKit source files. Bun’s own LICENSE.md lists additional embedded libraries and JavaScript polyfills whose source and per-file notices are included in that asset.
## BoringSSL
File renamed without changes.
File renamed without changes.
+32
View File
@@ -0,0 +1,32 @@
## Highlights
### Changed
- **Stats Session View**: The expanded session view in the stats dashboard has been redesigned. It shows stat tiles for pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words with lookup and card-mined markers.
### Fixed
- **Subtitle Delay**: Retiming subtitles in mpv (z/x or sub-step) now carries through SubMiner. Mined card audio, images, and timestamps match the retimed line, and so do the subtitle sidebar, subtitle navigation, and replay.
- **Subtitle Sidebar**: mpv keys and mouse buttons, such as volume keys and double-click fullscreen, work again while the sidebar is open.
### Docs
- **README**: Refreshed the anime screenshots using the current build.
## What's Changed
- fix(overlay): forward mpv input while subtitle sidebar is open by @ksyasuda in #300
- fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation by @ksyasuda in #302
- feat(stats): redesign expanded session view with density chart by @ksyasuda in #304
## 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`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
+4
View File
@@ -162,12 +162,15 @@ function M.create(ctx)
and not (state.overlay_running and state.auto_play_ready_signal_seen == true)
end
-- A warm playlist advance keeps the already-ready overlay window, and the app only reports
-- readiness when content first loads, so a restarted spinner would never clear (#315).
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
and not (state.overlay_running and state.overlay_content_ready_seen == true)
end
local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt)
@@ -311,6 +314,7 @@ function M.create(ctx)
process.stop_overlay_loading_osd()
clear_pending_visible_overlay_hide()
state.auto_play_ready_signal_seen = false
state.overlay_content_ready_seen = false
state.current_media_identity = nil
state.current_media_title = nil
state.pending_reload_media_identity = nil
+6 -1
View File
@@ -46,7 +46,12 @@ function M.create(ctx)
process.hold_auto_play_ready_gate()
end)
mp.register_script_message("subminer-overlay-loading-ready", function()
process.stop_overlay_loading_osd()
process.mark_overlay_content_ready()
end)
-- Sent when the app dismisses loading while its overlay window has no content (hidden mid-load,
-- or a replaced window).
mp.register_script_message("subminer-overlay-loading-dismissed", function()
process.mark_overlay_content_not_ready()
end)
mp.register_script_message("subminer-stats-toggle", function()
if opts.osd_messages then
+19
View File
@@ -282,6 +282,19 @@ function M.create(ctx)
clear_overlay_loading_osd_deadline()
end
-- Handles the app's subminer-overlay-loading-ready message.
local function mark_overlay_content_ready()
state.overlay_content_ready_seen = true
stop_overlay_loading_osd()
end
-- Handles subminer-overlay-loading-dismissed: the app's current overlay window has no content
-- (hidden mid-load, or a replaced window), so earlier readiness no longer applies.
local function mark_overlay_content_not_ready()
state.overlay_content_ready_seen = false
stop_overlay_loading_osd()
end
local function start_overlay_loading_osd()
if state.overlay_loading_osd_active then
return
@@ -741,6 +754,7 @@ function M.create(ctx)
state.overlay_running = false
state.auto_play_ready_signal_seen = false
state.overlay_content_ready_seen = false
subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
stop_overlay_loading_osd()
show_osd("Overlay start failed")
@@ -795,6 +809,7 @@ function M.create(ctx)
state.overlay_running = false
state.texthooker_running = false
state.auto_play_ready_signal_seen = false
state.overlay_content_ready_seen = false
stop_overlay_loading_osd()
disarm_auto_play_ready_gate()
show_osd("Stopped")
@@ -934,6 +949,7 @@ function M.create(ctx)
state.overlay_running = false
state.texthooker_running = false
state.auto_play_ready_signal_seen = false
state.overlay_content_ready_seen = false
state.suppress_ready_overlay_restore = false
state.force_ready_overlay_restore = true
disarm_auto_play_ready_gate({ resume_playback = false })
@@ -957,6 +973,7 @@ function M.create(ctx)
if not success or (result and result.status ~= 0) then
state.overlay_running = false
state.auto_play_ready_signal_seen = false
state.overlay_content_ready_seen = false
subminer_log(
"error",
"process",
@@ -1035,6 +1052,8 @@ function M.create(ctx)
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,
mark_overlay_content_ready = mark_overlay_content_ready,
mark_overlay_content_not_ready = mark_overlay_content_not_ready,
}
end
+3
View File
@@ -14,6 +14,9 @@ function M.new()
auto_play_ready_osd_timer = nil,
auto_play_ready_signal_seen = false,
auto_play_ready_initial_pause_ownership_consumed = false,
-- The running overlay has reported its content ready at least once; warm playlist advances
-- reuse that window, so they must not restart the loading spinner (#315).
overlay_content_ready_seen = false,
overlay_loading_osd_active = false,
overlay_loading_osd_timer = nil,
overlay_loading_osd_deadline = nil,
-60
View File
@@ -1,60 +0,0 @@
## Highlights
### Added
- **Hachidori Dictionary Backend**: Bundled Hachidori backend as an alternative to Yomitan. Select it with `dictionaryBackend` and restart SubMiner.
- Works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
- First-run setup builds Hachidori's Anki template from your existing SubMiner deck, tags, and field mappings.
- Can link an external Hachidori app, browser, or Docker host while Anki mining stays in SubMiner.
- The tray and dictionary-settings shortcut open the selected backend's settings. `--hachidori` and `--yomitan` open a specific one.
- **Jellyfin Subtitle Generation**: Japanese subtitle generation now works while playing Jellyfin episodes, with both direct-play and transcoded playback.
### Fixed
- **Media Timing Review**: Opens on the first try and keeps playback paused while open, even when a dictionary popup closes.
- **Jellyfin Terminal Playback**:
- `subminer jellyfin -p` lists libraries when SubMiner is already running, and picker image previews work again.
- Real errors, like an expired session or an unreachable server, now appear instead of an empty list.
- Watch progress and played status reach Jellyfin even when cast discovery is off.
- When it starts SubMiner itself, SubMiner now shows a tray icon and quits when mpv closes.
- The rofi search step is now a compact input box.
- **macOS Casting**: Jellyfin casting finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight. `mpv.executablePath` and `SUBMINER_MPV_PATH` are now honored on macOS and Linux.
- **macOS Launcher PATH**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys type normally in modals and text fields instead of seeking mpv. Toggling the mode inside a modal no longer turns on a hidden keyboard-only mode.
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and the stats dashboard. Stats sentence search matches katakana in either width.
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
- **Subtitle Sidebar**: The open sidebar does far less work during playback.
- **Hyprland and Sway**: A slow or hung `hyprctl` or `swaymsg` no longer freezes the app.
- **Stats Mining**: Cards mined from the stats dashboard keep the selected history line and media.
- **Security Updates**: Updated Electron, undici, and follow-redirects to fix new security advisories.
### Docs
- **Hachidori Setup**: Covers backend selection, Hachidori dictionary and Anki setup, settings flags and shortcuts, and linking an external host.
- **Jellyfin**: The docs now say SubMiner joins the cast menu at startup only when both `startupWarmups.jellyfinRemoteSession` and `jellyfin.remoteControlAutoConnect` are on. Otherwise, enable **Jellyfin Discovery** in the tray.
- **Casting and mpv**: Added how casting finds mpv on each platform, a troubleshooting entry for casts that never start mpv, and a note on the macOS 27 Accessibility list rename.
- **Jimaku Environment Variables**: Removed the unused `SUBMINER_JIMAKU_*` variables from the docs. Set the key with `jimaku.apiKey` or `jimaku.apiKeyCommand` instead.
## What's Changed
- feat(dictionary): add Hachidori backend support by @ksyasuda in #266
- fix(jellyfin): make terminal play work with a running app and report progress by @ksyasuda in #288
- fix(overlay): stop Hyprland/Sway tracker polls from blocking the main thread by @ksyasuda in #289
- fix(overlay): poll cheap playback state in the open subtitle sidebar by @ksyasuda in #290
- fix(overlay): keep keyboard mode from swallowing modal and text-field keys by @ksyasuda in #291
- refactor: remove dead code and enable noUnusedLocals by @ksyasuda in #292
- feat(subtitles): generate Japanese subtitles during Jellyfin playback by @ksyasuda in #294
- fix(subtitles): widen halfwidth katakana where subtitle text enters the app by @ksyasuda in #295
- fix(jellyfin): find mpv when SubMiner is launched from Finder by @ksyasuda in #298
## 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`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
+4
View File
@@ -29,6 +29,10 @@ test.each([false, true])(
port: 0,
fetch(request) {
const pathname = new URL(request.url).pathname;
// Ignore stray local probes of the ephemeral port; only count the script's downloads.
if (!pathname.startsWith('/ksyasuda/SubMiner/releases/download/')) {
return new Response('not found', { status: 404 });
}
requests.push(pathname);
if (unavailable || requests.length === 1) return new Response('try again', { status: 503 });
const name = pathname.split('/').at(-1);
+19 -13
View File
@@ -203,7 +203,7 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
assert.match(changelog, /### Fixed\n- Polished: fixed entry\./);
assert.match(changelog, /## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/);
const releaseNotes = project.read('release/release-notes.md');
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./);
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
@@ -240,7 +240,7 @@ test('writeChangelogArtifacts skips changelog prepend when release section alrea
assert.equal(project.exists('changes/001.md'), false);
assert.equal(project.read('CHANGELOG.md'), existingChangelog);
assert.match(
project.read('release/release-notes.md'),
project.read('packaging/release-notes/release-notes.md'),
/## Highlights\n### Added\n- Existing release bullet\./,
);
},
@@ -268,7 +268,7 @@ test('writeStableReleaseArtifacts reuses the requested version and date for chan
});
assert.deepEqual(result.outputPaths, [project.at('CHANGELOG.md')]);
assert.equal(result.releaseNotesPath, project.at('release/release-notes.md'));
assert.equal(result.releaseNotesPath, project.at('packaging/release-notes/release-notes.md'));
assert.equal(result.docsChangelogPath, project.at('docs-site/changelog.md'));
assert.match(project.read('CHANGELOG.md'), /## v0\.4\.1 \(2026-03-07\)/);
@@ -471,7 +471,7 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
});
assert.equal(outputPath, project.at('release/prerelease-notes.md'));
assert.equal(outputPath, project.at('packaging/release-notes/prerelease-notes.md'));
assert.equal(
project.read('CHANGELOG.md'),
existingChangelog,
@@ -527,7 +527,7 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
'prerelease-reuse-existing-notes',
{
'package.json': packageJson('0.11.3-beta.2'),
'release/prerelease-notes.md': existingNotes,
'packaging/release-notes/prerelease-notes.md': existingNotes,
'changes/001.md': fragment('fixed', 'launcher', '- Fixed launcher prerelease packaging.'),
},
(project) => {
@@ -574,7 +574,7 @@ test('writePrereleaseNotesForVersion ignores unmarked prerelease notes from an o
'prerelease-ignore-unmarked-old-notes',
{
'package.json': packageJson('0.17.0-beta.1'),
'release/prerelease-notes.md': existingNotes,
'packaging/release-notes/prerelease-notes.md': existingNotes,
'changes/001.md': fragment(
'changed',
'overlay',
@@ -785,7 +785,7 @@ test('writeChangelogArtifacts filters internal fragments from the release-notes
'release-notes mode drops internal fragments',
);
const releaseNotes = project.read('release/release-notes.md');
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
assert.doesNotMatch(releaseNotes, /<details>/);
assert.doesNotMatch(releaseNotes, /### Internal/);
assert.match(
@@ -841,7 +841,7 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
project.at('changes/002.md'),
]);
const releaseNotes = project.read('release/release-notes.md');
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
assert.match(releaseNotes, /## What's Changed\n\n/);
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
@@ -904,7 +904,10 @@ test('writeChangelogArtifacts skips contributor attribution in GitHub Actions wi
});
assert.deepEqual(warnings, []);
assert.doesNotMatch(project.read('release/release-notes.md'), /## What's Changed/);
assert.doesNotMatch(
project.read('packaging/release-notes/release-notes.md'),
/## What's Changed/,
);
} finally {
console.warn = originalWarn;
for (const key of envKeys) {
@@ -990,7 +993,10 @@ test('writeReleaseNotesForVersion preserves committed contributor attribution be
await withProject(
'release-notes-preserve-attribution',
{ 'CHANGELOG.md': existingChangelog, 'release/release-notes.md': committedReleaseNotes },
{
'CHANGELOG.md': existingChangelog,
'packaging/release-notes/release-notes.md': committedReleaseNotes,
},
(project) => {
const outputPath = writeReleaseNotesForVersion({
cwd: project.projectRoot,
@@ -1055,7 +1061,7 @@ test('writeChangelogArtifacts strips <details> blocks from release notes when re
'no Claude calls should fire when the section already exists',
);
const releaseNotes = project.read('release/release-notes.md');
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: previously committed\./);
assert.doesNotMatch(releaseNotes, /<details>/);
assert.doesNotMatch(releaseNotes, /### Internal/);
@@ -1237,7 +1243,7 @@ test('writePrereleaseNotesForVersion strips the stale delta section from the reu
'prerelease-reuse-strips-delta',
{
'package.json': packageJson('0.12.0-beta.3'),
'release/prerelease-notes.md': existingNotes,
'packaging/release-notes/prerelease-notes.md': existingNotes,
'changes/001.md': fragment('added', 'overlay', '- Added overlay coverage.'),
},
(project) => {
@@ -1271,7 +1277,7 @@ test('verifyPrereleaseNotesMatchVersion accepts matching notes and rejects stale
(project) => {
const verify = (version = '0.12.0-beta.2') =>
verifyPrereleaseNotesMatchVersion({ cwd: project.projectRoot, version });
const notesPath = 'release/prerelease-notes.md';
const notesPath = 'packaging/release-notes/prerelease-notes.md';
assert.throws(() => verify(), /Missing .*prerelease-notes\.md/);
+4 -4
View File
@@ -68,8 +68,8 @@ type PullRequestChangelogOptions = {
changedLabels?: string[];
};
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md');
const RELEASE_NOTES_PATH = path.join('packaging', 'release-notes', 'release-notes.md');
const PRERELEASE_NOTES_PATH = path.join('packaging', 'release-notes', 'prerelease-notes.md');
const CHANGELOG_HEADER = '# Changelog';
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
const SKIP_CHANGELOG_LABEL = 'skip-changelog';
@@ -1144,7 +1144,7 @@ function isFragmentPath(candidate: string): boolean {
function isIgnoredPullRequestPath(candidate: string): boolean {
return (
candidate === 'CHANGELOG.md' ||
candidate === 'release/release-notes.md' ||
candidate === 'packaging/release-notes/release-notes.md' ||
candidate === 'AGENTS.md' ||
candidate === 'README.md' ||
candidate.startsWith('changes/') ||
@@ -1405,7 +1405,7 @@ export function verifyPrereleaseNotesMatchVersion(options?: ChangelogOptions): v
const markerVersion = extractPrereleaseVersionMarker(readFileSync(prereleaseNotesPath, 'utf8'));
if (markerVersion !== version) {
throw new Error(
`release/prerelease-notes.md was generated for ${markerVersion ?? 'an unknown version (missing or legacy prerelease-version marker)'} but this release is ${version}. Rerun 'bun run changelog:prerelease-notes --version ${version}' and commit the result.`,
`packaging/release-notes/prerelease-notes.md was generated for ${markerVersion ?? 'an unknown version (missing or legacy prerelease-version marker)'} but this release is ${version}. Rerun 'bun run changelog:prerelease-notes --version ${version}' and commit the result.`,
);
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const source = path.join(repoRoot, 'vendor', 'hachidori');
const output = path.join(repoRoot, 'build', 'hachidori');
const output = path.join(repoRoot, 'dist', 'hachidori');
const provenance = JSON.parse(fs.readFileSync(path.join(source, 'SOURCE.json'), 'utf8'));
// Upstream commits the engine binaries. Verify the pinned bytes before staging
+3 -3
View File
@@ -19,7 +19,7 @@ test('Hachidori staging configures Electron without changing the fork source', a
files.map((file) => readFileSync(source(file), 'utf8')),
before,
);
const staged = await import(new URL('../build/hachidori/overlay-mode.js', import.meta.url).href);
const staged = await import(new URL('../dist/hachidori/overlay-mode.js', import.meta.url).href);
assert.equal(staged.OVERLAY_MODE, true);
assert.equal(staged.HOST_CAPABILITIES.customJavaScript, false);
const original = await import(source('overlay-mode.js').href);
@@ -28,13 +28,13 @@ test('Hachidori staging configures Electron without changing the fork source', a
// SubMiner mines media from mpv, not the overlay viewport, so the lookup
// highlight stays on for a first install.
const stagedSetup = await import(
new URL('../build/hachidori/setup-state.js', import.meta.url).href
new URL('../dist/hachidori/setup-state.js', import.meta.url).href
);
assert.equal(stagedSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, true);
const originalSetup = await import(source('setup-state.js').href);
assert.equal(originalSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, false);
const manifest = JSON.parse(
readFileSync(new URL('../build/hachidori/manifest.json', import.meta.url), 'utf8'),
readFileSync(new URL('../dist/hachidori/manifest.json', import.meta.url), 'utf8'),
);
const originalManifest = JSON.parse(readFileSync(source('manifest.json'), 'utf8'));
assert.deepEqual(manifest, {
+1 -1
View File
@@ -10,7 +10,7 @@ const repoRoot = path.resolve(dirname, '..');
const submoduleDir = path.join(repoRoot, 'vendor', 'subminer-yomitan');
const submodulePackagePath = path.join(submoduleDir, 'package.json');
const submodulePackageLockPath = path.join(submoduleDir, 'package-lock.json');
const buildOutputDir = path.join(repoRoot, 'build', 'yomitan');
const buildOutputDir = path.join(repoRoot, 'dist', 'yomitan');
const stampPath = path.join(buildOutputDir, '.subminer-build.json');
const zipPath = path.join(submoduleDir, 'builds', 'yomitan-chrome.zip');
const bunCommand = process.versions.bun ? process.execPath : 'bun';
+1 -1
View File
@@ -48,7 +48,7 @@ app
.then(async () => {
const targetSession = session.fromPartition('persist:hachidori-check');
const extension = await targetSession.extensions.loadExtension(
path.join(root, 'build/hachidori'),
path.join(root, 'dist/hachidori'),
{ allowFileAccess: true },
);
// Keep a host window alive while the dictionary importer opens/closes its
+4 -1
View File
@@ -18,7 +18,7 @@ test('platform packaging preserves the runtime allowlist after builder normalize
'/tmp/subminer-filter-output',
{
defaultSrc: root,
globalOutDir: path.join(root, 'release'),
globalOutDir: path.join(root, 'dist', 'release'),
customBuildOptions: { files: config.build[platform].files },
macroExpander: (value) => value.replaceAll('${arch}', 'x64'),
},
@@ -51,6 +51,9 @@ test('platform packaging preserves the runtime allowlist after builder normalize
'stats/dist/nested/fixtures/data.json',
'vendor/texthooker-ui/docs/nested/component.tsx',
'dist/launcher/subminer',
'dist/yomitan/manifest.json',
'dist/hachidori/manifest.json',
'dist/release/SubMiner-1.0.0.AppImage',
'dist/settings/fonts/MPLUS1[wght].ttf',
'vendor/subminer-yomitan/ext/manifest.json',
]) {
Loaded 100 of 437 files, more files were not shown because too many files have changed in this diff. Show more