Compare commits

..
Author SHA1 Message Date
sudacode 10424a57d5 fix(plugins): tag cards with the episode playing when they were added
- episode-tags: match each note id to the file history instead of the current media, so late card-mined events (YouTube media, field grouping) get the right episode
- episode-tags: group notes per file into one addTags call; skip cards added before the plugin started
- auto-jimaku: track a generation counter so a pending check goes stale when the same file reloads
- docs: describe the per-card episode matching in plugins.md
2026-10-27 22:41:32 -07:00
sudacode 15f32b10c7 feat(plugins): add episode-tags and auto-jimaku example plugins
- episode-tags: tag mined cards with show/episode parsed from the file name or stream title
- auto-jimaku: open Jimaku search when a file starts without a Japanese subtitle track
- Hold card-mined for notes merged into a note still waiting on queued YouTube media
- Route YouTube media settle reports through reportCardsReady
- Document both plugins in docs-site and changelog fragment
2026-10-27 22:31:34 -07:00
sudacode 390cf03e8d fix(plugins): report merged card-mined ids as the kept note
- Resolve note ids merged away by field grouping to the surviving note before emitting card-mined, deduplicating results
- Update plugin docs and setCardsReadyCallback comment to match
2026-10-27 22:16:24 -07:00
sudacode d2ab808bc3 fix(plugins): delay card-mined until field grouping and YouTube media
- Hold a sentence card's ready report until field grouping finishes
- Report cards waiting on queued YouTube media once it is applied or the download fails
- Proxy reports added notes ready right away when auto-update is off
- Polling keeps enriching the rest of a batch after one note fails
- Update plugin and architecture docs
2026-10-27 21:35:57 -07:00
sudacode ff012f0497 feat(plugins): add Anki card-mined event, AnkiConnect invoke, anki-sync
- Add ctx.anki.on('card-mined') fed by polling, proxy, and card creation once enrichment finishes
- Add ctx.anki.invoke to send one AnkiConnect action to the effective url with no retries
- Ship example anki-sync plugin that syncs to AnkiWeb after mining goes quiet or playback ends
- Unload plugins that finish activating after unloadAll gave up on them
- Document the Anki API and anki-sync plugin in docs-site and architecture docs
2026-10-27 20:04:34 -07:00
sudacode 4bb85f19bc fix(plugins): unload late-loading plugins before quit teardown
- unloadAll waits for an in-progress load so plugins still activating get deactivated too
- One 2 s budget covers the load wait and all deactivates
- Quit cleanup awaits plugin unload before any other teardown
- Force-quit timer now allows the plugin budget plus 2 s
2026-10-27 00:33:29 -07:00
sudacode 7a526531d7 fix(plugins): await plugin unload on quit with deactivate timeout
- Move plugin unload into the will-quit cleanup handler so it starts first and quit waits for it
- Give each plugin's deactivate 3 s (configurable via deactivateTimeoutMs) before disposing it anyway
- Document the quit timeout in docs-site/plugins.md
2026-10-26 23:22:25 -07:00
sudacode 04c91add22 fix(plugins): skip modal teardown for stale plugin panel closes
handleModalClosed now reports whether teardown should continue. A late
close notice for a replaced panel returns before the generic modal-close
flow, so it no longer hides the modal window showing the newer panel.
2026-10-26 22:57:45 -07:00
sudacode d5fb43caa3 feat(plugins): add plugin windows, CLI actions, and data dirs
- Add `--plugin-action <id>/<name>` CLI flag, dispatched via runPluginAction
- Add ctx.ui.openWindow for plugin-owned BrowserWindows with sandboxed preload
- Add ctx.dataDir (`plugin-data/<id>/`), ctx.actions.run, ctx.mpv.ensureRunning
- Emit mpv end-file events to plugins with reason, entry id, and error
- Run Windows .cmd/.bat shims through cmd.exe in exec, killing the tree on abort
- Update plugin docs, usage, architecture notes, and e2e coverage
2026-10-26 22:40:25 -07:00
sudacode 7b92511f23 fix(plugins): ignore stale plugin panel close notices
The overlay's modal-closed notice now carries the panel id, and the
runtime only finishes the active panel when the ids match. A late notice
for a replaced panel no longer aborts the newer one.
2026-10-26 22:08:35 -07:00
sudacode 0f53b8beca fix(plugins): SIGKILL exec children that ignore SIGTERM
- Escalate timed-out or aborted plugin exec children to SIGKILL after a grace period
- Move ai-explain example to assets/plugins and convert it to CommonJS
- Update plugin docs for the new path and the .js/.mjs module formats
2026-10-26 21:55:51 -07:00
sudacode ca534cd232 feat(plugins): add JavaScript plugin system with overlay panel
- Load plugins from <config dir>/plugins/<id>/ when enabled under `plugins` in config
- Expose ctx with mpv events/commands, timers, subtitle context, actions, notify, exec, and panel
- Bind plugin actions via `__plugin-action:<id>/<action>` keybindings (runPluginAction session action)
- Add plugin-panel modal with markdown rendering; closing aborts the panel signal
- Add ai-explain example plugin that queries `codex exec` for the current line
- Add e2e harness `files` option and plugin e2e scenario; document plugins in docs-site and internal docs
2026-10-26 21:38:51 -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
148 changed files with 4643 additions and 264 deletions

No files matched your search

+17 -17
View File
@@ -67,26 +67,26 @@ jobs:
- name: Build unversioned AppImage - name: Build unversioned AppImage
run: | run: |
shopt -s nullglob shopt -s nullglob
appimages=(release/SubMiner-*.AppImage) appimages=(dist/release/SubMiner-*.AppImage)
if [ "${#appimages[@]}" -eq 0 ]; then if [ "${#appimages[@]}" -eq 0 ]; then
echo "No versioned AppImage found to create unversioned artifact." echo "No versioned AppImage found to create unversioned artifact."
ls -la release ls -la dist/release
exit 1 exit 1
fi fi
cp "${appimages[0]}" release/SubMiner.AppImage cp "${appimages[0]}" dist/release/SubMiner.AppImage
- name: Smoke packaged runtime assets - name: Smoke packaged runtime assets
shell: bash 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 - name: Upload AppImage artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: appimage name: appimage
path: | path: |
release/*.AppImage dist/release/*.AppImage
release/latest*.yml dist/release/latest*.yml
release/*.blockmap dist/release/*.blockmap
if-no-files-found: error if-no-files-found: error
build-macos: build-macos:
@@ -159,17 +159,17 @@ jobs:
- name: Smoke packaged runtime assets - name: Smoke packaged runtime assets
shell: bash 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 - name: Upload macOS artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: macos name: macos
path: | path: |
release/*.dmg dist/release/*.dmg
release/*.zip dist/release/*.zip
release/latest*.yml dist/release/latest*.yml
release/*.blockmap dist/release/*.blockmap
if-no-files-found: error if-no-files-found: error
build-windows: build-windows:
@@ -224,15 +224,15 @@ jobs:
- name: Smoke packaged runtime assets - name: Smoke packaged runtime assets
shell: bash 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 - name: Upload Windows artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: windows name: windows
path: | path: |
release/*.exe dist/release/*.exe
release/*.zip dist/release/*.zip
release/latest*.yml dist/release/latest*.yml
release/*.blockmap dist/release/*.blockmap
if-no-files-found: error if-no-files-found: error
+25 -23
View File
@@ -44,19 +44,19 @@ jobs:
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: appimage name: appimage
path: release path: dist/release
- name: Download macOS artifacts - name: Download macOS artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: macos name: macos
path: release path: dist/release
- name: Download Windows artifacts - name: Download Windows artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: windows name: windows
path: release path: dist/release
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
@@ -90,13 +90,15 @@ jobs:
- name: Package optional assets bundle - name: Package optional assets bundle
run: | 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 \ config.example.jsonc \
plugin/subminer \ plugin/subminer \
plugin/subminer.conf \ plugin/subminer.conf \
assets/themes/subminer.rasi \ assets/themes/subminer.rasi \
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \ assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
resources/bun/licenses packaging/bun/licenses
- name: Package Bun corresponding source - name: Package Bun corresponding source
run: bun scripts/package-bun-source.mjs run: bun scripts/package-bun-source.mjs
@@ -104,16 +106,16 @@ jobs:
- name: Generate checksums - name: Generate checksums
run: | run: |
shopt -s nullglob shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/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 if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation." echo "No release artifacts found for checksum generation."
exit 1 exit 1
fi fi
: > release/SHA256SUMS.txt : > dist/release/SHA256SUMS.txt
for file in "${files[@]}"; do for file in "${files[@]}"; do
printf '%s %s\n' \ printf '%s %s\n' \
"$(sha256sum "$file" | awk '{print $1}')" \ "$(sha256sum "$file" | awk '{print $1}')" \
"${file##*/}" >> release/SHA256SUMS.txt "${file##*/}" >> dist/release/SHA256SUMS.txt
done done
- name: Get version from tag - name: Get version from tag
@@ -124,12 +126,12 @@ jobs:
env: env:
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }} RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: | run: |
if [ ! -s release/prerelease-notes.md ]; then if [ ! -s packaging/release-notes/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." 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 exit 1
fi fi
if ! bun run changelog:check-prerelease-notes --version "$RELEASE_VERSION"; then 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 exit 1
fi fi
@@ -142,15 +144,15 @@ jobs:
shopt -s nullglob shopt -s nullglob
artifacts=( artifacts=(
release/*.AppImage dist/release/*.AppImage
release/*.dmg dist/release/*.dmg
release/*.exe dist/release/*.exe
release/*.zip dist/release/*.zip
release/*.tar.gz dist/release/*.tar.gz
release/*.tar.gz.sha256 dist/release/*.tar.gz.sha256
release/latest*.yml dist/release/latest*.yml
release/*.blockmap dist/release/*.blockmap
release/SHA256SUMS.txt dist/release/SHA256SUMS.txt
dist/launcher/subminer dist/launcher/subminer
dist/launcher/subminer.cmd dist/launcher/subminer.cmd
) )
@@ -165,14 +167,14 @@ jobs:
--draft \ --draft \
--prerelease \ --prerelease \
--title "$RELEASE_VERSION" \ --title "$RELEASE_VERSION" \
--notes-file release/prerelease-notes.md --notes-file packaging/release-notes/prerelease-notes.md
else else
gh release create "$RELEASE_VERSION" \ gh release create "$RELEASE_VERSION" \
--draft \ --draft \
--latest=false \ --latest=false \
--prerelease \ --prerelease \
--title "$RELEASE_VERSION" \ --title "$RELEASE_VERSION" \
--notes-file release/prerelease-notes.md --notes-file packaging/release-notes/prerelease-notes.md
fi fi
for asset in "${artifacts[@]}"; do for asset in "${artifacts[@]}"; do
@@ -183,4 +185,4 @@ jobs:
--draft=false \ --draft=false \
--prerelease \ --prerelease \
--title "$RELEASE_VERSION" \ --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 uses: actions/download-artifact@v4
with: with:
name: appimage name: appimage
path: release path: dist/release
- name: Download macOS artifacts - name: Download macOS artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: macos name: macos
path: release path: dist/release
- name: Download Windows artifacts - name: Download Windows artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: windows name: windows
path: release path: dist/release
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
@@ -91,13 +91,15 @@ jobs:
- name: Package optional assets bundle - name: Package optional assets bundle
run: | 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 \ config.example.jsonc \
plugin/subminer \ plugin/subminer \
plugin/subminer.conf \ plugin/subminer.conf \
assets/themes/subminer.rasi \ assets/themes/subminer.rasi \
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \ assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
resources/bun/licenses packaging/bun/licenses
- name: Package Bun corresponding source - name: Package Bun corresponding source
run: bun scripts/package-bun-source.mjs run: bun scripts/package-bun-source.mjs
@@ -105,16 +107,16 @@ jobs:
- name: Generate checksums - name: Generate checksums
run: | run: |
shopt -s nullglob shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/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 if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation." echo "No release artifacts found for checksum generation."
exit 1 exit 1
fi fi
: > release/SHA256SUMS.txt : > dist/release/SHA256SUMS.txt
for file in "${files[@]}"; do for file in "${files[@]}"; do
printf '%s %s\n' \ printf '%s %s\n' \
"$(sha256sum "$file" | awk '{print $1}')" \ "$(sha256sum "$file" | awk '{print $1}')" \
"${file##*/}" >> release/SHA256SUMS.txt "${file##*/}" >> dist/release/SHA256SUMS.txt
done done
- name: Get version from tag - name: Get version from tag
@@ -152,24 +154,24 @@ jobs:
gh release edit "$RELEASE_VERSION" \ gh release edit "$RELEASE_VERSION" \
--draft=false \ --draft=false \
--title "$RELEASE_VERSION" \ --title "$RELEASE_VERSION" \
--notes-file release/release-notes.md --notes-file packaging/release-notes/release-notes.md
else else
gh release create "$RELEASE_VERSION" \ gh release create "$RELEASE_VERSION" \
--title "$RELEASE_VERSION" \ --title "$RELEASE_VERSION" \
--notes-file release/release-notes.md --notes-file packaging/release-notes/release-notes.md
fi fi
shopt -s nullglob shopt -s nullglob
artifacts=( artifacts=(
release/*.AppImage dist/release/*.AppImage
release/*.dmg dist/release/*.dmg
release/*.exe dist/release/*.exe
release/*.zip dist/release/*.zip
release/*.tar.gz dist/release/*.tar.gz
release/*.tar.gz.sha256 dist/release/*.tar.gz.sha256
release/latest*.yml dist/release/latest*.yml
release/*.blockmap dist/release/*.blockmap
release/SHA256SUMS.txt dist/release/SHA256SUMS.txt
dist/launcher/subminer dist/launcher/subminer
dist/launcher/subminer.cmd dist/launcher/subminer.cmd
) )
+1 -7
View File
@@ -8,11 +8,6 @@ node_modules/
# Electron build output # Electron build output
out/ out/
dist/ dist/
release/*
!release/
!release/release-notes.md
!release/prerelease-notes.md
build/yomitan/
coverage/ coverage/
# Launcher build artifact (produced by make build-launcher) # Launcher build artifact (produced by make build-launcher)
@@ -20,8 +15,6 @@ coverage/
/main-entry.js /main-entry.js
/main-entry.js.map /main-entry.js.map
/build/hachidori/
# Logs # Logs
*.log *.log
npm-debug.log* npm-debug.log*
@@ -43,6 +36,7 @@ environment.toml
.vitepress/dist/ .vitepress/dist/
docs/.vitepress/cache/ docs/.vitepress/cache/
docs/.vitepress/dist/ docs/.vitepress/dist/
# Local test media and scripts
tests/* tests/*
.worktrees/ .worktrees/
.tmp/ .tmp/
+3 -1
View File
@@ -1,7 +1,9 @@
node_modules node_modules
dist dist
release
coverage coverage
vendor vendor
*.log *.log
src/core/services/tokenizer/__fixtures__/golden/*.json 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"
}
+8 -17
View File
@@ -21,11 +21,10 @@ MACOS_DATA_DIR ?= $(HOME)/Library/Application Support/SubMiner
MACOS_APP_DIR ?= $(HOME)/Applications MACOS_APP_DIR ?= $(HOME)/Applications
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
# If building from source, the AppImage will typically land in release/. # If building from source, the AppImage will typically land in dist/release/.
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage)) APPIMAGE_SRC = $(firstword $(wildcard dist/release/SubMiner-*.AppImage))
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app)) MACOS_APP_SRC = $(firstword $(wildcard dist/release/*.app dist/release/*/*.app))
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip)) MACOS_ZIP_SRC = $(firstword $(wildcard dist/release/SubMiner-*.zip))
PRERELEASE_NOTES := release/prerelease-notes.md
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown) UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
ifeq ($(OS),Windows_NT) ifeq ($(OS),Windows_NT)
@@ -57,7 +56,7 @@ help:
" build-linux Build Linux AppImage" \ " build-linux Build Linux AppImage" \
" build-macos Build macOS DMG/ZIP (signed if configured)" \ " build-macos Build macOS DMG/ZIP (signed if configured)" \
" build-macos-unsigned Build macOS DMG/ZIP without signing/notarization" \ " 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 Build and launch local Electron app" \
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \ " dev-start-macos Build and launch local Electron app with macOS tracker backend" \
" dev-watch Start fast watch loop (tsc + renderer + Electron dev app)" \ " dev-watch Start fast watch loop (tsc + renderer + Electron dev app)" \
@@ -165,15 +164,7 @@ build-launcher:
clean: clean:
@printf '%s\n' "[INFO] Removing build artifacts" @printf '%s\n' "[INFO] Removing build artifacts"
@if [ -f "$(PRERELEASE_NOTES)" ]; then \ @rm -rf dist
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 -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage" @rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
generate-config: ensure-bun generate-config: ensure-bun
@@ -229,7 +220,7 @@ install-linux: build-launcher
@if [ -n "$(APPIMAGE_SRC)" ]; then \ @if [ -n "$(APPIMAGE_SRC)" ]; then \
install -m 0755 "$(APPIMAGE_SRC)" "$(BINDIR)/SubMiner.AppImage"; \ install -m 0755 "$(APPIMAGE_SRC)" "$(BINDIR)/SubMiner.AppImage"; \
else \ 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"; \ printf '%s\n' " Build one with: make build"; \
fi fi
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)" @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)"; \ ditto -x -k "$(MACOS_ZIP_SRC)" "$(MACOS_APP_DIR)"; \
printf '%s\n' "[INFO] Installed app bundle from $(MACOS_ZIP_SRC)"; \ printf '%s\n' "[INFO] Installed app bundle from $(MACOS_ZIP_SRC)"; \
else \ 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"; \ printf '%s\n' " Build one with: make build"; \
fi fi
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)" @printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
+7 -3
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. 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"> <div align="center">
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv"> <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. 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"> <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> </div>
<br> <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. 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"> <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> </div>
<br> <br>
@@ -102,6 +102,10 @@ A stats dashboard tracks watch time, vocabulary growth, mining throughput, sessi
<td><b>WebSocket</b></td> <td><b>WebSocket</b></td>
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td> <td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
</tr> </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> </table>
<br> <br>
+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 };
+5
View File
@@ -0,0 +1,5 @@
{
"name": "AI Explain",
"version": "1.0.0",
"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 };
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Anki Sync",
"version": "1.0.0",
"main": "main.js"
}
+58
View File
@@ -0,0 +1,58 @@
// 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;
// Bumped on every file change so a check still waiting on mpv knows it is stale.
let generation = 0;
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(mediaPath, checkGeneration) {
cancelCheck = null;
const [currentPath, trackList] = await Promise.all([
ctx.mpv.getProperty('path'),
ctx.mpv.getProperty('track-list'),
]);
// Another file started while we waited, even if it was this one again; its own check is
// already scheduled.
if (checkGeneration !== generation) return;
if (currentPath !== mediaPath || !Array.isArray(trackList)) return;
if (hasJapaneseSubtitle(trackList)) return;
ctx.log.info(`No Japanese subtitles for ${mediaPath}; running ${settings.action}.`);
await ctx.actions.run(settings.action);
}
ctx.mpv.on('media-path-change', ({ path: mediaPath }) => {
generation += 1;
cancelCheck?.();
cancelCheck = null;
if (!mediaPath) return;
if (!settings.streams && /^[a-z]+:\/\//i.test(mediaPath)) return;
const checkGeneration = generation;
cancelCheck = ctx.after(settings.delayMs, () => check(mediaPath, checkGeneration));
});
}
module.exports = { activate };
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Auto Jimaku",
"version": "1.0.0",
"main": "main.js"
}
+147
View File
@@ -0,0 +1,147 @@
// 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('::');
}
// Files remembered for matching cards to what was playing; older cards are not tagged.
const MAX_HISTORY = 50;
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 };
// Files played since activation, oldest first. An Anki note id is the time the note was
// added in milliseconds, so each card is tagged with the file playing when it was added,
// even when card-mined fires after playback moved on (YouTube media, field grouping).
const history = [];
function remember(mediaPath, since) {
history.push({ since, path: mediaPath || null, title: null });
if (history.length > MAX_HISTORY) history.shift();
}
function nameAt(noteId) {
for (let i = history.length - 1; i >= 0; i -= 1) {
const entry = history[i];
if (entry.since <= noteId) return mediaName(entry.path, entry.title);
}
return null;
}
ctx.mpv.on('media-path-change', ({ path: mediaPath }) => remember(mediaPath, Date.now()));
// SubMiner reads the title back after each path change, so it lands on the new file.
ctx.mpv.on('media-title-change', ({ title }) => {
const latest = history.at(-1);
if (latest) latest.title = title;
});
// A file may already be playing when the plugin starts.
const startedAt = Date.now();
Promise.all([ctx.mpv.getProperty('path'), ctx.mpv.getProperty('media-title')])
.then(([mediaPath, title]) => {
if (history.length > 0 || typeof mediaPath !== 'string' || !mediaPath) return;
remember(mediaPath, startedAt);
history[0].title = typeof title === 'string' ? title : null;
})
.catch((error) => ctx.log.debug(`Could not read the playing media: ${error.message}`));
ctx.anki.on('card-mined', async ({ noteIds }) => {
// Notes from the same file share one addTags call.
const notesByName = new Map();
for (const noteId of noteIds) {
const name = nameAt(noteId);
if (!name) {
ctx.log.debug(`Note ${noteId} was not added during a file SubMiner saw; not tagging it.`);
continue;
}
notesByName.set(name, [...(notesByName.get(name) ?? []), 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, tags: tags.join(' ') });
ctx.log.info(`Tagged ${notes.length} 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 };
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Episode Tags",
"version": "1.0.0",
"main": "main.js"
}
+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. - 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. - `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. - `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 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 - 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 - 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` - prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
+9
View File
@@ -0,0 +1,9 @@
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 an example `mining-goal` plugin that tracks cards mined per day, notifies you at a daily goal, and shows your streak and the last week in a panel.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: readme
- Refreshed the README's anime screenshots using the current SubMiner build.
+5
View File
@@ -0,0 +1,5 @@
type: internal
area: build
- Moved packaging inputs (electron-builder resources, Bun runtime manifests and licenses, committed release notes) under `packaging/`.
- Build output now lands in `dist/`: packaged apps in `dist/release/`, staged Yomitan and Hachidori in `dist/yomitan/` and `dist/hachidori/`.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- mpv keys and mouse buttons (such as `9`/`0` volume and double-click fullscreen) pass through the overlay again while the subtitle sidebar is open; only input inside the sidebar stays with it.
+8
View File
@@ -218,6 +218,14 @@
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right "overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior. }, // 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 // Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable. // Overlay keyboard shortcuts. Set a shortcut to null to disable.
+1
View File
@@ -221,6 +221,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'AniList', link: '/anilist-integration' }, { text: 'AniList', link: '/anilist-integration' },
{ text: 'AniSkip', link: '/aniskip-integration' }, { text: 'AniSkip', link: '/aniskip-integration' },
{ text: 'Character Dictionary', link: '/character-dictionary' }, { text: 'Character Dictionary', link: '/character-dictionary' },
{ text: 'Plugins', link: '/plugins' },
], ],
}, },
{ {
+10 -1
View File
@@ -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)). 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 ### 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. 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.
@@ -268,7 +277,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`. - `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. - `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. - 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 ### Shortcuts configuration
+1 -1
View File
@@ -226,7 +226,7 @@ make deps
make build-macos 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> </details>
+169
View File
@@ -0,0 +1,169 @@
# 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. Each card is tagged with the file that was playing when it was added, so a card that finishes later (YouTube media, field grouping) still gets its own episode, and cards added before the plugin started 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 |
## Example: daily mining goal
[`assets/plugins/mining-goal`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/mining-goal) counts the cards you mine each day and shows a notification when you reach your goal. Its `stats` action opens a panel with today's count, your streak of days at the goal, and the last 7 days. Counts are kept in `plugin-data/mining-goal/counts.json`.
| Setting | Default | What it does |
| -------------- | ------- | ------------------------------------------------------------------- |
| `goal` | `10` | Cards per day |
| `dayStartHour` | `4` | Cards mined before this hour count toward the day before, like Anki |
| `notify` | `true` | Show a notification when you reach the goal |
Only cards SubMiner mines count. Cards mined from the stats dashboard or added in Anki don't.
## Write a plugin
A plugin folder needs a `plugin.json`:
```json
{ "name": "My Plugin", "version": "1.0.0", "main": "main.js" }
```
`main` points to a CommonJS module (`.js`) or an ES module (`.mjs`) that exports `activate(ctx)`. It may return a cleanup function, or the module can export `deactivate()`. On quit, SubMiner waits up to 2 seconds for it, including any promise it returns. Everything a plugin registers through `ctx` (listeners, timers, actions, panels, running programs) is cleaned up automatically when SubMiner quits.
```js
function activate(ctx) {
ctx.mpv.on('media-path-change', ({ path }) => ctx.log.info(`Now playing ${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.
### `ctx` reference
| Member | What it does |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `id`, `dir`, `settings` | Plugin id, plugin folder path, and `plugins.<id>.settings` |
| `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.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 |
| `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. 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.
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`, and `end-file`. `end-file` gives `{ reason, playlistEntryId, error }`, where `reason` is `eof`, `stop`, `quit`, `error`, `redirect`, or `unknown`.
Anki events: `card-mined` gives `{ noteIds }` 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, you get the older card's id.
`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.
+8
View File
@@ -218,6 +218,14 @@
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right "overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior. }, // 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 // Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable. // Overlay keyboard shortcuts. Set a shortcut to null to disable.
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. 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 ## 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" ## "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" ## "MeCab not found on system"
+2 -1
View File
@@ -22,7 +22,7 @@ The bundled Yomitan is separate from any Yomitan in your browser. It has its own
### Hachidori setup ### 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. 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.
@@ -104,6 +104,7 @@ subminer doctor # check dependencies, config, and the mpv soc
subminer generate-subs video.mkv # make Japanese subtitles from the audio subminer generate-subs video.mkv # make Japanese subtitles from the audio
subminer logs -e # export a log ZIP for bug reports subminer logs -e # export a log ZIP for bug reports
subminer app --setup # reopen first-run setup subminer app --setup # reopen first-run setup
subminer app --plugin-action <id>/<name> # run a plugin action
subminer -u # update SubMiner subminer -u # update SubMiner
``` ```
+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. 4. Bump `package.json` to the release version.
5. Build release metadata before tagging (this calls `claude -p` locally): 5. Build release metadata before tagging (this calls `claude -p` locally):
`bun run changelog:build --version <version> --date <yyyy-mm-dd>` `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 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. 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. missed something — the committed Markdown is what ships.
7. Run release gate locally: 7. Run release gate locally:
`bun run changelog:check --version <version>` `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 `bun-v<version>-source.tar.gz` plus its `.sha256` file. The script
fails if Bun's CMake dependency pins, WebKit pin, source checksums, patch fails if Bun's CMake dependency pins, WebKit pin, source checksums, patch
inputs, or collected license files differ from 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 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`). (`latest*.yml`) and blockmaps (`*.blockmap`).
8. If `docs-site/` changed, also run: 8. If `docs-site/` changed, also run:
`bun run docs:test` `bun run docs:test`
@@ -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 source archive. A missing source repository or license file fails the
release instead of publishing only the executable. release instead of publishing only the executable.
When validating packaged updater output, confirm the platform build writes 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 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 committed file — so review it before committing. Rerun
`bun run changelog:prerelease-notes --version <version>` for every later `bun run changelog:prerelease-notes --version <version>` for every later
beta/RC, even if no fragments changed: the notes carry a hidden 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`. - 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. - 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: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`. - 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: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. - `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.
- `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. - `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. - The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
- Do not tag while `changes/*.md` fragments still exist. - Do not tag while `changes/*.md` fragments still exist.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts. - Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
- 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`. - 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. - 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. - 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.
+1
View File
@@ -37,6 +37,7 @@ 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. - `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/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/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. 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, 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/renderer/` owns overlay rendering and input behavior.
- `src/config/` owns config definitions, defaults, loading, and resolution. - `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/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
+1
View File
@@ -34,6 +34,7 @@ Read when: you need to find the owner module for a behavior or test surface
- 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-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- 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. - 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-*`
- 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/` - Window trackers: `src/window-trackers/`
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support - Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
in `src/core/services/stats-server/` in `src/core/services/stats-server/`
+3 -2
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)) then `bun run test:e2e` (see [Headless End-to-End Harness](#headless-end-to-end-harness))
- Build/release scripts (`scripts/**`): `bun run test:scripts` - Build/release scripts (`scripts/**`): `bun run test:scripts`
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`. - 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 Content checks run inside the electron-builder afterPack hook. See the
[release guide](../RELEASING.md#package-contents-checks) for the [release guide](../RELEASING.md#package-contents-checks) for the
installed-app verification checklist. installed-app verification checklist.
@@ -123,7 +123,8 @@ whenever the behavior does not depend on a specific compositor.
per-window screenshots), mpv over its JSON IPC socket, and app commands through the per-window screenshots), mpv over its JSON IPC socket, and app commands through the
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...). normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through - 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 - 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`).
+11
View File
@@ -28,6 +28,8 @@ type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
export type E2eSessionOptions = { export type E2eSessionOptions = {
/** Deep-merged over the harness config, for scenarios that need other settings. */ /** Deep-merged over the harness config, for scenarios that need other settings. */
config?: JsonObject; config?: JsonObject;
/** Files written before launch, keyed by path relative to the config dir (e.g. plugins). */
files?: Record<string, string>;
}; };
export type AppTarget = { export type AppTarget = {
@@ -193,6 +195,15 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {}); const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2)); 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 = { const env: EnvDelta = {
set: { set: {
+217
View File
@@ -0,0 +1,217 @@
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.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 }));
});
return () => ipcMain.removeHandler('e2e-probe:info');
};
`;
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' },
);
});
+13 -6
View File
@@ -146,7 +146,8 @@
"afterPack": "scripts/electron-builder-after-pack.cjs", "afterPack": "scripts/electron-builder-after-pack.cjs",
"icon": "assets/SubMiner-square.png", "icon": "assets/SubMiner-square.png",
"directories": { "directories": {
"output": "release" "buildResources": "packaging/electron-builder",
"output": "dist/release"
}, },
"publish": [ "publish": [
{ {
@@ -178,8 +179,8 @@
"category": "public.app-category.video", "category": "public.app-category.video",
"icon": "assets/SubMiner-square.png", "icon": "assets/SubMiner-square.png",
"hardenedRuntime": true, "hardenedRuntime": true,
"entitlements": "build/entitlements.mac.plist", "entitlements": "packaging/electron-builder/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist", "entitlementsInherit": "packaging/electron-builder/entitlements.mac.plist",
"extraResources": [ "extraResources": [
{ {
"from": "dist/scripts/get-mpv-window-macos", "from": "dist/scripts/get-mpv-window-macos",
@@ -211,7 +212,7 @@
"oneClick": false, "oneClick": false,
"perMachine": false, "perMachine": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"include": "build/installer.nsh" "include": "packaging/electron-builder/installer.nsh"
}, },
"files": [ "files": [
"dist/**/*", "dist/**/*",
@@ -225,17 +226,18 @@
"!**/{test,tests,__tests__,fixture,fixtures,__fixtures__}{,/**/*}", "!**/{test,tests,__tests__,fixture,fixtures,__fixtures__}{,/**/*}",
"!dist/launcher{,/**/*}", "!dist/launcher{,/**/*}",
"!dist/scripts{,/**/*}", "!dist/scripts{,/**/*}",
"!dist/{yomitan,hachidori,release}{,/**/*}",
"!dist/{renderer,settings,syncui}/fonts{,/**/*}", "!dist/{renderer,settings,syncui}/fonts{,/**/*}",
"!node_modules/koffi/{src,vendor,doc}{,/**/*}", "!node_modules/koffi/{src,vendor,doc}{,/**/*}",
"!node_modules/@libsql/linux-x64-musl{,/**/*}" "!node_modules/@libsql/linux-x64-musl{,/**/*}"
], ],
"extraResources": [ "extraResources": [
{ {
"from": "build/yomitan", "from": "dist/yomitan",
"to": "yomitan" "to": "yomitan"
}, },
{ {
"from": "build/hachidori", "from": "dist/hachidori",
"to": "hachidori" "to": "hachidori"
}, },
{ {
@@ -277,6 +279,11 @@
} }
] ]
}, },
"prettier": {
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all"
},
"patchedDependencies": { "patchedDependencies": {
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch" "@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
} }
@@ -26,6 +26,6 @@
} }
}, },
"licenseInventoryStatus": "source-and-notices-pinned-to-binary-revision", "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" "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 - 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 - 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. 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 # 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 ## BoringSSL
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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, /### Fixed\n- Polished: fixed entry\./);
assert.match(changelog, /## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/); 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, /## Highlights\n### Added\n- Polished: added entry\./);
assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./); assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./);
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/); 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.exists('changes/001.md'), false);
assert.equal(project.read('CHANGELOG.md'), existingChangelog); assert.equal(project.read('CHANGELOG.md'), existingChangelog);
assert.match( assert.match(
project.read('release/release-notes.md'), project.read('packaging/release-notes/release-notes.md'),
/## Highlights\n### Added\n- Existing release bullet\./, /## 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.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.equal(result.docsChangelogPath, project.at('docs-site/changelog.md'));
assert.match(project.read('CHANGELOG.md'), /## v0\.4\.1 \(2026-03-07\)/); 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: () => [] }, 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( assert.equal(
project.read('CHANGELOG.md'), project.read('CHANGELOG.md'),
existingChangelog, existingChangelog,
@@ -527,7 +527,7 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
'prerelease-reuse-existing-notes', 'prerelease-reuse-existing-notes',
{ {
'package.json': packageJson('0.11.3-beta.2'), '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.'), 'changes/001.md': fragment('fixed', 'launcher', '- Fixed launcher prerelease packaging.'),
}, },
(project) => { (project) => {
@@ -574,7 +574,7 @@ test('writePrereleaseNotesForVersion ignores unmarked prerelease notes from an o
'prerelease-ignore-unmarked-old-notes', 'prerelease-ignore-unmarked-old-notes',
{ {
'package.json': packageJson('0.17.0-beta.1'), 'package.json': packageJson('0.17.0-beta.1'),
'release/prerelease-notes.md': existingNotes, 'packaging/release-notes/prerelease-notes.md': existingNotes,
'changes/001.md': fragment( 'changes/001.md': fragment(
'changed', 'changed',
'overlay', 'overlay',
@@ -785,7 +785,7 @@ test('writeChangelogArtifacts filters internal fragments from the release-notes
'release-notes mode drops internal fragments', '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, /<details>/);
assert.doesNotMatch(releaseNotes, /### Internal/); assert.doesNotMatch(releaseNotes, /### Internal/);
assert.match( assert.match(
@@ -841,7 +841,7 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
project.at('changes/002.md'), 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, /## What's Changed\n\n/);
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/); assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/); assert.match(releaseNotes, /- 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.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 { } finally {
console.warn = originalWarn; console.warn = originalWarn;
for (const key of envKeys) { for (const key of envKeys) {
@@ -990,7 +993,10 @@ test('writeReleaseNotesForVersion preserves committed contributor attribution be
await withProject( await withProject(
'release-notes-preserve-attribution', 'release-notes-preserve-attribution',
{ 'CHANGELOG.md': existingChangelog, 'release/release-notes.md': committedReleaseNotes }, {
'CHANGELOG.md': existingChangelog,
'packaging/release-notes/release-notes.md': committedReleaseNotes,
},
(project) => { (project) => {
const outputPath = writeReleaseNotesForVersion({ const outputPath = writeReleaseNotesForVersion({
cwd: project.projectRoot, 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', '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.match(releaseNotes, /## Highlights\n### Added\n- Polished: previously committed\./);
assert.doesNotMatch(releaseNotes, /<details>/); assert.doesNotMatch(releaseNotes, /<details>/);
assert.doesNotMatch(releaseNotes, /### Internal/); assert.doesNotMatch(releaseNotes, /### Internal/);
@@ -1237,7 +1243,7 @@ test('writePrereleaseNotesForVersion strips the stale delta section from the reu
'prerelease-reuse-strips-delta', 'prerelease-reuse-strips-delta',
{ {
'package.json': packageJson('0.12.0-beta.3'), '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.'), 'changes/001.md': fragment('added', 'overlay', '- Added overlay coverage.'),
}, },
(project) => { (project) => {
@@ -1271,7 +1277,7 @@ test('verifyPrereleaseNotesMatchVersion accepts matching notes and rejects stale
(project) => { (project) => {
const verify = (version = '0.12.0-beta.2') => const verify = (version = '0.12.0-beta.2') =>
verifyPrereleaseNotesMatchVersion({ cwd: project.projectRoot, version }); 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/); assert.throws(() => verify(), /Missing .*prerelease-notes\.md/);
+4 -4
View File
@@ -68,8 +68,8 @@ type PullRequestChangelogOptions = {
changedLabels?: string[]; changedLabels?: string[];
}; };
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md'); const RELEASE_NOTES_PATH = path.join('packaging', 'release-notes', 'release-notes.md');
const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md'); const PRERELEASE_NOTES_PATH = path.join('packaging', 'release-notes', 'prerelease-notes.md');
const CHANGELOG_HEADER = '# Changelog'; const CHANGELOG_HEADER = '# Changelog';
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal']; const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
const SKIP_CHANGELOG_LABEL = 'skip-changelog'; const SKIP_CHANGELOG_LABEL = 'skip-changelog';
@@ -1144,7 +1144,7 @@ function isFragmentPath(candidate: string): boolean {
function isIgnoredPullRequestPath(candidate: string): boolean { function isIgnoredPullRequestPath(candidate: string): boolean {
return ( return (
candidate === 'CHANGELOG.md' || candidate === 'CHANGELOG.md' ||
candidate === 'release/release-notes.md' || candidate === 'packaging/release-notes/release-notes.md' ||
candidate === 'AGENTS.md' || candidate === 'AGENTS.md' ||
candidate === 'README.md' || candidate === 'README.md' ||
candidate.startsWith('changes/') || candidate.startsWith('changes/') ||
@@ -1405,7 +1405,7 @@ export function verifyPrereleaseNotesMatchVersion(options?: ChangelogOptions): v
const markerVersion = extractPrereleaseVersionMarker(readFileSync(prereleaseNotesPath, 'utf8')); const markerVersion = extractPrereleaseVersionMarker(readFileSync(prereleaseNotesPath, 'utf8'));
if (markerVersion !== version) { if (markerVersion !== version) {
throw new Error( 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 repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const source = path.join(repoRoot, 'vendor', 'hachidori'); 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')); const provenance = JSON.parse(fs.readFileSync(path.join(source, 'SOURCE.json'), 'utf8'));
// Upstream commits the engine binaries. Verify the pinned bytes before staging // 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')), files.map((file) => readFileSync(source(file), 'utf8')),
before, 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.OVERLAY_MODE, true);
assert.equal(staged.HOST_CAPABILITIES.customJavaScript, false); assert.equal(staged.HOST_CAPABILITIES.customJavaScript, false);
const original = await import(source('overlay-mode.js').href); 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 // SubMiner mines media from mpv, not the overlay viewport, so the lookup
// highlight stays on for a first install. // highlight stays on for a first install.
const stagedSetup = await import( 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); assert.equal(stagedSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, true);
const originalSetup = await import(source('setup-state.js').href); const originalSetup = await import(source('setup-state.js').href);
assert.equal(originalSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, false); assert.equal(originalSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, false);
const manifest = JSON.parse( 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')); const originalManifest = JSON.parse(readFileSync(source('manifest.json'), 'utf8'));
assert.deepEqual(manifest, { 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 submoduleDir = path.join(repoRoot, 'vendor', 'subminer-yomitan');
const submodulePackagePath = path.join(submoduleDir, 'package.json'); const submodulePackagePath = path.join(submoduleDir, 'package.json');
const submodulePackageLockPath = path.join(submoduleDir, 'package-lock.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 stampPath = path.join(buildOutputDir, '.subminer-build.json');
const zipPath = path.join(submoduleDir, 'builds', 'yomitan-chrome.zip'); const zipPath = path.join(submoduleDir, 'builds', 'yomitan-chrome.zip');
const bunCommand = process.versions.bun ? process.execPath : 'bun'; const bunCommand = process.versions.bun ? process.execPath : 'bun';
+1 -1
View File
@@ -48,7 +48,7 @@ app
.then(async () => { .then(async () => {
const targetSession = session.fromPartition('persist:hachidori-check'); const targetSession = session.fromPartition('persist:hachidori-check');
const extension = await targetSession.extensions.loadExtension( const extension = await targetSession.extensions.loadExtension(
path.join(root, 'build/hachidori'), path.join(root, 'dist/hachidori'),
{ allowFileAccess: true }, { allowFileAccess: true },
); );
// Keep a host window alive while the dictionary importer opens/closes its // 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', '/tmp/subminer-filter-output',
{ {
defaultSrc: root, defaultSrc: root,
globalOutDir: path.join(root, 'release'), globalOutDir: path.join(root, 'dist', 'release'),
customBuildOptions: { files: config.build[platform].files }, customBuildOptions: { files: config.build[platform].files },
macroExpander: (value) => value.replaceAll('${arch}', 'x64'), 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', 'stats/dist/nested/fixtures/data.json',
'vendor/texthooker-ui/docs/nested/component.tsx', 'vendor/texthooker-ui/docs/nested/component.tsx',
'dist/launcher/subminer', 'dist/launcher/subminer',
'dist/yomitan/manifest.json',
'dist/hachidori/manifest.json',
'dist/release/SubMiner-1.0.0.AppImage',
'dist/settings/fonts/MPLUS1[wght].ttf', 'dist/settings/fonts/MPLUS1[wght].ttf',
'vendor/subminer-yomitan/ext/manifest.json', 'vendor/subminer-yomitan/ext/manifest.json',
]) { ]) {
+9 -3
View File
@@ -10,14 +10,20 @@ import { Readable } from 'node:stream';
const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..'); const repoRoot = path.resolve(scriptDir, '..');
export const DEFAULT_MANIFEST_PATH = path.join(repoRoot, 'build', 'bun-source-manifest.json'); export const DEFAULT_MANIFEST_PATH = path.join(
repoRoot,
'packaging',
'bun',
'bun-source-manifest.json',
);
export const DEFAULT_RUNTIME_MANIFEST_PATH = path.join( export const DEFAULT_RUNTIME_MANIFEST_PATH = path.join(
repoRoot, repoRoot,
'build', 'packaging',
'bun',
'bun-runtime-manifest.json', 'bun-runtime-manifest.json',
); );
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json'); export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
export const DEFAULT_OUTPUT_DIR = path.join(repoRoot, 'release'); export const DEFAULT_OUTPUT_DIR = path.join(repoRoot, 'dist', 'release');
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-corresponding-source'); export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-corresponding-source');
function isRecord(value) { function isRecord(value) {
+21 -2
View File
@@ -4,6 +4,9 @@ import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { import {
DEFAULT_MANIFEST_PATH,
DEFAULT_PACKAGE_JSON_PATH,
DEFAULT_RUNTIME_MANIFEST_PATH,
downloadArchive, downloadArchive,
parseRegisteredRepositories, parseRegisteredRepositories,
parseSourceManifest, parseSourceManifest,
@@ -67,10 +70,26 @@ describe('Bun corresponding-source manifest', () => {
}); });
}); });
test('default manifest paths resolve to aligned checked-in manifests', async () => {
const [manifestText, runtimeManifestText, packageJsonText] = await Promise.all([
fs.readFile(DEFAULT_MANIFEST_PATH, 'utf8'),
fs.readFile(DEFAULT_RUNTIME_MANIFEST_PATH, 'utf8'),
fs.readFile(DEFAULT_PACKAGE_JSON_PATH, 'utf8'),
]);
expect(() =>
validateRuntimeAlignment(
parseSourceManifest(JSON.parse(manifestText)),
JSON.parse(packageJsonText),
JSON.parse(runtimeManifestText),
),
).not.toThrow();
});
test('rejects stale runtime or package-manager pins', async () => { test('rejects stale runtime or package-manager pins', async () => {
const manifest = parseSourceManifest( const manifest = parseSourceManifest(
JSON.parse( JSON.parse(
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'), await fs.readFile(path.join(projectRoot, 'packaging/bun/bun-source-manifest.json'), 'utf8'),
), ),
); );
const runtimeManifest = { const runtimeManifest = {
@@ -100,7 +119,7 @@ describe('Bun corresponding-source manifest', () => {
test('rejects drift in CMake dependency and WebKit pins', async () => { test('rejects drift in CMake dependency and WebKit pins', async () => {
const manifest = parseSourceManifest( const manifest = parseSourceManifest(
JSON.parse( JSON.parse(
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'), await fs.readFile(path.join(projectRoot, 'packaging/bun/bun-source-manifest.json'), 'utf8'),
), ),
); );
const registrations = manifest.sources const registrations = manifest.sources
+1 -1
View File
@@ -6,7 +6,7 @@ cat <<'EOF'
patch-yomitan.sh is retired. patch-yomitan.sh is retired.
SubMiner now uses the forked source submodule at vendor/subminer-yomitan and builds the SubMiner now uses the forked source submodule at vendor/subminer-yomitan and builds the
Chromium extension artifact into build/yomitan. Chromium extension artifact into dist/yomitan.
Use: Use:
git submodule update --init --recursive git submodule update --init --recursive
+2 -2
View File
@@ -9,9 +9,9 @@ paths=(
"tsconfig.json" "tsconfig.json"
"tsconfig.renderer.json" "tsconfig.renderer.json"
"tsconfig.typecheck.json" "tsconfig.typecheck.json"
".prettierrc.json"
".github" ".github"
"build" "packaging/bun/bun-runtime-manifest.json"
"packaging/bun/bun-source-manifest.json"
"e2e" "e2e"
"launcher" "launcher"
"scripts" "scripts"
+7 -2
View File
@@ -14,9 +14,14 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..'); const repoRoot = path.resolve(scriptDir, '..');
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json'); export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
export const DEFAULT_MANIFEST_PATH = path.join(repoRoot, 'build', 'bun-runtime-manifest.json'); export const DEFAULT_MANIFEST_PATH = path.join(
repoRoot,
'packaging',
'bun',
'bun-runtime-manifest.json',
);
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-runtime'); export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-runtime');
export const DEFAULT_LICENSES_SOURCE_DIR = path.join(repoRoot, 'resources', 'bun', 'licenses'); export const DEFAULT_LICENSES_SOURCE_DIR = path.join(repoRoot, 'packaging', 'bun', 'licenses');
export const STAGED_METADATA_FILE = 'metadata.json'; export const STAGED_METADATA_FILE = 'metadata.json';
export const REQUIRED_LICENSE_FILES = [ export const REQUIRED_LICENSE_FILES = [
'Bun-LICENSE.md', 'Bun-LICENSE.md',
+2 -2
View File
@@ -35,7 +35,7 @@ function runtimeConfig() {
'win32-x64': { file: 'bun-windows-x64-baseline.zip', sha256: '5'.repeat(64) }, 'win32-x64': { file: 'bun-windows-x64-baseline.zip', sha256: '5'.repeat(64) },
}, },
licenseInventoryStatus: 'complete-for-bun-1.3.5-declared-linked-libraries', licenseInventoryStatus: 'complete-for-bun-1.3.5-declared-linked-libraries',
sourceManifest: 'build/bun-source-manifest.json', sourceManifest: 'packaging/bun/bun-source-manifest.json',
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz', correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
}; };
} }
@@ -96,7 +96,7 @@ test('manifest version must match the exact packageManager Bun pin', () => {
releaseTagCommit: 'fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab', releaseTagCommit: 'fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab',
artifacts: {}, artifacts: {},
licenseInventoryStatus: 'partial', licenseInventoryStatus: 'partial',
sourceManifest: 'build/bun-source-manifest.json', sourceManifest: 'packaging/bun/bun-source-manifest.json',
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz', correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
}, },
'1.3.5', '1.3.5',
+64
View File
@@ -82,6 +82,7 @@ type IntegrationInternals = {
fields: Record<string, { value: string }>; fields: Record<string, { value: string }>;
}) => void; }) => void;
rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void; rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void;
reportCardsReady: (noteIds: number[]) => void;
}; };
/** Named stand-ins for AnkiIntegration's long positional constructor arguments. */ /** Named stand-ins for AnkiIntegration's long positional constructor arguments. */
@@ -273,6 +274,18 @@ test('AnkiIntegration resolves merged-away note ids to the kept note id', () =>
assert.equal(integration.resolveCurrentNoteId(444), 444); assert.equal(integration.resolveCurrentNoteId(444), 444);
}); });
test('AnkiIntegration reports ready cards under the note that kept them after a merge', () => {
const integration = createIntegration({});
const ready: number[][] = [];
integration.setCardsReadyCallback((noteIds) => ready.push(noteIds));
const internals = internalsOf(integration);
internals.rememberMergedNoteIds(111, 222);
internals.reportCardsReady([111, 222, 444]);
assert.deepEqual(ready, [[222, 444]]);
});
// --- Sentence highlighting -------------------------------------------------- // --- Sentence highlighting --------------------------------------------------
const MPV_SENTENCE = '先日 貴様らが潜入した キールダンジョンから―'; const MPV_SENTENCE = '先日 貴様らが潜入した キールダンジョンから―';
@@ -404,6 +417,57 @@ test('AnkiIntegration triggers field grouping after a local duplicate sentence c
assert.equal(groupingTriggered, 1); assert.equal(groupingTriggered, 1);
}); });
test('AnkiIntegration reports a new sentence card ready only after field grouping', async () => {
const integration = createIntegration({
isKiku: { enabled: true, fieldGrouping: 'manual' },
});
const events: string[] = [];
integration.setCardsReadyCallback((noteIds) => events.push(`ready ${noteIds.join(',')}`));
const internals = internalsOf(integration);
internals.cardCreationService = {
createSentenceCard: async () => {
integration.trackDuplicateNoteIdsForNote(42, [7]);
internals.reportCardsReady([42]);
return true;
},
};
internals.fieldGroupingService = {
triggerFieldGroupingForLastAddedCard: async () => {
events.push('group');
},
};
assert.equal(await integration.createSentenceCard('duplicate sentence', 0, 1), true);
assert.deepEqual(events, ['group', 'ready 42']);
});
test('AnkiIntegration reports a card with queued YouTube media ready once the media lands', async () => {
const events: string[] = [];
const integration = createIntegration({
fields: { audio: 'ExpressionAudio', image: 'Picture' },
media: { imageFormat: 'jpg' },
behavior: { notificationType: 'osd' },
});
installFakeAnki(integration, {
noteFieldNames: ['ExpressionAudio', 'Picture'],
mediaGenerator: {
generateAudio: async () => Buffer.from('audio'),
generateScreenshot: async () => Buffer.from('image'),
},
});
integration.setCardsReadyCallback((noteIds) => events.push(`ready ${noteIds.join(',')}`));
const internals = internalsOf(integration);
internals.queuePendingYoutubeMediaUpdate(youtubeJob({ noteId: 101 }));
// Note 7 was merged into 101, so it waits on 101's media too.
internals.rememberMergedNoteIds(7, 101);
internals.reportCardsReady([101, 7, 5]);
assert.deepEqual(events, ['ready 5']);
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
assert.deepEqual(events, ['ready 5', 'ready 101']);
});
test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => { test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => {
const osdMessages: string[] = []; const osdMessages: string[] = [];
const integration = createIntegration( const integration = createIntegration(
+54
View File
@@ -240,6 +240,9 @@ export class AnkiIntegration {
private runtime: AnkiIntegrationRuntime; private runtime: AnkiIntegrationRuntime;
private aiConfig: AiConfig; private aiConfig: AiConfig;
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null; private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
private cardsReadyCallback: ((noteIds: number[]) => void) | null = null;
// Ready reports collected while createSentenceCard still has field grouping to run.
private heldReadyNoteIds: number[] | null = null;
private knownWordCacheUpdatedCallback: (() => void) | null = null; private knownWordCacheUpdatedCallback: (() => void) | null = null;
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null; private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
private generateSentenceFuriganaCallback: private generateSentenceFuriganaCallback:
@@ -336,6 +339,33 @@ export class AnkiIntegration {
} }
} }
private reportCardsReady(noteIds: number[]): void {
if (this.heldReadyNoteIds) {
this.heldReadyNoteIds.push(...noteIds);
return;
}
// Notes still waiting on queued YouTube media, directly or through the note they were
// merged into, are reported by the queue once it settles.
const queue = this.pendingYoutubeMediaQueue;
this.emitCardsReady(
noteIds.filter(
(noteId) =>
!queue.hasPendingNote(noteId) && !queue.hasPendingNote(this.resolveCurrentNoteId(noteId)),
),
);
}
private emitCardsReady(noteIds: number[]): void {
if (noteIds.length === 0) return;
// Field grouping may have merged a note away; report the note that kept its content.
const currentNoteIds = [...new Set(noteIds.map((noteId) => this.resolveCurrentNoteId(noteId)))];
try {
this.cardsReadyCallback?.(currentNoteIds);
} catch (error) {
log.warn('cardsReady callback failed:', (error as Error).message);
}
}
private getMediaResolverOptions(): MediaGenerationInputResolverOptions { private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
const options: MediaGenerationInputResolverOptions = { const options: MediaGenerationInputResolverOptions = {
logDebug: (message) => log.debug(message), logDebug: (message) => log.debug(message),
@@ -414,6 +444,7 @@ export class AnkiIntegration {
showStatusNotification: (message) => this.showStatusNotification(message), showStatusNotification: (message) => this.showStatusNotification(message),
showNotification: (noteId, label, errorSuffix) => showNotification: (noteId, label, errorSuffix) =>
this.showNotification(noteId, label, errorSuffix), this.showNotification(noteId, label, errorSuffix),
onUpdateSettled: (noteId) => this.reportCardsReady([noteId]),
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)), logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)), logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
logError: (...args) => log.error(args[0] as string, ...args.slice(1)), logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
@@ -444,6 +475,7 @@ export class AnkiIntegration {
recordCardsAdded: (count, noteIds) => { recordCardsAdded: (count, noteIds) => {
this.recordCardsMinedSafely(count, noteIds, 'polling'); this.recordCardsMinedSafely(count, noteIds, 'polling');
}, },
onCardsReady: (noteIds) => this.reportCardsReady(noteIds),
isUpdateInProgress: () => this.updateInProgress, isUpdateInProgress: () => this.updateInProgress,
setUpdateInProgress: (value) => { setUpdateInProgress: (value) => {
this.updateInProgress = value; this.updateInProgress = value;
@@ -468,6 +500,7 @@ export class AnkiIntegration {
recordCardsAdded: (count, noteIds) => { recordCardsAdded: (count, noteIds) => {
this.recordCardsMinedSafely(count, noteIds, 'proxy'); this.recordCardsMinedSafely(count, noteIds, 'proxy');
}, },
onCardsReady: (noteIds) => this.reportCardsReady(noteIds),
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => { trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
this.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds); this.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
}, },
@@ -593,6 +626,7 @@ export class AnkiIntegration {
recordCardsMinedCallback: (count, noteIds) => { recordCardsMinedCallback: (count, noteIds) => {
this.recordCardsMinedSafely(count, noteIds, 'card creation'); this.recordCardsMinedSafely(count, noteIds, 'card creation');
}, },
onCardsReady: (noteIds) => this.reportCardsReady(noteIds),
reviewMediaTiming: (request) => this.reviewMediaTiming(request), reviewMediaTiming: (request) => this.reviewMediaTiming(request),
}); });
} }
@@ -1543,6 +1577,10 @@ export class AnkiIntegration {
secondarySubText?: string, secondarySubText?: string,
): Promise<boolean> { ): Promise<boolean> {
const trackedDuplicateNoteIdsBeforeCreate = new Set(this.trackedDuplicateNoteIds.keys()); const trackedDuplicateNoteIdsBeforeCreate = new Set(this.trackedDuplicateNoteIds.keys());
// Field grouping below can still rewrite the new card, so hold its ready report until then.
const ownsHold = this.heldReadyNoteIds === null;
if (ownsHold) this.heldReadyNoteIds = [];
try {
const created = await this.cardCreationService.createSentenceCard( const created = await this.cardCreationService.createSentenceCard(
sentence, sentence,
startTime, startTime,
@@ -1562,6 +1600,13 @@ export class AnkiIntegration {
} }
} }
return created; return created;
} finally {
if (ownsHold) {
const held = this.heldReadyNoteIds ?? [];
this.heldReadyNoteIds = null;
this.reportCardsReady(held);
}
}
} }
trackDuplicateNoteIdsForNote(noteId: number, duplicateNoteIds: number[]): void { trackDuplicateNoteIdsForNote(noteId: number, duplicateNoteIds: number[]): void {
@@ -1780,6 +1825,15 @@ export class AnkiIntegration {
this.recordCardsMinedCallback = callback; this.recordCardsMinedCallback = callback;
} }
/**
* Called once SubMiner has finished writing a mined card (fields, media including queued YouTube
* media, and field grouping), or as soon as polling or the proxy sees it when auto-update is off.
* Ids merged away by field grouping are reported as the note that kept them.
*/
setCardsReadyCallback(callback: ((noteIds: number[]) => void) | null): void {
this.cardsReadyCallback = callback;
}
setKnownWordCacheUpdatedCallback(callback: (() => void) | null): void { setKnownWordCacheUpdatedCallback(callback: (() => void) | null): void {
this.knownWordCacheUpdatedCallback = callback; this.knownWordCacheUpdatedCallback = callback;
} }
@@ -17,17 +17,23 @@ async function waitForCondition(
throw new Error('Timed out waiting for condition'); throw new Error('Timed out waiting for condition');
} }
test('proxy enqueues addNote result for enrichment', async () => { test('proxy enqueues addNote result for enrichment and reports it ready afterwards', async () => {
const processed: number[] = []; const processed: number[] = [];
const recordedCards: number[] = []; const recordedCards: number[] = [];
const events: string[] = [];
const proxy = new AnkiConnectProxyServer({ const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true, shouldAutoUpdateNewCards: () => true,
processNewCard: async (noteId) => { processNewCard: async (noteId) => {
processed.push(noteId); processed.push(noteId);
events.push(`process ${noteId}`);
throw new Error('enrichment failed');
}, },
recordCardsAdded: (count) => { recordCardsAdded: (count) => {
recordedCards.push(count); recordedCards.push(count);
}, },
onCardsReady: (noteIds) => {
events.push(`ready ${noteIds.join(',')}`);
},
logInfo: () => undefined, logInfo: () => undefined,
logWarn: () => undefined, logWarn: () => undefined,
logError: () => undefined, logError: () => undefined,
@@ -42,9 +48,44 @@ test('proxy enqueues addNote result for enrichment', async () => {
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'), Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
); );
await waitForCondition(() => processed.length === 1); await waitForCondition(() => events.length === 2);
assert.deepEqual(processed, [42]); assert.deepEqual(processed, [42]);
assert.deepEqual(recordedCards, [1]); assert.deepEqual(recordedCards, [1]);
assert.deepEqual(events, ['process 42', 'ready 42']);
});
test('proxy reports an added note ready right away when auto-update is off', () => {
const processed: number[] = [];
const recordedCards: number[] = [];
const ready: number[][] = [];
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => false,
processNewCard: async (noteId) => {
processed.push(noteId);
},
recordCardsAdded: (count) => {
recordedCards.push(count);
},
onCardsReady: (noteIds) => {
ready.push(noteIds);
},
logInfo: () => undefined,
logWarn: () => undefined,
logError: () => undefined,
});
(
proxy as unknown as {
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
}
).maybeEnqueueFromRequest(
{ action: 'addNote' },
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
);
assert.deepEqual(ready, [[42]]);
assert.deepEqual(processed, []);
assert.deepEqual(recordedCards, []);
}); });
test('proxy enqueues addNote bare numeric response for enrichment', async () => { test('proxy enqueues addNote bare numeric response for enrichment', async () => {
+13 -2
View File
@@ -16,6 +16,8 @@ export interface AnkiConnectProxyServerDeps {
shouldAutoUpdateNewCards: () => boolean; shouldAutoUpdateNewCards: () => boolean;
processNewCard: (noteId: number) => Promise<void>; processNewCard: (noteId: number) => Promise<void>;
recordCardsAdded?: (count: number, noteIds: number[]) => void; recordCardsAdded?: (count: number, noteIds: number[]) => void;
/** Called once SubMiner is done with an added note: after enrichment, or right away when it is off. */
onCardsReady?: (noteIds: number[]) => void;
trackAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void; trackAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
getDeck?: () => string | undefined; getDeck?: () => string | undefined;
findNotes?: ( findNotes?: (
@@ -216,15 +218,17 @@ export class AnkiConnectProxyServer {
requestJson: Record<string, unknown> | null, requestJson: Record<string, unknown> | null,
responseBody: Buffer, responseBody: Buffer,
): void { ): void {
if (!requestJson || !this.deps.shouldAutoUpdateNewCards()) { if (!requestJson) {
return; return;
} }
const autoUpdate = this.deps.shouldAutoUpdateNewCards();
const action = const action =
typeof requestJson.action === 'string' typeof requestJson.action === 'string'
? requestJson.action ? requestJson.action
: String(requestJson.action ?? ''); : String(requestJson.action ?? '');
if (action === 'updateNoteFields') { if (action === 'updateNoteFields') {
if (!autoUpdate) return;
const params = requestJson.params; const params = requestJson.params;
if ( if (
!params || !params ||
@@ -277,7 +281,12 @@ export class AnkiConnectProxyServer {
params.subminerEnrich === false params.subminerEnrich === false
) { ) {
// Stats owns the saved sentence and media; the live mpv context is unrelated. // Stats owns the saved sentence and media; the live mpv context is unrelated.
if (noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds); if (autoUpdate && noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds);
return;
}
if (!autoUpdate) {
// Nothing to enrich, so the added notes are already in their final state.
if (noteIds.length > 0) this.deps.onCardsReady?.(noteIds);
return; return;
} }
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult); this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
@@ -534,6 +543,7 @@ export class AnkiConnectProxyServer {
this.pendingNoteIdSet.delete(noteId); this.pendingNoteIdSet.delete(noteId);
if (!this.deps.shouldAutoUpdateNewCards()) { if (!this.deps.shouldAutoUpdateNewCards()) {
this.deps.onCardsReady?.([noteId]);
continue; continue;
} }
@@ -547,6 +557,7 @@ export class AnkiConnectProxyServer {
); );
} finally { } finally {
this.inFlightNoteIds.delete(noteId); this.inFlightNoteIds.delete(noteId);
this.deps.onCardsReady?.([noteId]);
} }
} }
} finally { } finally {
@@ -31,6 +31,29 @@ test('CardCreationService counts locally created sentence cards', async () => {
assert.deepEqual(minedCards, [{ count: 1, noteIds: [42] }]); assert.deepEqual(minedCards, [{ count: 1, noteIds: [42] }]);
}); });
test('CardCreationService reports a created card ready after its fields are written', async () => {
const events: string[] = [];
const service = new CardCreationService(
createCardCreationDeps({
client: createFakeClient({
notesInfo: async () => [{ noteId: 42, fields: { Sentence: { value: 'existing' } } }],
updateNoteFields: async () => {
events.push('update');
},
}),
setCardTypeFields: (updatedFields) => {
updatedFields.CardType = 'sentence';
},
onCardsReady: (noteIds) => {
events.push(`ready ${noteIds.join(',')}`);
},
}),
);
assert.equal(await service.createSentenceCard('テスト', 0, 1), true);
assert.deepEqual(events, ['update', 'ready 42']);
});
const THROWING_HOOKS: Array<{ const THROWING_HOOKS: Array<{
name: string; name: string;
hook: Partial<CardCreationDeps>; hook: Partial<CardCreationDeps>;
+6
View File
@@ -152,6 +152,8 @@ interface CardCreationDeps {
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void; trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>; findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void; recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
/** Called once card creation is done writing a card; queued YouTube media may still be pending. */
onCardsReady?: (noteIds: number[]) => void;
reviewMediaTiming?: ( reviewMediaTiming?: (
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>, request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
) => Promise<MediaTimingReviewDecision>; ) => Promise<MediaTimingReviewDecision>;
@@ -608,6 +610,7 @@ export class CardCreationService {
endTime = cappedEndTime; endTime = cappedEndTime;
} }
let createdNoteId: number | null = null;
try { try {
return await this.deps.withUpdateProgress('Creating sentence card', async () => { return await this.deps.withUpdateProgress('Creating sentence card', async () => {
const timingDecision = this.deps.reviewMediaTiming const timingDecision = this.deps.reviewMediaTiming
@@ -720,6 +723,7 @@ export class CardCreationService {
fields, fields,
this.getConfiguredAnkiTags(), this.getConfiguredAnkiTags(),
); );
createdNoteId = noteId;
log.info('Created sentence card:', noteId); log.info('Created sentence card:', noteId);
} catch (error) { } catch (error) {
log.error('Failed to create sentence card:', (error as Error).message); log.error('Failed to create sentence card:', (error as Error).message);
@@ -884,6 +888,8 @@ export class CardCreationService {
log.error('Error creating sentence card:', (error as Error).message); log.error('Error creating sentence card:', (error as Error).message);
this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false); this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false);
return false; return false;
} finally {
if (createdNoteId !== null) this.deps.onCardsReady?.([createdNoteId]);
} }
} }
@@ -56,6 +56,8 @@ export interface PendingYoutubeMediaQueueDeps {
) => string; ) => string;
showStatusNotification: (message: string) => void; showStatusNotification: (message: string) => void;
showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>; showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>;
/** Called once a queued note's media was applied or dropped, so the card is in its final state. */
onUpdateSettled?: (noteId: number) => void;
logInfo: (message: string, ...args: unknown[]) => void; logInfo: (message: string, ...args: unknown[]) => void;
logWarn: (message: string, ...args: unknown[]) => void; logWarn: (message: string, ...args: unknown[]) => void;
logError: (message: string, ...args: unknown[]) => void; logError: (message: string, ...args: unknown[]) => void;
@@ -79,9 +81,18 @@ function shouldGenerateImage(config: AnkiConnectConfig): boolean {
export class PendingYoutubeMediaQueue { export class PendingYoutubeMediaQueue {
private updates: PendingYoutubeMediaUpdate[] = []; private updates: PendingYoutubeMediaUpdate[] = [];
// Jobs taken off the queue whose media is still being applied or dropped.
private settlingNoteIds = new Set<number>();
constructor(private readonly deps: PendingYoutubeMediaQueueDeps) {} constructor(private readonly deps: PendingYoutubeMediaQueueDeps) {}
/** True while the note still waits on queued media. */
hasPendingNote(noteId: number): boolean {
return (
this.settlingNoteIds.has(noteId) || this.updates.some((update) => update.noteId === noteId)
);
}
enqueue(job: PendingYoutubeMediaUpdate): void { enqueue(job: PendingYoutubeMediaUpdate): void {
if (!job.generateAudio && !job.generateImage) { if (!job.generateAudio && !job.generateImage) {
return; return;
@@ -195,6 +206,8 @@ export class PendingYoutubeMediaQueue {
'Failed to apply queued YouTube media update:', 'Failed to apply queued YouTube media update:',
error instanceof Error ? error.message : String(error), error instanceof Error ? error.message : String(error),
); );
} finally {
this.settle(job);
} }
} }
@@ -234,16 +247,24 @@ export class PendingYoutubeMediaQueue {
'Failed to show queued YouTube media failure notification:', 'Failed to show queued YouTube media failure notification:',
error instanceof Error ? error.message : String(error), error instanceof Error ? error.message : String(error),
); );
} finally {
this.settle(job);
} }
} }
} }
private settle(job: PendingYoutubeMediaUpdate): void {
this.settlingNoteIds.delete(job.noteId);
this.deps.onUpdateSettled?.(job.noteId);
}
private takeMatchingUpdates(sourceUrl: string): PendingYoutubeMediaUpdate[] { private takeMatchingUpdates(sourceUrl: string): PendingYoutubeMediaUpdate[] {
const matched: PendingYoutubeMediaUpdate[] = []; const matched: PendingYoutubeMediaUpdate[] = [];
const remaining: PendingYoutubeMediaUpdate[] = []; const remaining: PendingYoutubeMediaUpdate[] = [];
for (const job of this.updates) { for (const job of this.updates) {
if (youtubeMediaUrlsMatch(job.sourceUrl, sourceUrl)) { if (youtubeMediaUrlsMatch(job.sourceUrl, sourceUrl)) {
matched.push(job); matched.push(job);
this.settlingNoteIds.add(job.noteId);
} else { } else {
remaining.push(job); remaining.push(job);
} }
+53 -2
View File
@@ -3,9 +3,10 @@ import test from 'node:test';
import { PollingRunner } from './polling'; import { PollingRunner } from './polling';
test('polling runner records newly added cards after initialization', async () => { test('polling runner records new cards and reports each ready after enrichment', async () => {
const originalDateNow = Date.now; const originalDateNow = Date.now;
const recordedCards: number[] = []; const recordedCards: number[] = [];
const events: string[] = [];
let tracked = new Set<number>(); let tracked = new Set<number>();
const responses = [ const responses = [
[10, 11], [10, 11],
@@ -18,10 +19,15 @@ test('polling runner records newly added cards after initialization', async () =
getPollingRate: () => 250, getPollingRate: () => 250,
findNotes: async () => responses.shift() ?? [], findNotes: async () => responses.shift() ?? [],
shouldAutoUpdateNewCards: () => true, shouldAutoUpdateNewCards: () => true,
processNewCard: async () => undefined, processNewCard: async (noteId) => {
events.push(`process ${noteId}`);
},
recordCardsAdded: (count) => { recordCardsAdded: (count) => {
recordedCards.push(count); recordedCards.push(count);
}, },
onCardsReady: (noteIds) => {
events.push(`ready ${noteIds.join(',')}`);
},
isUpdateInProgress: () => false, isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined, setUpdateInProgress: () => undefined,
getTrackedNoteIds: () => tracked, getTrackedNoteIds: () => tracked,
@@ -38,6 +44,51 @@ test('polling runner records newly added cards after initialization', async () =
await runner.pollOnce(); await runner.pollOnce();
assert.deepEqual(recordedCards, [2]); assert.deepEqual(recordedCards, [2]);
assert.deepEqual(events, ['process 12', 'ready 12', 'process 13', 'ready 13']);
} finally {
Date.now = originalDateNow;
}
});
test('polling runner keeps going after one note fails to enrich', async () => {
const originalDateNow = Date.now;
const events: string[] = [];
const warnings: unknown[][] = [];
let tracked = new Set<number>();
const responses = [[10], [10, 12, 13]];
try {
Date.now = () => 120_000;
const runner = new PollingRunner({
getDeck: () => undefined,
getPollingRate: () => 250,
findNotes: async () => responses.shift() ?? [],
shouldAutoUpdateNewCards: () => true,
processNewCard: async (noteId) => {
events.push(`process ${noteId}`);
if (noteId === 12) throw new Error('overlay callback failed');
},
onCardsReady: (noteIds) => {
events.push(`ready ${noteIds.join(',')}`);
},
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
getTrackedNoteIds: () => tracked,
setTrackedNoteIds: (noteIds) => {
tracked = noteIds;
},
showStatusNotification: () => undefined,
logDebug: () => undefined,
logInfo: () => undefined,
logWarn: (...args) => {
warnings.push(args);
},
});
await runner.pollOnce();
await runner.pollOnce();
assert.deepEqual(events, ['process 12', 'ready 12', 'process 13', 'ready 13']);
assert.deepEqual(warnings, [['Failed to auto-enrich note 12:', 'overlay callback failed']]);
} finally { } finally {
Date.now = originalDateNow; Date.now = originalDateNow;
} }
+10
View File
@@ -10,6 +10,8 @@ export interface PollingRunnerDeps {
shouldAutoUpdateNewCards: () => boolean; shouldAutoUpdateNewCards: () => boolean;
processNewCard: (noteId: number) => Promise<void>; processNewCard: (noteId: number) => Promise<void>;
recordCardsAdded?: (count: number, noteIds: number[]) => void; recordCardsAdded?: (count: number, noteIds: number[]) => void;
/** Called once SubMiner is done with a new note: after enrichment, or at detection when it is off. */
onCardsReady?: (noteIds: number[]) => void;
isUpdateInProgress: () => boolean; isUpdateInProgress: () => boolean;
setUpdateInProgress: (value: boolean) => void; setUpdateInProgress: (value: boolean) => void;
getTrackedNoteIds: () => Set<number>; getTrackedNoteIds: () => Set<number>;
@@ -85,9 +87,17 @@ export class PollingRunner {
if (this.deps.shouldAutoUpdateNewCards()) { if (this.deps.shouldAutoUpdateNewCards()) {
for (const noteId of newNoteIds) { for (const noteId of newNoteIds) {
try {
await this.deps.processNewCard(noteId); await this.deps.processNewCard(noteId);
} catch (error) {
// One failed note must not leave the rest of the batch unprocessed.
this.deps.logWarn(`Failed to auto-enrich note ${noteId}:`, (error as Error).message);
} finally {
this.deps.onCardsReady?.([noteId]);
}
} }
} else { } else {
this.deps.onCardsReady?.(newNoteIds);
this.deps.logInfo( this.deps.logInfo(
'New card detected (auto-update disabled). Press Ctrl+V to update from clipboard.', 'New card detected (auto-update disabled). Press Ctrl+V to update from clipboard.',
); );
+8
View File
@@ -340,6 +340,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(sessionAction), true); assert.equal(shouldStartApp(sessionAction), true);
assert.equal(commandNeedsOverlayRuntime(sessionAction), true); assert.equal(commandNeedsOverlayRuntime(sessionAction), true);
const pluginAction = parseArgs(['--plugin-action', 'anime/open']);
assert.equal(pluginAction.pluginAction, 'anime/open');
assert.equal(parseArgs(['--plugin-action=anime/open']).pluginAction, 'anime/open');
assert.equal(hasExplicitCommand(pluginAction), true);
assert.equal(shouldStartApp(pluginAction), true);
// The plugin decides what it needs; a window-only action must not force the overlay up.
assert.equal(commandNeedsOverlayRuntime(pluginAction), false);
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']); const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true); assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
+12
View File
@@ -48,6 +48,8 @@ export interface CliArgs {
cycleRuntimeOptionId?: string; cycleRuntimeOptionId?: string;
cycleRuntimeOptionDirection?: 1 | -1; cycleRuntimeOptionDirection?: 1 | -1;
sessionAction?: SessionActionDispatchRequest; sessionAction?: SessionActionDispatchRequest;
/** `<plugin id>/<action name>` to run, like a `__plugin-action:` keybinding. */
pluginAction?: string;
copySubtitleCount?: number; copySubtitleCount?: number;
mineSentenceCount?: number; mineSentenceCount?: number;
anilistStatus: boolean; anilistStatus: boolean;
@@ -338,6 +340,12 @@ export function parseArgs(argv: string[]): CliArgs {
} else if (arg === '--session-action') { } else if (arg === '--session-action') {
const parsed = parseSessionAction(readValue(argv[i + 1])); const parsed = parseSessionAction(readValue(argv[i + 1]));
if (parsed) args.sessionAction = parsed; if (parsed) args.sessionAction = parsed;
} else if (arg.startsWith('--plugin-action=')) {
const value = arg.slice('--plugin-action='.length).trim();
if (value) args.pluginAction = value;
} else if (arg === '--plugin-action') {
const value = readValue(argv[i + 1])?.trim();
if (value) args.pluginAction = value;
} else if (arg.startsWith('--copy-subtitle-count=')) { } else if (arg.startsWith('--copy-subtitle-count=')) {
const value = Number(arg.split('=', 2)[1]); const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value; if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
@@ -601,6 +609,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.playbackFeedback !== undefined || args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined || args.sessionAction !== undefined ||
args.pluginAction !== undefined ||
args.copySubtitleCount !== undefined || args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined || args.mineSentenceCount !== undefined ||
args.anilistStatus || args.anilistStatus ||
@@ -682,6 +691,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
args.playbackFeedback === undefined && args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined && args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined && args.sessionAction === undefined &&
args.pluginAction === undefined &&
args.copySubtitleCount === undefined && args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined && args.mineSentenceCount === undefined &&
!args.anilistStatus && !args.anilistStatus &&
@@ -752,6 +762,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.playbackFeedback !== undefined || args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined || args.sessionAction !== undefined ||
args.pluginAction !== undefined ||
args.copySubtitleCount !== undefined || args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined || args.mineSentenceCount !== undefined ||
args.dictionary || args.dictionary ||
@@ -819,6 +830,7 @@ export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
args.playbackFeedback === undefined && args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined && args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined && args.sessionAction === undefined &&
args.pluginAction === undefined &&
args.copySubtitleCount === undefined && args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined && args.mineSentenceCount === undefined &&
!args.anilistStatus && !args.anilistStatus &&
+1
View File
@@ -46,6 +46,7 @@ ${B}Mining${R}
--open-session-help Open session help modal --open-session-help Open session help modal
--open-controller-select Open controller select modal --open-controller-select Open controller select modal
--open-controller-debug Open controller debug modal --open-controller-debug Open controller debug modal
--plugin-action ${D}<id>/<name>${R} Run a plugin action
${B}AniList${R} ${B}AniList${R}
--anilist-setup Open AniList authentication flow --anilist-setup Open AniList authentication flow
+2
View File
@@ -37,6 +37,7 @@ const {
startupWarmups, startupWarmups,
updates, updates,
notifications, notifications,
plugins,
auto_start_overlay, auto_start_overlay,
} = CORE_DEFAULT_CONFIG; } = CORE_DEFAULT_CONFIG;
const { const {
@@ -74,6 +75,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
startupWarmups, startupWarmups,
updates, updates,
notifications, notifications,
plugins,
subtitleStyle, subtitleStyle,
subtitleSidebar, subtitleSidebar,
auto_start_overlay, auto_start_overlay,
+2
View File
@@ -17,6 +17,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'startupWarmups' | 'startupWarmups'
| 'updates' | 'updates'
| 'notifications' | 'notifications'
| 'plugins'
| 'auto_start_overlay' | 'auto_start_overlay'
> = { > = {
dictionaryBackend: 'yomitan', dictionaryBackend: 'yomitan',
@@ -145,5 +146,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
notifications: { notifications: {
overlayPosition: 'top-right', overlayPosition: 'top-right',
}, },
plugins: {},
auto_start_overlay: true, auto_start_overlay: true,
}; };
+7
View File
@@ -546,6 +546,13 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.notifications.overlayPosition, defaultValue: defaultConfig.notifications.overlayPosition,
description: 'Position for in-overlay notification cards.', description: 'Position for in-overlay notification cards.',
}, },
{
path: 'plugins',
kind: 'object',
defaultValue: defaultConfig.plugins,
description:
'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.',
},
{ {
path: 'shortcuts.multiCopyTimeoutMs', path: 'shortcuts.multiCopyTimeoutMs',
kind: 'number', kind: 'number',
+1
View File
@@ -57,6 +57,7 @@ export const SPECIAL_COMMANDS = {
ANIMETOSHO_OPEN: '__animetosho-open', ANIMETOSHO_OPEN: '__animetosho-open',
TSUKIHIME_OPEN: '__tsukihime-open', TSUKIHIME_OPEN: '__tsukihime-open',
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
PLUGIN_ACTION_PREFIX: '__plugin-action:',
REPLAY_SUBTITLE: '__replay-subtitle', REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle', PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open', YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
@@ -92,6 +92,15 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: position changes apply to the next overlay notification.'], notes: ['Hot-reload: position changes apply to the next overlay notification.'],
key: 'notifications', key: 'notifications',
}, },
{
title: 'Plugins',
description: [
'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.',
],
notes: ['Restart SubMiner after changing plugins.'],
key: 'plugins',
},
{ {
title: 'Keyboard Shortcuts', title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'], description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
+2
View File
@@ -4,6 +4,7 @@ import { createResolveContext } from './resolve/context';
import { applyCoreDomainConfig } from './resolve/core-domains'; import { applyCoreDomainConfig } from './resolve/core-domains';
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking'; import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
import { applyIntegrationConfig } from './resolve/integrations'; import { applyIntegrationConfig } from './resolve/integrations';
import { applyPluginsConfig } from './resolve/plugins';
import { applyStatsConfig } from './resolve/stats'; import { applyStatsConfig } from './resolve/stats';
import { applySubtitleDomainConfig } from './resolve/subtitle-domains'; import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
import { applyTopLevelConfig } from './resolve/top-level'; import { applyTopLevelConfig } from './resolve/top-level';
@@ -15,6 +16,7 @@ const APPLY_RESOLVE_STEPS = [
applyIntegrationConfig, applyIntegrationConfig,
applyImmersionTrackingConfig, applyImmersionTrackingConfig,
applyStatsConfig, applyStatsConfig,
applyPluginsConfig,
applyAnkiConnectResolution, applyAnkiConnectResolution,
] as const; ] as const;
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveConfig } from '../resolve';
test('resolveConfig keeps plugin entries and their settings', () => {
const { resolved, warnings } = resolveConfig({
plugins: {
'ai-explain': { enabled: true, settings: { codexPath: 'codex', before: 2 } },
dormant: {},
},
});
assert.deepEqual(resolved.plugins, {
'ai-explain': { enabled: true, settings: { codexPath: 'codex', before: 2 } },
dormant: { enabled: false, settings: {} },
});
assert.deepEqual(warnings, []);
});
test('resolveConfig warns about malformed plugin entries and keeps them disabled', () => {
const { resolved, warnings } = resolveConfig({
plugins: {
flag: { enabled: 'yes' as never, settings: [] as never },
scalar: true as never,
},
});
assert.deepEqual(resolved.plugins, { flag: { enabled: false, settings: {} } });
assert.deepEqual(
warnings.map((warning) => warning.path),
['plugins.flag.enabled', 'plugins.flag.settings', 'plugins.scalar'],
);
});
+30
View File
@@ -0,0 +1,30 @@
import { ResolveContext } from './context';
import { isObject } from './shared';
/** Keeps well-formed `plugins.<id>` entries; ids are validated again when the plugin loads. */
export function applyPluginsConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
if (src.plugins === undefined) return;
if (!isObject(src.plugins)) {
warn('plugins', src.plugins, resolved.plugins, 'Expected object keyed by plugin id.');
return;
}
for (const [id, entry] of Object.entries(src.plugins)) {
const path = `plugins.${id}`;
if (!isObject(entry)) {
warn(path, entry, undefined, 'Expected { "enabled": boolean, "settings"?: object }.');
continue;
}
if (entry.enabled !== undefined && typeof entry.enabled !== 'boolean') {
warn(`${path}.enabled`, entry.enabled, false, 'Expected boolean.');
}
if (entry.settings !== undefined && !isObject(entry.settings)) {
warn(`${path}.settings`, entry.settings, {}, 'Expected object.');
}
resolved.plugins[id] = {
enabled: entry.enabled === true,
settings: isObject(entry.settings) ? { ...entry.settings } : {},
};
}
}
+4
View File
@@ -81,6 +81,7 @@ const JSON_OBJECT_FIELDS = new Set([
'keybindings', 'keybindings',
'controller.bindings', 'controller.bindings',
'controller.profiles', 'controller.profiles',
'plugins',
'ankiConnect.knownWords.decks', 'ankiConnect.knownWords.decks',
'subtitleStyle.css', 'subtitleStyle.css',
'subtitleStyle.secondary.css', 'subtitleStyle.secondary.css',
@@ -441,6 +442,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('notifications.')) { if (path.startsWith('notifications.')) {
return { category: 'behavior', section: 'Notifications' }; return { category: 'behavior', section: 'Notifications' };
} }
if (path === 'plugins') {
return { category: 'advanced', section: 'Plugins' };
}
if (path === 'mpv.aniskipButtonKey') { if (path === 'mpv.aniskipButtonKey') {
return { category: 'input', section: 'Overlay Shortcuts' }; return { category: 'input', section: 'Overlay Shortcuts' };
} }
+5
View File
@@ -13,6 +13,7 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'), loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
resolveKeybindings: () => calls.push('resolveKeybindings'), resolveKeybindings: () => calls.push('resolveKeybindings'),
createMpvClient: () => calls.push('createMpvClient'), createMpvClient: () => calls.push('createMpvClient'),
startPlugins: () => calls.push('startPlugins'),
reloadConfig: () => calls.push('reloadConfig'), reloadConfig: () => calls.push('reloadConfig'),
getResolvedConfig: () => ({ getResolvedConfig: () => ({
websocket: { enabled: 'auto' }, websocket: { enabled: 'auto' },
@@ -162,6 +163,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
assert.equal(calls.includes('loadSubtitlePosition'), false); assert.equal(calls.includes('loadSubtitlePosition'), false);
assert.equal(calls.includes('resolveKeybindings'), false); assert.equal(calls.includes('resolveKeybindings'), false);
assert.equal(calls.includes('createMpvClient'), false); assert.equal(calls.includes('createMpvClient'), false);
assert.equal(calls.includes('startPlugins'), false);
assert.equal(calls.includes('initRuntimeOptionsManager'), false); assert.equal(calls.includes('initRuntimeOptionsManager'), false);
assert.equal(calls.includes('createImmersionTracker'), false); assert.equal(calls.includes('createImmersionTracker'), false);
assert.equal(calls.includes('startJellyfinRemoteSession'), false); assert.equal(calls.includes('startJellyfinRemoteSession'), false);
@@ -326,6 +328,9 @@ test('runAppReadyRuntime starts background warmups after overlay startup', async
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups')); assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups')); assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.includes('setLogLevel:info:config'));
assert.ok(calls.indexOf('setLogLevel:info:config') < calls.indexOf('startPlugins'));
assert.ok(calls.indexOf('startPlugins') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups')); assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs')); assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
}); });
+17
View File
@@ -737,6 +737,23 @@ test('handleCliCommand dispatches generic session action payloads', async () =>
}); });
}); });
test('handleCliCommand runs plugin actions through the session action dispatcher', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(makeArgs({ pluginAction: 'anime/open' }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'runPluginAction',
payload: { pluginActionId: 'anime/open' },
});
});
test('handleCliCommand dispatches mark-watched session action', async () => { test('handleCliCommand dispatches mark-watched session action', async () => {
let request: unknown = null; let request: unknown = null;
const { deps } = createDeps({ const { deps } = createDeps({
+6
View File
@@ -422,6 +422,12 @@ export function handleCliCommand(
`sessionAction:${args.sessionAction.actionId}`, `sessionAction:${args.sessionAction.actionId}`,
'Session action failed', 'Session action failed',
); );
} else if (args.pluginAction) {
dispatchCliSessionAction(
{ actionId: 'runPluginAction', payload: { pluginActionId: args.pluginAction } },
`pluginAction:${args.pluginAction}`,
'Plugin action failed',
);
} else if (args.toggle || args.toggleVisibleOverlay) { } else if (args.toggle || args.toggleVisibleOverlay) {
deps.toggleVisibleOverlay(); deps.toggleVisibleOverlay();
} else if (args.togglePrimarySubtitleBar) { } else if (args.togglePrimarySubtitleBar) {
@@ -11,9 +11,9 @@ test('Hachidori resolves development and packaged artifacts without falling back
assert.equal( assert.equal(
resolveHachidoriExtensionPath({ resolveHachidoriExtensionPath({
...options, ...options,
exists: (p) => p === '/app/build/hachidori/manifest.json', exists: (p) => p === '/app/dist/hachidori/manifest.json',
}), }),
'/app/build/hachidori', '/app/dist/hachidori',
); );
assert.equal( assert.equal(
resolveHachidoriExtensionPath({ resolveHachidoriExtensionPath({
+1 -1
View File
@@ -15,7 +15,7 @@ export function resolveHachidoriExtensionPath(options: {
exists?: (candidate: string) => boolean; exists?: (candidate: string) => boolean;
}): string { }): string {
const candidates = [ const candidates = [
path.resolve(options.moduleDir, '../../../build/hachidori'), path.resolve(options.moduleDir, '../../../dist/hachidori'),
path.join(options.resourcesPath, 'hachidori'), path.join(options.resourcesPath, 'hachidori'),
]; ];
const found = candidates.find((candidate) => const found = candidates.find((candidate) =>
+5 -3
View File
@@ -575,8 +575,8 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
const openedModals: unknown[] = []; const openedModals: unknown[] = [];
registerIpcHandlers( registerIpcHandlers(
{ {
onOverlayModalClosed: (modal) => { onOverlayModalClosed: (modal, _senderWindow, instanceId) => {
closedModals.push(modal); closedModals.push(instanceId === undefined ? modal : [modal, instanceId]);
}, },
onOverlayModalOpened: (modal) => { onOverlayModalOpened: (modal) => {
openedModals.push(modal); openedModals.push(modal);
@@ -637,7 +637,9 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku');
assert.deepEqual(closedModals, ['subsync', 'kiku']); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'plugin-panel', 4);
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'plugin-panel', 'bad');
assert.deepEqual(closedModals, ['subsync', 'kiku', ['plugin-panel', 4], 'plugin-panel']);
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad'); handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad');
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync'); handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync');
+12 -3
View File
@@ -50,6 +50,7 @@ export interface IpcServiceDeps {
onOverlayModalClosed: ( onOverlayModalClosed: (
modal: OverlayHostedModal, modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null, senderWindow: ElectronBrowserWindow | null,
instanceId?: number,
) => void; ) => void;
onOverlayModalOpened?: ( onOverlayModalOpened?: (
modal: OverlayHostedModal, modal: OverlayHostedModal,
@@ -317,6 +318,7 @@ export interface IpcDepsRuntimeOptions {
onOverlayModalClosed: ( onOverlayModalClosed: (
modal: OverlayHostedModal, modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null, senderWindow: ElectronBrowserWindow | null,
instanceId?: number,
) => void; ) => void;
onOverlayModalOpened?: ( onOverlayModalOpened?: (
modal: OverlayHostedModal, modal: OverlayHostedModal,
@@ -527,13 +529,20 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
}, },
); );
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (event: unknown, modal: unknown) => { ipc.on(
IPC_CHANNELS.command.overlayModalClosed,
(event: unknown, modal: unknown, instanceId: unknown) => {
const parsedModal = parseOverlayHostedModal(modal); const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return; if (!parsedModal) return;
const senderWindow = const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayModalClosed(parsedModal, senderWindow); deps.onOverlayModalClosed(
}); parsedModal,
senderWindow,
Number.isInteger(instanceId) ? (instanceId as number) : undefined,
);
},
);
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => { ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal); const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return; if (!parsedModal) return;
+16
View File
@@ -445,3 +445,19 @@ test('dispatchMpvProtocolMessage emits client-message string args', async () =>
assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]); assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]);
}); });
test('dispatchMpvProtocolMessage emits end-file with reason, entry id, and error', async () => {
const received: unknown[] = [];
const { deps } = createDeps({ emitEndFile: (payload) => received.push(payload) });
await dispatchMpvProtocolMessage(
{ event: 'end-file', reason: 'error', playlist_entry_id: 3, file_error: 'loading failed' },
deps,
);
await dispatchMpvProtocolMessage({ event: 'end-file', reason: 'something-new' }, deps);
assert.deepEqual(received, [
{ reason: 'error', playlistEntryId: 3, error: 'loading failed' },
{ reason: 'unknown', playlistEntryId: null, error: null },
]);
});
+25
View File
@@ -9,8 +9,30 @@ export type MpvMessage = {
args?: unknown; args?: unknown;
request_id?: number; request_id?: number;
error?: string; error?: string;
reason?: unknown;
playlist_entry_id?: unknown;
file_error?: unknown;
}; };
const MPV_END_FILE_REASONS = ['eof', 'stop', 'quit', 'error', 'redirect'] as const;
export type MpvEndFileReason = (typeof MPV_END_FILE_REASONS)[number] | 'unknown';
/** mpv's `end-file` event: why the current file stopped, and mpv's error text when it failed. */
export interface MpvEndFileEvent {
reason: MpvEndFileReason;
playlistEntryId: number | null;
error: string | null;
}
function parseEndFileEvent(msg: MpvMessage): MpvEndFileEvent {
const reason = MPV_END_FILE_REASONS.find((known) => known === msg.reason) ?? 'unknown';
return {
reason,
playlistEntryId: typeof msg.playlist_entry_id === 'number' ? msg.playlist_entry_id : null,
error: typeof msg.file_error === 'string' && msg.file_error ? msg.file_error : null,
};
}
export const MPV_REQUEST_ID_SUBTEXT = 101; export const MPV_REQUEST_ID_SUBTEXT = 101;
export const MPV_REQUEST_ID_PATH = 102; export const MPV_REQUEST_ID_PATH = 102;
export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103; export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103;
@@ -100,6 +122,7 @@ export interface MpvProtocolHandleMessageDeps {
shouldQuitOnMpvShutdown: () => boolean; shouldQuitOnMpvShutdown: () => boolean;
requestAppQuit: () => void; requestAppQuit: () => void;
emitClientMessage?: (payload: { args: string[] }) => void; emitClientMessage?: (payload: { args: string[] }) => void;
emitEndFile?: (payload: MpvEndFileEvent) => void;
} }
type SubtitleTrackCandidate = { type SubtitleTrackCandidate = {
@@ -419,6 +442,8 @@ export async function dispatchMpvProtocolMessage(
if (args.length > 0) { if (args.length > 0) {
deps.emitClientMessage?.({ args }); deps.emitClientMessage?.({ args });
} }
} else if (msg.event === 'end-file') {
deps.emitEndFile?.(parseEndFileEvent(msg));
} else if (msg.event === 'shutdown') { } else if (msg.event === 'shutdown') {
deps.restorePreviousSecondarySubVisibility(); deps.restorePreviousSecondarySubVisibility();
if (deps.shouldQuitOnMpvShutdown()) { if (deps.shouldQuitOnMpvShutdown()) {
+5
View File
@@ -4,6 +4,7 @@ import {
dispatchMpvProtocolMessage, dispatchMpvProtocolMessage,
MPV_REQUEST_ID_TRACK_LIST_AUDIO, MPV_REQUEST_ID_TRACK_LIST_AUDIO,
MPV_REQUEST_ID_TRACK_LIST_SECONDARY, MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
type MpvEndFileEvent,
MpvMessage, MpvMessage,
MpvProtocolHandleMessageDeps, MpvProtocolHandleMessageDeps,
splitMpvMessagesFromBuffer, splitMpvMessagesFromBuffer,
@@ -138,6 +139,7 @@ export interface MpvIpcClientEventMap {
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> }; 'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
'secondary-subtitle-visibility': { visible: boolean }; 'secondary-subtitle-visibility': { visible: boolean };
'client-message': { args: string[] }; 'client-message': { args: string[] };
'end-file': MpvEndFileEvent;
} }
type MpvIpcClientEventName = keyof MpvIpcClientEventMap; type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
@@ -519,6 +521,9 @@ export class MpvIpcClient implements MpvClient {
emitClientMessage: (payload) => { emitClientMessage: (payload) => {
this.emit('client-message', payload); this.emit('client-message', payload);
}, },
emitEndFile: (payload) => {
this.emit('end-file', payload);
},
}; };
} }
+137
View File
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { setTimeout as sleep } from 'node:timers/promises';
import { execPluginCommand, resolvePluginSpawnTarget } from './exec';
// The test runtime (bun or node) doubles as a portable child process.
const runtime = process.execPath;
test('pipes stdin to the child and collects stdout', async () => {
const result = await execPluginCommand(
runtime,
['-e', 'process.stdin.on("data", (d) => process.stdout.write(String(d).toUpperCase()))'],
{ stdin: '字幕 line' },
);
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, '字幕 LINE');
});
test('resolves with a non-zero exit code and stderr', async () => {
const result = await execPluginCommand(runtime, [
'-e',
'process.stderr.write("bad things"); process.exit(3)',
]);
assert.equal(result.exitCode, 3);
assert.equal(result.stderr, 'bad things');
});
test('drops the AppImage library path from the child environment', async () => {
const previous = process.env.LD_LIBRARY_PATH;
process.env.LD_LIBRARY_PATH = '/tmp/.mount_SubMinXXXXXX/usr/lib';
try {
const result = await execPluginCommand(
runtime,
['-e', 'process.stdout.write(String(process.env.LD_LIBRARY_PATH) + "|" + process.env.EXTRA)'],
{ env: { EXTRA: 'kept' } },
);
assert.equal(result.stdout, 'undefined|kept');
} finally {
if (previous === undefined) delete process.env.LD_LIBRARY_PATH;
else process.env.LD_LIBRARY_PATH = previous;
}
});
test('aborting kills the child and rejects with AbortError', async () => {
const controller = new AbortController();
const pending = execPluginCommand(runtime, ['-e', 'setTimeout(() => {}, 60_000)'], {
signal: controller.signal,
});
setTimeout(() => controller.abort(), 50);
await assert.rejects(pending, { name: 'AbortError' });
});
test('force-kills a child that ignores SIGTERM after the grace period', async (t) => {
if (process.platform === 'win32') return t.skip('POSIX signals only');
const pidFile = path.join(await mkdtemp(path.join(os.tmpdir(), 'plugin-exec-')), 'pid');
const controller = new AbortController();
const pending = execPluginCommand(
runtime,
[
'-e',
`process.on("SIGTERM", () => {}); require("node:fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); setInterval(() => {}, 1000);`,
],
{ signal: controller.signal },
100,
);
let pid = 0;
for (let i = 0; i < 100 && !pid; i += 1) {
pid = Number(await readFile(pidFile, 'utf8').catch(() => '0'));
if (!pid) await sleep(20);
}
assert.ok(pid > 0, 'child never reported its pid');
try {
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
let alive = true;
for (let i = 0; i < 100 && alive; i += 1) {
await sleep(20);
alive = isRunning(pid);
}
assert.equal(alive, false, 'child survived SIGTERM');
} finally {
if (isRunning(pid)) process.kill(pid, 'SIGKILL');
await rm(path.dirname(pidFile), { recursive: true, force: true });
}
});
function isRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
test('rejects when the binary does not exist', async () => {
await assert.rejects(
execPluginCommand('subminer-definitely-missing-binary', []),
(error: NodeJS.ErrnoException) => error.code === 'ENOENT',
);
});
test('Windows batch shims run through cmd.exe; other commands spawn directly', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'plugin-exec-shim-'));
try {
await writeFile(path.join(dir, 'codex.cmd'), '');
await writeFile(path.join(dir, 'native.exe'), '');
const env = { PATH: dir, PATHEXT: '.exe;.cmd', ComSpec: 'cmd.exe' };
const shim = resolvePluginSpawnTarget('codex', ['exec', '-'], env, 'win32');
assert.equal(shim.viaShell, true);
assert.equal(shim.file, 'cmd.exe');
assert.deepEqual(shim.args.slice(0, 4), ['/d', '/s', '/v:off', '/c']);
assert.ok(shim.args[4]!.includes(path.join(dir, 'codex.cmd')));
assert.deepEqual(resolvePluginSpawnTarget('native', ['-v'], env, 'win32'), {
file: path.join(dir, 'native.exe'),
args: ['-v'],
viaShell: false,
});
assert.deepEqual(resolvePluginSpawnTarget('codex', ['-'], env, 'linux'), {
file: 'codex',
args: ['-'],
viaShell: false,
});
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+134
View File
@@ -0,0 +1,134 @@
import { type ChildProcess, spawn } from 'node:child_process';
import { buildSystemBinaryEnv } from '../../utils/system-binary-env';
import {
buildWindowsShimInvocation,
isWindowsCommandShim,
resolveWindowsCommand,
} from '../../utils/windows-command-shim';
import type { PluginExecOptions, PluginExecResult } from './types';
const MAX_CAPTURED_OUTPUT_CHARS = 4 * 1024 * 1024;
const DEFAULT_KILL_GRACE_MS = 3_000;
function appendCapped(current: string, chunk: string): string {
if (current.length >= MAX_CAPTURED_OUTPUT_CHARS) return current;
return (current + chunk).slice(0, MAX_CAPTURED_OUTPUT_CHARS);
}
interface SpawnTarget {
file: string;
args: string[];
viaShell: boolean;
}
/**
* Windows batch shims (npm installs `codex` as `codex.cmd`) only run through cmd.exe. Native
* binaries, and every command on Linux and macOS, are spawned directly.
*/
export function resolvePluginSpawnTarget(
command: string,
args: string[],
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform = process.platform,
): SpawnTarget {
if (platform !== 'win32') return { file: command, args, viaShell: false };
const resolved = resolveWindowsCommand(command, env);
if (!isWindowsCommandShim(resolved)) return { file: resolved, args, viaShell: false };
return { ...buildWindowsShimInvocation(resolved, args, env), viaShell: true };
}
// Killing cmd.exe alone would leave the shim's own child running, so end the whole tree.
function killWindowsProcessTree(child: ChildProcess): void {
if (child.pid === undefined) return;
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true,
}).on('error', () => child.kill());
}
/**
* Runs `command` (no shell, except cmd.exe for Windows batch shims) and collects its output. Resolves with the exit code on any exit,
* including non-zero; rejects when the binary cannot start, the timeout passes, or `signal` aborts.
* A timed-out or aborted child gets SIGTERM, then SIGKILL after `killGraceMs` (a shim's
* process tree is ended with taskkill instead).
*/
export function execPluginCommand(
command: string,
args: string[],
options: PluginExecOptions = {},
killGraceMs = DEFAULT_KILL_GRACE_MS,
): Promise<PluginExecResult> {
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(new DOMException('Command aborted.', 'AbortError'));
return;
}
const env = { ...buildSystemBinaryEnv(), ...options.env };
const target = resolvePluginSpawnTarget(command, args, env);
const child = spawn(target.file, target.args, {
cwd: options.cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
windowsVerbatimArguments: target.viaShell,
});
let stdout = '';
let stderr = '';
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const settle = (finish: () => void): void => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
options.signal?.removeEventListener('abort', onAbort);
finish();
};
// Rejects right away, then escalates to SIGKILL if the child ignores SIGTERM.
const killWith = (error: Error): void => {
settle(() => reject(error));
if (target.viaShell) {
killWindowsProcessTree(child);
return;
}
child.kill('SIGTERM');
const forceKill = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
}, killGraceMs);
forceKill.unref();
child.once('exit', () => clearTimeout(forceKill));
};
function onAbort(): void {
killWith(new DOMException('Command aborted.', 'AbortError'));
}
options.signal?.addEventListener('abort', onAbort, { once: true });
if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
timeout = setTimeout(
() => killWith(new Error(`${command} timed out after ${options.timeoutMs} ms.`)),
options.timeoutMs,
);
}
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
stdout = appendCapped(stdout, chunk);
});
child.stderr.on('data', (chunk: string) => {
stderr = appendCapped(stderr, chunk);
});
// A child that exits without reading stdin raises EPIPE here; the exit code tells the story.
child.stdin.on('error', () => {});
child.on('error', (error) => settle(() => reject(error)));
child.on('close', (exitCode) => settle(() => resolve({ exitCode, stdout, stderr })));
child.stdin.end(options.stdin ?? '');
});
}
+47
View File
@@ -0,0 +1,47 @@
import path from 'node:path';
import type { PluginManifest } from './types';
const PLUGIN_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
/** Plugin ids double as directory names and action prefixes, and action names follow the same rule. */
export function isValidPluginName(id: string): boolean {
return PLUGIN_NAME_PATTERN.test(id);
}
/** Validates parsed `plugin.json` content. Returns an error message instead of throwing. */
export function parsePluginManifest(
raw: unknown,
fallbackName: string,
): { manifest: PluginManifest } | { error: string } {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
return { error: 'plugin.json must contain a JSON object.' };
}
const value = raw as Record<string, unknown>;
if (typeof value.main !== 'string' || value.main.trim().length === 0) {
return { error: 'plugin.json needs a "main" entry file.' };
}
if (value.name !== undefined && typeof value.name !== 'string') {
return { error: 'plugin.json "name" must be a string.' };
}
if (value.version !== undefined && typeof value.version !== 'string') {
return { error: 'plugin.json "version" must be a string.' };
}
return {
manifest: {
name: typeof value.name === 'string' && value.name.trim() ? value.name.trim() : fallbackName,
version: typeof value.version === 'string' ? value.version : null,
main: value.main.trim(),
},
};
}
/** Resolves a plugin file (entry, window HTML, preload), rejecting paths that leave the plugin directory. */
export function resolvePluginFilePath(pluginDir: string, file: string): string | null {
const resolved = path.resolve(pluginDir, file);
const relative = path.relative(pluginDir, resolved);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
return null;
}
return resolved;
}
@@ -0,0 +1,468 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import type { BrowserWindow } from 'electron';
import type { SessionActionDispatchRequest } from '../../../types/runtime';
import { createPluginHost, type PluginHostDeps } from './plugin-host';
import type {
PluginConfigEntry,
PluginLogger,
PluginMpvEventName,
PluginWindowRequest,
} from './types';
function writePlugin(root: string, id: string, files: Record<string, string>): void {
const dir = path.join(root, id);
mkdirSync(dir, { recursive: true });
for (const [name, content] of Object.entries(files)) {
writeFileSync(path.join(dir, name), content);
}
}
type FakeWindow = ReturnType<typeof createFakeWindow>;
/** The slice of BrowserWindow the host touches: destroy, isDestroyed, and the `closed` event. */
function createFakeWindow() {
const events = new EventEmitter();
const fake = {
destroyed: false,
isDestroyed: () => fake.destroyed,
destroy: () => {
fake.destroyed = true;
events.emit('closed');
},
once: (event: string, listener: () => void) => events.once(event, listener),
asWindow: () => fake as unknown as BrowserWindow,
};
return fake;
}
function createHarness(
config: Record<string, PluginConfigEntry>,
overrides: Partial<PluginHostDeps<{ marker: string }>> = {},
) {
const root = mkdtempSync(path.join(os.tmpdir(), 'subminer-plugins-'));
const errors: string[] = [];
const notifications: string[] = [];
const sessionActions: SessionActionDispatchRequest[] = [];
const windowRequests: PluginWindowRequest[] = [];
const windows = new Map<string, FakeWindow>();
const logger: PluginLogger = {
debug: () => {},
info: () => {},
warn: () => {},
error: (message) => errors.push(message),
};
const deps: PluginHostDeps<{ marker: string }> = {
pluginsDir: path.join(root, 'plugins'),
pluginDataDir: path.join(root, 'data'),
getPluginConfig: () => config,
createLogger: () => logger,
isMpvConnected: () => true,
sendMpvCommand: () => {},
requestMpvProperty: async () => null,
ensureMpvRunning: async () => true,
invokeAnkiConnect: async () => null,
dispatchSessionAction: async (request) => {
sessionActions.push(request);
},
getCurrentSubtitle: () => null,
getSubtitleContext: () => ({ current: null, before: [], after: [] }),
notify: (message) => notifications.push(message),
openPanel: () => {
throw new Error('not used');
},
openWindow: (request) => {
windowRequests.push(request);
const existing = windows.get(request.key);
if (existing && !existing.destroyed) return { window: existing.asWindow(), created: false };
const window = createFakeWindow();
windows.set(request.key, window);
return { window: window.asWindow(), created: true };
},
unstable: { marker: 'internals' },
...overrides,
};
return {
root: deps.pluginsDir,
dataRoot: deps.pluginDataDir,
errors,
notifications,
sessionActions,
windowRequests,
windows,
host: createPluginHost(deps),
cleanup: () => rmSync(root, { recursive: true, force: true }),
};
}
const enabled = (settings: Record<string, unknown> = {}): PluginConfigEntry => ({
enabled: true,
settings,
});
function createFakeMpvClient() {
const bus = new EventEmitter();
return {
on: (event: PluginMpvEventName, listener: (payload: unknown) => void) =>
bus.on(event, listener),
off: (event: PluginMpvEventName, listener: (payload: unknown) => void) =>
bus.off(event, listener),
emit: (event: PluginMpvEventName, payload: unknown) => bus.emit(event, payload),
};
}
test('loads enabled plugins and runs their registered actions', async () => {
const h = createHarness({
greeter: enabled({ greeting: 'hi' }),
off: { enabled: false, settings: {} },
});
try {
writePlugin(h.root, 'greeter', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.actions.register('say', () => ctx.notify(ctx.settings.greeting + ' ' + ctx.unstable.marker));
};`,
});
writePlugin(h.root, 'off', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `throw new Error('disabled plugins must not load');`,
});
await h.host.loadAll();
assert.equal(await h.host.runAction('greeter/say'), true);
assert.deepEqual(h.notifications, ['hi internals']);
assert.equal(await h.host.runAction('greeter/missing'), false);
assert.deepEqual(h.errors, []);
} finally {
h.cleanup();
}
});
test('loads ES module plugins', async () => {
const h = createHarness({ esm: enabled() });
try {
writePlugin(h.root, 'esm', {
'plugin.json': JSON.stringify({ main: 'main.mjs' }),
'main.mjs': `export function activate(ctx) { ctx.actions.register('go', () => ctx.notify('esm')); }`,
});
await h.host.loadAll();
assert.equal(await h.host.runAction('esm/go'), true);
assert.deepEqual(h.notifications, ['esm']);
} finally {
h.cleanup();
}
});
test('a failed activation rolls back its registrations without affecting other plugins', async () => {
const h = createHarness({ broken: enabled(), healthy: enabled() });
try {
writePlugin(h.root, 'broken', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.actions.register('half', () => {});
throw new Error('boom');
};`,
});
writePlugin(h.root, 'healthy', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => { ctx.actions.register('ok', () => {}); };`,
});
await h.host.loadAll();
assert.equal(await h.host.runAction('broken/half'), false);
assert.equal(await h.host.runAction('healthy/ok'), true);
assert.deepEqual(h.errors, ['Activation failed: boom']);
} finally {
h.cleanup();
}
});
test('rejects entry files outside the plugin directory', async () => {
const h = createHarness({ escape: enabled() });
try {
writePlugin(h.root, 'escape', {
'plugin.json': JSON.stringify({ main: '../elsewhere.cjs' }),
});
await h.host.loadAll();
assert.equal(h.errors.length, 1);
assert.match(h.errors[0]!, /must stay inside/);
} finally {
h.cleanup();
}
});
test('mpv listener errors are contained and unload detaches listeners and calls deactivate', async () => {
const h = createHarness({ watcher: enabled() });
const client = createFakeMpvClient();
try {
writePlugin(h.root, 'watcher', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.mpv.on('pause-change', ({ paused }) => {
ctx.notify('paused=' + paused);
throw new Error('listener bug');
});
return () => ctx.notify('deactivated');
};`,
});
h.host.attachMpvClient(client);
await h.host.loadAll();
client.emit('pause-change', { paused: true });
assert.deepEqual(h.notifications, ['paused=true']);
assert.deepEqual(h.errors, ['mpv pause-change listener failed: listener bug']);
await h.host.unloadAll();
client.emit('pause-change', { paused: false });
assert.deepEqual(h.notifications, ['paused=true', 'deactivated']);
} finally {
h.cleanup();
}
});
test('unload gives up on a deactivate that never settles and still disposes the plugin', async () => {
const h = createHarness({ stuck: enabled() }, { deactivateTimeoutMs: 20 });
const client = createFakeMpvClient();
try {
writePlugin(h.root, 'stuck', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.mpv.on('pause-change', () => ctx.notify('still listening'));
return () => new Promise(() => {});
};`,
});
h.host.attachMpvClient(client);
await h.host.loadAll();
await h.host.unloadAll();
client.emit('pause-change', { paused: true });
assert.deepEqual(h.notifications, []);
assert.deepEqual(h.errors, ['deactivate failed: timed out after 20 ms']);
} finally {
h.cleanup();
}
});
test('events follow a replacement mpv client', async () => {
const h = createHarness({ watcher: enabled() });
const first = createFakeMpvClient();
const second = createFakeMpvClient();
try {
writePlugin(h.root, 'watcher', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.mpv.on('media-path-change', ({ path }) => ctx.notify(path));
};`,
});
h.host.attachMpvClient(first);
await h.host.loadAll();
h.host.attachMpvClient(second);
first.emit('media-path-change', { path: 'stale.mkv' });
second.emit('media-path-change', { path: 'current.mkv' });
assert.deepEqual(h.notifications, ['current.mkv']);
} finally {
h.cleanup();
}
});
test('unload waits for an in-progress load so late plugins are unloaded too', async () => {
const h = createHarness({ late: enabled() });
const client = createFakeMpvClient();
try {
writePlugin(h.root, 'late', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.mpv.on('pause-change', () => ctx.notify('still listening'));
return () => ctx.notify('deactivated');
};`,
});
h.host.attachMpvClient(client);
// The plugin is still reading its manifest when unload starts.
void h.host.loadAll();
await h.host.unloadAll();
client.emit('pause-change', { paused: true });
assert.deepEqual(h.notifications, ['deactivated']);
} finally {
h.cleanup();
}
});
test('a plugin that finishes activating after unload gave up unloads itself', async () => {
const h = createHarness({ slow: enabled() }, { deactivateTimeoutMs: 20 });
const client = createFakeMpvClient();
try {
writePlugin(h.root, 'slow', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = async (ctx) => {
await new Promise((resolve) => setTimeout(resolve, 60));
ctx.mpv.on('pause-change', () => ctx.notify('still listening'));
return () => ctx.notify('deactivated');
};`,
});
h.host.attachMpvClient(client);
const loading = h.host.loadAll();
await h.host.unloadAll();
await loading;
client.emit('pause-change', { paused: true });
assert.deepEqual(h.notifications, ['deactivated']);
} finally {
h.cleanup();
}
});
test('runAction waits for a load that is still in progress', async () => {
const h = createHarness({ slow: enabled() });
try {
writePlugin(h.root, 'slow', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = async (ctx) => {
await new Promise((resolve) => setTimeout(resolve, 20));
ctx.actions.register('go', () => ctx.notify('went'));
};`,
});
void h.host.loadAll();
assert.equal(await h.host.runAction('slow/go'), true);
assert.deepEqual(h.notifications, ['went']);
} finally {
h.cleanup();
}
});
test('each plugin gets its own data folder, created before activate', async () => {
const h = createHarness({ keeper: enabled() });
try {
writePlugin(h.root, 'keeper', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `const fs = require('node:fs');
exports.activate = (ctx) => {
fs.writeFileSync(require('node:path').join(ctx.dataDir, 'state.json'), '{}');
ctx.notify(ctx.dataDir);
};`,
});
await h.host.loadAll();
const expected = path.join(h.dataRoot, 'keeper');
assert.deepEqual(h.notifications, [expected]);
assert.equal(existsSync(path.join(expected, 'state.json')), true);
assert.deepEqual(h.errors, []);
} finally {
h.cleanup();
}
});
test('anki card-mined reaches listeners until unload, and invoke forwards to AnkiConnect', async () => {
const invoked: unknown[][] = [];
const h = createHarness(
{ syncer: enabled() },
{
invokeAnkiConnect: async (...args) => {
invoked.push(args);
return 'synced';
},
},
);
try {
writePlugin(h.root, 'syncer', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.anki.on('card-mined', async ({ noteIds }) => {
ctx.notify(noteIds.join(',') + ' ' + (await ctx.anki.invoke('sync', undefined, { timeoutMs: 5 })));
});
};`,
});
await h.host.loadAll();
h.host.emitAnkiEvent('card-mined', { noteIds: [1, 2] });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(h.notifications, ['1,2 synced']);
assert.deepEqual(invoked, [['sync', {}, { timeoutMs: 5 }]]);
await h.host.unloadAll();
h.host.emitAnkiEvent('card-mined', { noteIds: [3] });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(h.notifications.length, 1);
} finally {
h.cleanup();
}
});
test('actions.run forwards built-in session actions', async () => {
const h = createHarness({ caller: enabled() });
try {
writePlugin(h.root, 'caller', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = async (ctx) => {
await ctx.actions.run('openJimaku');
await ctx.actions.run('cycleRuntimeOption', { runtimeOptionId: 'x', direction: -1 });
};`,
});
await h.host.loadAll();
assert.deepEqual(h.sessionActions, [
{ actionId: 'openJimaku' },
{ actionId: 'cycleRuntimeOption', payload: { runtimeOptionId: 'x', direction: -1 } },
]);
} finally {
h.cleanup();
}
});
test('openWindow resolves plugin files, reuses open windows, and closes them on unload', async () => {
const h = createHarness({ viewer: enabled() });
try {
writePlugin(h.root, 'viewer', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.actions.register('open', () => ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', width: 400 }));
ctx.actions.register('escape', () => ctx.ui.openWindow({ html: '../other/index.html' }));
};`,
});
await h.host.loadAll();
await h.host.runAction('viewer/open');
await h.host.runAction('viewer/open');
const dir = path.join(h.root, 'viewer');
assert.deepEqual(h.windowRequests[0], {
key: `viewer:${path.join(dir, 'ui', 'index.html')}`,
htmlPath: path.join(dir, 'ui', 'index.html'),
preloadPath: path.join(dir, 'preload.js'),
title: 'viewer',
width: 400,
height: 720,
});
assert.equal(h.windows.size, 1);
await h.host.runAction('viewer/escape');
assert.equal(h.windowRequests.length, 2);
assert.match(h.errors[0] ?? '', /must stay inside/);
const [window] = [...h.windows.values()];
await h.host.unloadAll();
assert.equal(window?.destroyed, true);
} finally {
h.cleanup();
}
});
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff. Show more