Compare commits

...
67 Commits
Author SHA1 Message Date
sudacode 0360270e81 chore(release): v0.22.0 2026-11-06 00:07:01 -08:00
sudacode a8e929eb26 refactor(controller): remove unused rightStickHorizontal binding
- Drop the Alt Horizontal row from Alt+C; no action read this binding
- Remove the key from config defaults, resolver, IPC validator, and types
- Regenerate config.example.jsonc and remove the inactive-binding exemption
2026-11-05 23:33:31 -08:00
sudacode b31ddce627 feat(controller): add hold + press combos to main bindings (#328)
* feat(controller): add hold + press combos to main bindings

Main Alt+C actions can now be learned as combos such as L1 + D-pad right.
A combo fires on each press while its hold button is down, and claims its
press input so token move and popup scroll ignore it until it is released.
A plain action on a hold button fires on release, unless another bound
button was pressed during the hold.

The save path needed two changes for combos to persist. The IPC validator
rebuilt bindings field by field and dropped `hold`, so Alt+C showed a combo
from its local copy until the next reload turned it back into a plain
button. Validator and config resolver now share one parser. Saves also
replace the controller section, since the deep merge in patchRawConfig kept
the old `hold` when a combo was rebound to one button.

Also fixes Learn on the (D-pad) rows, which threw after cancel() cleared
the capture target.

* test(launcher): wait for content in background child test

The test polled for the fake app's output files to exist, but the shell
creates a redirect target before writing to it. A poll landing in that gap
read an empty env file and failed with '' !== '1', as seen once in CI.
Wait until both files have content instead.

* fix(controller): keep a diagonal push from learning as a combo

Learn took the first axis of a diagonal stick push as the hold and the
second as the press, saving a combo that one axis could never trigger. This
reached main Alt+C rows when they moved to combo capture. Axes crossing the
threshold in the same poll as the first input now count as one movement. A
second axis moved in a later poll still records a combo.

* fix(controller): only treat simultaneous axes as one movement

The diagonal push guard also blocked an axis activated in the same poll as a
button, so L1 plus a stick direction could not record the stick until it was
released and moved again. Apply the guard only when the first input is an
axis, so that input pair records as a combo again.
2026-11-05 23:23:54 -08:00
sudacode 79e5535823 fix(controller): show dialog hints only while a controller is connected
- Add createControllerHintVisibility: use the dialog window's pad, else ask main which controller the overlay is reading, at most once per second while a dialog is open
- Hide shortcut tags when controller support is on but no controller is present
- Update the Anki integration docs and changelog fragments to match
2026-11-05 00:54:25 -08:00
sudacode 5140b5e9be test(overlay): pin an X11 session for Linux assembly tests
Fullscreen recreation only runs outside Hyprland and Sway, and the tests
inherited the running session's compositor variables, so they failed when run
from a Hyprland desktop.
2026-11-04 23:31:11 -08:00
sudacode c2dac537da fix(overlay): keep popup pause working with Hachidori 0.4.0
Hachidori 0.4.0 closed its popup shadow root, so isHachidoriPopupOpen always
returned false. The popup never took over the subtitle hover pause, and moving
off the subtitle resumed playback with the popup open. Popup key and controller
routing read the same check. The sidebar kept working because its hold follows
the attention event instead of the pane.

The fork now publishes the pane state on the host element, and the renderer
reads that attribute instead of upstream's shadow root and CSS class. The unit
stubs modelled an open root and hid this, so test:dictionary:electron now drives
the real reader and asserts the attribute follows the pane.
2026-11-04 22:46:25 -08:00
sudacode bc7e82fee6 build(deps): remove @discordjs/rest undici patch
- Drop the patchedDependencies entry from package.json and bun.lock
- Delete patches/@discordjs%2Frest@2.6.1.patch, which pinned undici to 6.27.0
2026-11-04 22:05:32 -08:00
sudacode c3e672efdb refactor(plugins): remove auto-jimaku example plugin and fix docs
- Remove the auto-jimaku example, now covered by the built-in Jimaku auto-open
- Drop its plugins docs section and changelog bullet
- List the keyboard-only `C` key in Keyboard shortcuts
- Point the configuration reference to Controller support for button defaults
2026-11-04 22:02:05 -08:00
sudacode 269c0708cb feat(stats): overhaul the Trends tab (#325) 2026-11-04 21:55:18 -08:00
sudacode f89f78b861 refactor(main): move plugins, session actions and subtitle generation into assemblies (#327)
* refactor(main): build overlay geometry and pointer interaction in the overlay assembly

Inject Electron's screen (and the stats-window promotion) into the overlay geometry and
visible-overlay interaction runtimes so they load under bun test, then construct both in
createOverlayAssembly together with the overlay visibility composer and the mpv subtitle
suppression handlers. Replace the three regex tests that pinned the moved Linux input and
shape code with behaviour tests on the runtimes.

* refactor(main): move the autoplay-ready gate into the playback assembly

The autoplay-ready gate, its post-warm subtitle prime, the current-media tokenization gate and
managed local subtitle selection all read playback state, and playback signals through the
gate, so createPlaybackAssembly now builds them. The overlay reaches the gate through a getter.

Replace the last four regex guards with behaviour tests: the gate holds the release until the
visible overlay has measured the line and ignores untokenized lines during warmup (playback),
cached autoplay priming never paints raw first and the sidebar media path is tagged only after
prefetch loads (priming runtime), and dashboard mining overrides the Hachidori Anki server only
when the proxy is on (stats server runtime). main-wiring.test.ts is now empty and removed.

* refactor(main): move the plugin host, panel and windows into a plugins assembly

* refactor(main): move session action dispatch and renderer mpv commands into an assembly

createSessionActionsAssembly owns the session action dispatcher, the secondary-subtitle mode cycle, queueing the clipboard's video, and the mpv commands the overlay renderer sends. The runtime option cycle both paths used is now one function.

* refactor(main): move Whisper subtitle generation into an assembly

* refactor(main): resolve subtitle annotation options in a runtime module

The runtime-option-over-config resolution that the texthooker page and the MeCab startup decision share moves out of main.ts into createAnnotationOptions.

* test(main): cover a non-boolean runtime annotation option value

* docs: describe the plugins, session actions and subtitle generation assemblies

Update ownership for overlay geometry and pointer interaction, the autoplay-ready gate, and annotation options; point the overlay priming entry points at their new modules; the main-wiring regex debt is gone.

* build: export MpvClientEvents so declaration emit can name the plugins assembly's return type
2026-11-04 21:54:49 -08:00
YashaNoosandClaude Opus 5.5 14ef7e823c Controller: character navigation in keyboard-only mode, plain Xbox face names (#326)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-04 21:54:00 -08:00
sudacode a8bc585f75 chore(hachidori): update bundled Hachidori to upstream 0.4.0
- Show every pitch accent in each popup entry, drawn on the headword's reading
- Split headword furigana per kanji and add Yomitan-style definition tags
2026-11-04 21:51:06 -08:00
sudacode 5f635f3042 fix(e2e): disable mpv direct rendering on macOS to avoid loadfile hang
- Pass `--vd-lavc-dr=no` alongside `--vo=libmpv` on darwin
- cocoa-cb answers DR image requests only from its main queue, which can stall on CI runners and freeze mpv's core and IPC with no timeout
2026-11-03 23:49:06 -08:00
sudacode 7c637149dd fix(controller): keep popup pause and press handling reliable (#324) 2026-11-03 23:13:46 -08:00
33f5f890d5 feat(jimaku): auto-open modal when a video has no Japanese subtitle (#319)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Ivan Melck <ivan@spec-kitty.ai>
2026-11-03 23:12:46 -08:00
sudacode d8c2315405 perf(stats): speed up dashboard queries on large databases (#322) 2026-11-03 22:04:01 -08:00
sudacode 8976d78390 feat(controller): route status messages through notifications
- Send controller connected and disabled messages over a new IPC channel so they follow ankiConnect.behavior.notificationType
- Remove the separate controller status toast from the overlay
- Only announce connected controllers when controller.enabled is on
- Announce a controller again after it reconnects, and update usage docs
2026-11-03 21:55:55 -08:00
sudacode 1c50d249e0 fix(mpv): reset per-file state when a same-file reload skips the path clear (#323)
mpv only sends the latest value of an observed property, and only after its
IPC thread has handled queued commands and events. A same-file
`loadfile replace` can therefore arrive with no `path=null`, so path
comparisons see no change, the autoplay gate stays released for that path and
drops every ready signal, and playback stays paused.

Track an `end-file` that mpv has not followed with a different path. If it is
still pending at `file-loaded`, replay the clear and read the path back, so
consumers see the same sequence as any other load. `file-loaded` waits for the
reply so plugins can read their media snapshot during the event.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(architecture): describe per-domain assemblies

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

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

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

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

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

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

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

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

* refactor(main): move dictionary runtimes into assemblies

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(anilist): drop main-deps builders

* refactor(jellyfin): drop main-deps builders

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(e2e): name mpv-stdio.log in the mpv close diagnostic
2026-10-31 00:50:33 -07:00
sudacode 0129e03c0f fix(anki): default fields.audio to SentenceAudio
- Generated sentence audio no longer overwrites Yomitan's ExpressionAudio on default configs
- fields.wordAudio still defaults to ExpressionAudio
- Regenerate config.example.jsonc and update the Anki and configuration docs
- Add changelog fragment
2026-10-28 22:47:54 -07:00
sudacode 586d7950cf fix(appimage): check mount exe links one at a time for uutils
- uutils readlink (Ubuntu 26.04+) aborts at the first unreadable /proc/<pid>/exe, so the keepalive released the mount early
- Add mount_busy helper that readlinks each link individually
- Add changelog fragment
2026-10-28 19:54:04 -07:00
sudacode 33dceabc3e test(appimage): keep straggler named sleep for multicall coreutils
- Name the copied straggler binary `sleep` so uutils/busybox multicall builds dispatch on argv[0] instead of exiting immediately
- Stop waiting on the straggler exec if it dies early, so the drain assertion reports the failure instead of hanging
2026-10-28 19:04:52 -07:00
sudacode ad1abc3268 test(appimage): wait for straggler exec before AppRun exits
- Fixes a flaky keepalive test where, under load, the supervisor could scan /proc while the forked child was still /bin/sh and release the mount too early
- AppRun now polls /proc/$!/exe until it resolves to the straggler binary
2026-10-28 18:27:40 -07:00
e59b78dca0 fix(anilist): add missing list entry on confident match (#305)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Ivan Melck <ivan@spec-kitty.ai>
2026-10-28 17:20:03 -07:00
sudacode 203a61a20f chore(release): v0.21.1 2026-10-26 23:27:24 -07:00
sudacode a681bbaf25 test(launcher): gate Linux AppImage env test with early return
Bun's node:test shim ignores the skip option, so the Linux-only test ran
and failed on macOS.
2026-10-26 23:27:24 -07:00
sudacode 6219a81144 feat(stats): redesign expanded session view with density chart (#304) 2026-10-26 23:08:48 -07:00
sudacode cc9af79d6e fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation (#302) 2026-10-26 22:19:08 -07:00
sudacode d3a94536a8 docs: link Hachidori project page from README and setup docs 2026-10-25 21:42:36 -07:00
sudacode 3068d02f89 docs(readme): refresh anime screenshots from current build
- Replace annotations, one-key-mining, and yomitan-lookup screenshots
- Update README alt text to match the new images
- Add docs changelog fragment
2026-10-25 19:32:25 -07:00
sudacode 9150a60630 fix(overlay): forward mpv input while subtitle sidebar is open (#300) 2026-10-24 23:28:58 -07:00
sudacode 3549e0ed93 chore(build): consolidate packaging inputs and build output layout (#299) 2026-10-24 23:28:38 -07:00
sudacode b0a8b6b3fc chore(release): prepare v0.21.0 2026-10-24 21:56:02 -07:00
sudacode 153bc5d4bf chore(changes): condense and split pending changelog fragments
- Shorten the Hachidori backend fragment to its user-visible outcomes
- Move the fork submodule note into an internal fragment
- Split stats dashboard history-line mining into its own fixed fragment
- Reclassify the setup launcher label fragment as internal
- Drop the Electron/undici security fragment
2026-10-24 21:15:33 -07:00
sudacode cbaa4626e1 feat(dictionary): add Hachidori backend support (#266) 2026-10-24 21:08:18 -07:00
sudacode e690ffba07 fix(deps): bump electron to 43.8.3 and undici to 7.30.1
- Pick up upstream security fixes
- Update undici override to match
- Add security changelog fragment
2026-10-24 18:35:16 -07:00
sudacode d047e3e0a4 fix(jellyfin): find mpv when SubMiner is launched from Finder (#298) 2026-10-24 18:27:31 -07:00
sudacode 01613f1be5 fix(subtitles): widen halfwidth katakana where subtitle text enters the app (#295)
* fix(subtitles): widen halfwidth katakana where subtitle text enters the app

Broadcast-derived Japanese subs write katakana halfwidth (アンタ, ダキニ), so
overlay lines mixed squashed and normal-width text. Normalize live mpv
sub-text and parsed cue text to fullwidth so display, lookups, mining,
and stats all see one form. The tokenizer reuses the shared helper.

* fix(subtitles): fold stray voiced marks and widen before cue dedup

- A lone halfwidth ゙ after fullwidth kana (ガ) now composes to ガ
  instead of leaving a combining mark that broke name matching.
- Parsed cue text is widened before duplicate merging so ガク and ガク
  on the same span collapse into one sidebar row.
- The YouTube track-load refresh widens the sub-text it reads directly.

* fix(stats): widen halfwidth katakana in stored lines and mined cards (#296)

Lines recorded before ingest widening still hold halfwidth katakana.
Occurrence and sentence-search queries widen line text on read, and the
mine-card route widens the sentence and word it receives, so the
dashboard and cards mined from history match newer rows. Word identity
columns are left as stored since the dashboard queries by them.

* fix(stats): match katakana in either width in sentence search

- Add narrowFullwidthKana, derived from widenHalfwidthKana so the two stay in sync
- Match subtitle text against the original, widened, and narrowed forms of each term so a search finds older lines stored as halfwidth
2026-10-13 22:26:01 -07:00
sudacode bf4e5bb609 feat(subtitles): generate Japanese subtitles during Jellyfin playback (#294)
* feat(subtitles): generate Japanese subtitles during Jellyfin playback

- Read the item's original file via toJellyfinOriginalStreamUrl and write output to a temp dir
- Transcoded playback uses Jellyfin's audio index and only external references
- Show Jellyfin media by title and redact URL tokens in subprocess errors
- Update overlay copy, docs, and changelog fragment

* fix(subtitles): redact full stream tokens and reuse one Jellyfin output folder

* test(subtitles): make Jellyfin output path assertions platform-neutral

- Compare dirname/basename via node:path instead of POSIX-only regexes
- Assert the full sub-add command uses the generated output path
2026-10-13 21:55:28 -07:00
sudacode 7909fcd2cf refactor(ipc): remove dead preload methods and the ASS line broadcast (#293)
Drop 16 overlay preload methods with no caller (onSubtitleAss,
getCurrentSubtitleAss, onVisibility, getOverlayVisibility,
openYomitanSettings, quitApp, toggleOverlay, getMecabStatus,
setMecabEnabled, getKeybindings, saveControllerPreference,
getAnkiConnectStatus, setAnkiConnectEnabled, clearAnkiConnectHistory,
cycleRuntimeOption, appendClipboardVideoToQueue) along with their
IPC_CHANNELS keys, handlers, validators and dep plumbing.

With both ASS consumers gone, stop observing sub-text/ass in mpv and
remove the subtitle-ass-change event chain and appState.currentSubAssText.
The main-process functions these wrapped stay; tray, CLI, shortcuts and
session actions still use them.
2026-10-12 22:11:17 -07:00
sudacode 8fc20b2499 refactor: remove dead code and enable noUnusedLocals (#292)
* refactor: remove dead code and enable noUnusedLocals

- delete launcher/jimaku.ts and the launcher Args/config jimaku* fields
  that only fed it (the SUBMINER_JIMAKU_* env vars had no effect)
- delete plugin/subminer/hover.lua; nothing has sent subminer-hover-token
  since Overlay 2.0
- drop never-read anilist.characterDictionary.refreshTtlHours and
  evictionPolicy (still hidden in the settings UI for old configs)
- drop unused root deps: eslint, @fontsource-variable/geist{,-mono}
  (stats/ declares its own)
- delete the committed draft renderer-performance plan doc
- turn on noUnusedLocals for typecheck and remove what it surfaced,
  including Windows focus-handoff values computed but never consumed

* docs(changes): add docs fragment and note retired hover messages
2026-10-12 22:10:54 -07:00
sudacode ea2b3a9507 fix(overlay): stop Hyprland/Sway tracker polls from blocking the main thread (#289) 2026-10-11 01:01:36 -07:00
sudacode a6d96e9966 fix(overlay): poll cheap playback state in the open subtitle sidebar (#290) 2026-10-11 00:35:27 -07:00
sudacode 7108e086ba fix(overlay): keep keyboard mode from swallowing modal and text-field keys (#291) 2026-10-11 00:33:47 -07:00
sudacode 78a9b6346f fix(jellyfin): make terminal play work with a running app and report progress (#288) 2026-10-10 17:27:48 -07:00
sudacode 78eca52b7e test: add headless end-to-end harness (#286)
* test: add headless end-to-end harness and CI job

Boot the built app against a real mpv, a synthetic clip, a fixture Yomitan
dictionary, and a fake AnkiConnect inside a private Xvfb display, then assert
on the overlay DOM and the AnkiConnect requests. Adds the test:e2e lane, an
e2e session CLI for exploratory checks, and a three-platform CI job.

* ci: set e2e temp dir from a step

* test: make e2e session startup robust on slow machines

Bun caps node:test hooks at 5s, so boot the session inside the first test.
Keep pausing until the app's resume retries stop, read cue tokens in one
round trip, and resolve Electron through its package entry so CI downloads
the binary.

* test: harden e2e harness for CI runners

Fail fast when mpv cannot be spawned, read every Yomitan popup frame instead
of trusting target order, and put the chocolatey mpv directory on PATH.

* test: add e2e failure diagnostics and mark Windows job experimental

Report what the overlay showed when a cue never renders, dispose the session
when the overlay cannot be opened, skip popup frames that vanish mid-poll,
and turn off AniSkip lookups in the harness config.

* test: read the lookup popup entry from textContent

* test: capture renderer console and main-side subtitle state in e2e failures

* test: expose the Electron main-process inspector to the e2e harness

Adds session.mainProcess() and 'e2e eval main', and reports main's window
list alongside the overlay state when a cue never renders.

* test: tap subtitle sends and receipts in e2e failure diagnostics

* test: probe alternate main-to-renderer paths in e2e failure diagnostics

* test: select the visible overlay layer, not the primed modal window

Windows and macOS pre-create a hidden modal window that loads the same
renderer document, and the harness could attach to it instead of the
visible overlay. Drops the delivery taps that found this.

* ci: run the Windows e2e job as a normal matrix entry

* test: address review on e2e harness

Slice the app log at a byte offset before decoding, create the session state
file owner-only since it carries the inspector URL, and keep the cue failure
when inspector diagnostics are unavailable.

* test: create the e2e session state file exclusively
2026-10-05 22:00:58 -07:00
sudacode b48083ddd8 test: cut low-value tests, rewrite the rest around behavior, parallelize lanes (#285) 2026-10-05 00:07:44 -07:00
sudacode 4b6842d034 fix(setup): label missing launcher "Not set up" instead of "Not installed" (#284) 2026-10-04 17:13:10 -07:00
sudacode 9ffa2bce94 chore(release): prepare v0.20.1 2026-10-04 00:01:18 -07:00
sudacode 88b73656a7 chore(release): prepare v0.20.1-beta.2 2026-10-03 21:22:19 -07:00
sudacode e77aa5c3b3 fix(stats): count mined cards once and keep lexical stats exact after deletes (#280) 2026-10-03 21:16:12 -07:00
sudacode 121b688afb fix(deps): bump http-cache-semantics to 4.3.0 for GHSA-ch52-4w7c-c8xp (#283) 2026-10-03 21:13:32 -07:00
sudacode 727be5683a fix(youtube): keep mpv queue when advancing to a queued video on Windows (#281) 2026-10-02 22:13:35 -07:00
sudacode 138bfe9759 fix(subtitles): allow Whisper generation during Windows YouTube stream playback (#282) 2026-10-02 22:13:15 -07:00
sudacode 0aba17d95b chore(release): generate concise release notes per feature or area
- Rewrite polish prompt to default to inline items, nesting only for several distinct changes
- Add length/density example and drop internal details from release bodies
- Tell prerelease mode to shorten long baseline bullets
- Update changes/README.md and prompt assertions in tests
2026-10-02 19:52:16 -07:00
1010 changed files with 66315 additions and 80775 deletions

No files matched your search

@@ -27,6 +27,7 @@ Do not use hidden wrapper commands. Verification commands are owned by `package.
- Launcher or mpv plugin: `bun run test:launcher` or `bun run test:env`, based on the behavior changed
- Runtime compatibility or dist-sensitive wiring: `bun run test:runtime:compat`
- Stats dashboard: `bun run test:stats`
- Overlay, renderer, mpv, or mining behavior in the running app: `bun run build`, then `bun run test:e2e`
- Build/release scripts: `bun run test:scripts`
For substantial changes, use the full gate documented in `AGENTS.md` and `docs/workflow/verification.md`.
+85
View File
@@ -9,3 +9,88 @@ on:
jobs:
build-test-audit:
uses: ./.github/workflows/quality-gate.yml
# Boots the built app with a real mpv on each platform. Kept outside the
# reusable quality gate so releases do not depend on it while the lane is new.
e2e:
name: E2E (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
permissions:
contents: read
defaults:
run:
shell: bash
env:
SUBMINER_E2E_KEEP: '1'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Point temp files at an uploadable directory
run: |
# os.tmpdir() follows these, so the kept session logs land under runner.temp.
dir="$RUNNER_TEMP/e2e"
mkdir -p "$dir"
{ echo "TMPDIR=$dir"; echo "TEMP=$dir"; echo "TMP=$dir"; } >> "$GITHUB_ENV"
- name: Install mpv and ffmpeg (Linux)
if: runner.os == 'Linux'
run: |
# Only Ubuntu sources are needed; unrelated runner repositories can be unavailable.
apt_sources=(-o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources -o Dir::Etc::sourceparts=-)
sudo apt-get "${apt_sources[@]}" update
sudo apt-get "${apt_sources[@]}" install -y mpv ffmpeg xvfb
- name: Install mpv and ffmpeg (macOS)
if: runner.os == 'macOS'
run: |
# Runner images bake in different Homebrew metadata, so without an update the
# installed mpv build depends on which image the job lands on.
brew update
brew install mpv ffmpeg
mpv --version
- name: Install mpv and ffmpeg (Windows)
if: runner.os == 'Windows'
run: |
choco install mpv ffmpeg -y --no-progress
# The mpv package adds no shim, so put its directory on PATH for later steps.
mpv_exe="$(find /c/ProgramData/chocolatey/lib /c/ProgramData/chocolatey/bin "/c/Program Files" /c/tools -maxdepth 6 -iname mpv.exe 2>/dev/null | head -n 1)"
test -n "$mpv_exe"
cygpath -w "$(dirname "$mpv_exe")" >> "$GITHUB_PATH"
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build
run: bun run build
- name: End-to-end suite
run: bun run test:e2e
- name: Dictionary backend windows (Linux)
if: runner.os == 'Linux'
run: xvfb-run -a bun run test:dictionary:electron
- name: Upload session logs (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-logs-${{ matrix.os }}
path: ${{ runner.temp }}/e2e/subminer-e2e-*/*.log
if-no-files-found: ignore
-49
View File
@@ -1,49 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
+17 -17
View File
@@ -67,26 +67,26 @@ jobs:
- name: Build unversioned AppImage
run: |
shopt -s nullglob
appimages=(release/SubMiner-*.AppImage)
appimages=(dist/release/SubMiner-*.AppImage)
if [ "${#appimages[@]}" -eq 0 ]; then
echo "No versioned AppImage found to create unversioned artifact."
ls -la release
ls -la dist/release
exit 1
fi
cp "${appimages[0]}" release/SubMiner.AppImage
cp "${appimages[0]}" dist/release/SubMiner.AppImage
- name: Smoke packaged runtime assets
shell: bash
run: xvfb-run -a bun run test:package "release/linux-unpacked/resources"
run: xvfb-run -a bun run test:package "dist/release/linux-unpacked/resources"
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
with:
name: appimage
path: |
release/*.AppImage
release/latest*.yml
release/*.blockmap
dist/release/*.AppImage
dist/release/latest*.yml
dist/release/*.blockmap
if-no-files-found: error
build-macos:
@@ -159,17 +159,17 @@ jobs:
- name: Smoke packaged runtime assets
shell: bash
run: bun run test:package "release/mac-arm64/SubMiner.app/Contents/Resources"
run: bun run test:package "dist/release/mac-arm64/SubMiner.app/Contents/Resources"
- name: Upload macOS artifacts
uses: actions/upload-artifact@v4
with:
name: macos
path: |
release/*.dmg
release/*.zip
release/latest*.yml
release/*.blockmap
dist/release/*.dmg
dist/release/*.zip
dist/release/latest*.yml
dist/release/*.blockmap
if-no-files-found: error
build-windows:
@@ -224,15 +224,15 @@ jobs:
- name: Smoke packaged runtime assets
shell: bash
run: bun run test:package "release/win-unpacked/resources"
run: bun run test:package "dist/release/win-unpacked/resources"
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
with:
name: windows
path: |
release/*.exe
release/*.zip
release/latest*.yml
release/*.blockmap
dist/release/*.exe
dist/release/*.zip
dist/release/latest*.yml
dist/release/*.blockmap
if-no-files-found: error
+25 -23
View File
@@ -44,19 +44,19 @@ jobs:
uses: actions/download-artifact@v4
with:
name: appimage
path: release
path: dist/release
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: macos
path: release
path: dist/release
- name: Download Windows artifacts
uses: actions/download-artifact@v4
with:
name: windows
path: release
path: dist/release
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -90,13 +90,15 @@ jobs:
- name: Package optional assets bundle
run: |
tar -czf "release/subminer-assets.tar.gz" \
# Keep the archive layout stable for the AUR PKGBUILD.
tar -czf "dist/release/subminer-assets.tar.gz" \
--transform 's,^packaging/bun/licenses,resources/bun/licenses,' \
config.example.jsonc \
plugin/subminer \
plugin/subminer.conf \
assets/themes/subminer.rasi \
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
resources/bun/licenses
packaging/bun/licenses
- name: Package Bun corresponding source
run: bun scripts/package-bun-source.mjs
@@ -104,16 +106,16 @@ jobs:
- name: Generate checksums
run: |
shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
files=(dist/release/*.AppImage dist/release/*.dmg dist/release/*.exe dist/release/*.zip dist/release/*.tar.gz dist/release/latest*.yml dist/release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation."
exit 1
fi
: > release/SHA256SUMS.txt
: > dist/release/SHA256SUMS.txt
for file in "${files[@]}"; do
printf '%s %s\n' \
"$(sha256sum "$file" | awk '{print $1}')" \
"${file##*/}" >> release/SHA256SUMS.txt
"${file##*/}" >> dist/release/SHA256SUMS.txt
done
- name: Get version from tag
@@ -124,12 +126,12 @@ jobs:
env:
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: |
if [ ! -s release/prerelease-notes.md ]; then
echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
if [ ! -s packaging/release-notes/prerelease-notes.md ]; then
echo "::error::packaging/release-notes/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
exit 1
fi
if ! bun run changelog:check-prerelease-notes --version "$RELEASE_VERSION"; then
echo "::error::release/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag."
echo "::error::packaging/release-notes/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag."
exit 1
fi
@@ -142,15 +144,15 @@ jobs:
shopt -s nullglob
artifacts=(
release/*.AppImage
release/*.dmg
release/*.exe
release/*.zip
release/*.tar.gz
release/*.tar.gz.sha256
release/latest*.yml
release/*.blockmap
release/SHA256SUMS.txt
dist/release/*.AppImage
dist/release/*.dmg
dist/release/*.exe
dist/release/*.zip
dist/release/*.tar.gz
dist/release/*.tar.gz.sha256
dist/release/latest*.yml
dist/release/*.blockmap
dist/release/SHA256SUMS.txt
dist/launcher/subminer
dist/launcher/subminer.cmd
)
@@ -165,14 +167,14 @@ jobs:
--draft \
--prerelease \
--title "$RELEASE_VERSION" \
--notes-file release/prerelease-notes.md
--notes-file packaging/release-notes/prerelease-notes.md
else
gh release create "$RELEASE_VERSION" \
--draft \
--latest=false \
--prerelease \
--title "$RELEASE_VERSION" \
--notes-file release/prerelease-notes.md
--notes-file packaging/release-notes/prerelease-notes.md
fi
for asset in "${artifacts[@]}"; do
@@ -183,4 +185,4 @@ jobs:
--draft=false \
--prerelease \
--title "$RELEASE_VERSION" \
--notes-file release/prerelease-notes.md
--notes-file packaging/release-notes/prerelease-notes.md
+21 -19
View File
@@ -45,19 +45,19 @@ jobs:
uses: actions/download-artifact@v4
with:
name: appimage
path: release
path: dist/release
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: macos
path: release
path: dist/release
- name: Download Windows artifacts
uses: actions/download-artifact@v4
with:
name: windows
path: release
path: dist/release
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -91,13 +91,15 @@ jobs:
- name: Package optional assets bundle
run: |
tar -czf "release/subminer-assets.tar.gz" \
# Keep the archive layout stable for the AUR PKGBUILD.
tar -czf "dist/release/subminer-assets.tar.gz" \
--transform 's,^packaging/bun/licenses,resources/bun/licenses,' \
config.example.jsonc \
plugin/subminer \
plugin/subminer.conf \
assets/themes/subminer.rasi \
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
resources/bun/licenses
packaging/bun/licenses
- name: Package Bun corresponding source
run: bun scripts/package-bun-source.mjs
@@ -105,16 +107,16 @@ jobs:
- name: Generate checksums
run: |
shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
files=(dist/release/*.AppImage dist/release/*.dmg dist/release/*.exe dist/release/*.zip dist/release/*.tar.gz dist/release/latest*.yml dist/release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation."
exit 1
fi
: > release/SHA256SUMS.txt
: > dist/release/SHA256SUMS.txt
for file in "${files[@]}"; do
printf '%s %s\n' \
"$(sha256sum "$file" | awk '{print $1}')" \
"${file##*/}" >> release/SHA256SUMS.txt
"${file##*/}" >> dist/release/SHA256SUMS.txt
done
- name: Get version from tag
@@ -152,24 +154,24 @@ jobs:
gh release edit "$RELEASE_VERSION" \
--draft=false \
--title "$RELEASE_VERSION" \
--notes-file release/release-notes.md
--notes-file packaging/release-notes/release-notes.md
else
gh release create "$RELEASE_VERSION" \
--title "$RELEASE_VERSION" \
--notes-file release/release-notes.md
--notes-file packaging/release-notes/release-notes.md
fi
shopt -s nullglob
artifacts=(
release/*.AppImage
release/*.dmg
release/*.exe
release/*.zip
release/*.tar.gz
release/*.tar.gz.sha256
release/latest*.yml
release/*.blockmap
release/SHA256SUMS.txt
dist/release/*.AppImage
dist/release/*.dmg
dist/release/*.exe
dist/release/*.zip
dist/release/*.tar.gz
dist/release/*.tar.gz.sha256
dist/release/latest*.yml
dist/release/*.blockmap
dist/release/SHA256SUMS.txt
dist/launcher/subminer
dist/launcher/subminer.cmd
)
+1 -5
View File
@@ -8,11 +8,6 @@ node_modules/
# Electron build output
out/
dist/
release/*
!release/
!release/release-notes.md
!release/prerelease-notes.md
build/yomitan/
coverage/
# Launcher build artifact (produced by make build-launcher)
@@ -41,6 +36,7 @@ environment.toml
.vitepress/dist/
docs/.vitepress/cache/
docs/.vitepress/dist/
# Local test media and scripts
tests/*
.worktrees/
.tmp/
+4
View File
@@ -8,3 +8,7 @@
[submodule "vendor/subminer-yomitan"]
path = vendor/subminer-yomitan
url = https://github.com/ksyasuda/subminer-yomitan
[submodule "vendor/hachidori"]
path = vendor/hachidori
url = https://github.com/ksyasuda/hachidori.git
branch = subminer
+3 -1
View File
@@ -1,7 +1,9 @@
node_modules
dist
release
coverage
vendor
*.log
src/core/services/tokenizer/__fixtures__/golden/*.json
# Reviewed release text and vendored license texts; never reformat
packaging/release-notes
packaging/bun/licenses
-5
View File
@@ -1,5 +0,0 @@
{
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all"
}
+3 -1
View File
@@ -8,6 +8,7 @@ Start here, then leave this file.
- Architecture map: [`docs/architecture/README.md`](./docs/architecture/README.md)
- Workflow map: [`docs/workflow/README.md`](./docs/workflow/README.md)
- Verification lanes: [`docs/workflow/verification.md`](./docs/workflow/verification.md)
- Testing guidelines: [`docs/workflow/testing.md`](./docs/workflow/testing.md)
- Knowledge-base rules: [`docs/knowledge-base/README.md`](./docs/knowledge-base/README.md)
- Release guide: [`docs/RELEASING.md`](./docs/RELEASING.md)
@@ -44,10 +45,11 @@ Start here, then leave this file.
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
- Stats dashboard UI (`stats/`): `bun run test:stats`
- Overlay / renderer / mpv / mining behavior in the running app: `bun run build`, then `bun run test:e2e` (headless, sandboxed; `bun run e2e start` for exploratory checks)
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
- macOS mpv window helper: `bun test scripts/get-mpv-window-macos.test.ts`
- macOS Swift helpers (`scripts/get-mpv-window-macos.swift`, `scripts/macos-controller-watch.swift`): compile them on macOS with `bun run build`
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
## Docs Upkeep
+168
View File
@@ -1,5 +1,173 @@
# Changelog
## v0.22.0 (2026-11-06)
### Added
- **Plugins**:
- JavaScript plugins load at startup from `<config dir>/plugins/<id>/` and are enabled under `plugins` in the config.
- Plugins can react to playback and mined cards, add bindable actions, run programs, and show results in overlay panels or their own windows.
- Example plugins cover AI line explanations (`ai-explain`), AnkiWeb sync (`anki-sync`), and show and episode tags on mined cards (`episode-tags`).
- Plugin authors get a versioned API with standalone types.
- **Subtitle Filters**: Remove text in parentheses, sound descriptions, speaker names, music notes, or custom regex matches from primary subtitles. Configure them in Settings under Behavior → Subtitle Filters.
- **Controller Combos**: Controller actions can now be combos, such as holding `L1` and pressing D-pad right. Click **Learn** in `Alt+C`, then hold one button and press another.
- **Controller Actions**: New bindings cover previous, next, replay, and play-next subtitle, hold-to-show secondary subtitle, and previous or next Yomitan entry. All are unbound by default and can be assigned in `Alt+C`.
- **Controller Dialog Layouts**: The media timing review and duplicate card dialogs can now be used with a controller and show button hints while one is connected. Remap both layouts per controller in `Alt+C`.
- **Controller Button Style**: Choose Xbox, Nintendo, or PlayStation button names per controller in `Alt+C`, or set a default with `controller.faceButtonLabels`.
- **Controller Clash Warnings**: `Alt+C` now warns when two actions share the same input.
- **Keyboard-Only Mode**:
- Press `C` or use the new controller binding to move by character, reaching words split by the subtitle parser.
- The new **Pause Video In Keyboard-Only Mode** setting pauses on entry and resumes on exit.
- **Jimaku Auto-Open**: Set `jimaku.autoOpenWhenPrimarySubtitleMissing` to pause and open Jimaku when a local video has no Japanese subtitle track. Reopening Jimaku after the video changes now refreshes the title and episode.
### Changed
- **Stats Trends and Vocabulary**:
- Trends adds a learning section, heatmap, title timeline, custom date ranges, and a page-wide title filter.
- Vocabulary now has one searchable word table, known words by frequency band, a growth chart, and a full kanji view.
- New word counts now count dictionary words, so a word's conjugations count once and totals are lower than before.
- Stats now explains when streamed words or lines can't be mined.
- **Bundled Hachidori**: Updated to upstream 0.4.0, adding every pitch accent in popup entries, per-kanji headword furigana, and Yomitan-style definition tags.
- **Controller Behavior**: **Toggle MPV Pause** now works outside keyboard-only mode. Connected and disabled messages now follow your `ankiConnect.behavior.notificationType` setting.
- **Dictionary Popup**: Turning keyboard-only mode off now also closes the popup.
### Fixed
- **Controller Settings (`Alt+C`)**:
- Remaps now apply right away, and Learn works on **(D-pad)** rows.
- Clicking a row shows **Learn**, **Clear**, and **Reset** again.
- The window previews your bindings until a press, then selects the pressed controller.
- Button names are now correct on standard-layout controllers.
- **Controller Input on macOS and Windows**: One press no longer runs its action twice. On macOS, buttons now work while mpv is in front.
- **Dictionary Popup**: Keyboard and controller lookups now pause and resume playback reliably. Moving between words right after closing a popup no longer reopens it.
- **Playback**: Reloading the current file no longer leaves playback paused. The "Overlay loading" message no longer sticks or turns into a false error when a playlist advances.
- **Stats**:
- The dashboard, Vocabulary tab, and sentence search load much faster on large databases, and Trends downloads far less data.
- Trends now counts each finished episode once, and its Library totals match the selected range.
- Word totals, readings, Hide Names, and rate charts are now accurate.
- Date handling is now correct in time zones ahead of UTC.
- **AniList**: Progress sync now adds a missing show to Watching, or Completed on the final episode, when the title matches exactly or is pinned. Entries marked Completed, Paused, Dropped, or Rewatching are still never changed.
- **Anki Sentence Audio**: Generated sentence audio no longer overwrites Yomitan's word audio on default configs. If you relied on the old default, set `ankiConnect.fields.audio` to `ExpressionAudio`.
- **AppImage**: Fixed "Service Crash" notifications on quit on Ubuntu 26.04 and newer.
### Docs
- **Keyboard and Controller Reference**: Keyboard shortcuts now list the keyboard-only `C` key, and the configuration reference points to Controller support for default button actions.
## v0.21.1 (2026-10-26)
### Changed
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
- A reading density chart of known and unknown words replaces the cumulative chart.
- Yomitan lookups and mined cards now appear in their own lane below the chart.
### Fixed
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
### Docs
- **README**: Refreshed the anime screenshots using the current build.
<details>
<summary>Internal changes</summary>
### Internal
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
</details>
## v0.21.0 (2026-10-24)
### Added
- **Hachidori Dictionary Backend**:
- Hachidori is now bundled as an alternative to Yomitan. Select it with `dictionaryBackend`, then restart SubMiner.
- It works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
- First-run setup builds Hachidori's Anki template from your SubMiner settings. It can also link an external Hachidori app, browser, or Docker host.
- The tray and dictionary-settings shortcut open settings for the active backend. Use `--hachidori` or `--yomitan` to open a specific one.
- **Jellyfin Subtitle Generation**: You can now generate Japanese subtitles while playing a Jellyfin episode, with both direct play and transcoded playback.
### Fixed
- **Jellyfin**:
- `subminer jellyfin -p` now lists libraries and shows image previews when SubMiner is already running. Real errors are shown instead of an empty list.
- Watch progress from terminal playback is reported to Jellyfin even with cast discovery off.
- When `subminer jellyfin -p` starts SubMiner, the app shows its tray icon and quits when mpv closes.
- Casting on macOS now finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight.
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and older stats lines. Stats sentence search matches katakana in either width.
- **Media Timing Review**: The review opens on the first attempt instead of after a delay. Playback stays paused while it is open.
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys now work for typing in modal text fields instead of seeking mpv.
- **Stats Dashboard Mining**: Cards mined from the stats dashboard keep the selected history line and media.
- **Hyprland and Sway**: The app no longer freezes when `hyprctl` or `swaymsg` is slow or hangs.
- **Subtitle Sidebar**: The open sidebar does much less background work during playback.
- **macOS Launcher Status**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
- **Security Updates**: Updated Electron, undici, and follow-redirects to address security advisories.
### Docs
- **Hachidori Setup**: Documented backend selection, Hachidori dictionary and Anki setup, settings shortcuts, and external host linking.
- **Jellyfin Casting**: Explained when SubMiner joins the cast menu at startup, and how casting finds mpv on each platform. Added a troubleshooting entry for casts that never start mpv.
- **Jimaku Key**: Removed environment variables that never had any effect. Set the key with `jimaku.apiKey` or `jimaku.apiKeyCommand`.
<details>
<summary>Internal changes</summary>
### Internal
- **End-to-End Harness**: Added a headless e2e harness (`bun run test:e2e`, `bun run e2e`) with real mpv, a fixture dictionary, and a fake AnkiConnect.
- **Hachidori Fork**: The bundled Hachidori integration lives in a pinned fork submodule.
- **Dead Code Removal**:
- Removed the unused launcher Jimaku client, the mpv plugin hover-highlight module, and the unused character dictionary config keys.
- Removed 16 unused overlay IPC methods. SubMiner no longer forwards every ASS line to the overlay.
- Typecheck now runs with `noUnusedLocals`.
- **Setup Launcher Label**: First-run setup now labels a missing launcher "Not set up" instead of "Not installed".
</details>
## v0.20.1 (2026-10-03)
### Added
- **YouTube Browser Window**:
- Browse YouTube with your login kept across restarts. Open it with `subminer youtube`, `subminer yt`, or tray **Browse YouTube**.
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
- Queued videos get the same Japanese subtitle setup, and the rest of the queue is kept on Windows.
- Closing the window quits a SubMiner started by `subminer youtube`.
- **Whisper Subtitles for YouTube**:
- Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions.
- The video stays paused while Whisper transcribes, and the subtitle generation modal shows progress.
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including Windows stream playback.
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay, including on Hyprland.
- **Run Commands from Session Help**: During playback, press `Enter` or double-click a row in session help to run that command.
### Changed
- **Release Assets**: Releases no longer include package-size reports.
### Fixed
- **Hyprland Overlay**: The overlay stays clickable after mpv goes fullscreen with `mpv.backend: x11`, and it no longer interrupts mpv while mpv enters fullscreen.
- **mpv Key Bindings**: Bindings from mpv such as `9`/`0` for volume now work while the overlay has focus, even if the overlay loaded before mpv connected.
- **Jellyfin**:
- The Japanese subtitle track is selected as soon as it downloads, without waiting for the other tracks.
- Japanese and English tracks are auto-selected on episodes whose embedded subtitles are images.
- Timing review previews and mined card audio use the audio track you are playing.
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and original subtitle boundaries no longer cover the waveform. The preview cursor no longer stutters on macOS with Bluetooth headphones.
- **YouTube Subtitles**: Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track.
- **Stats**:
- Mined cards are counted once in session and lifetime stats.
- Word and kanji dates and the new-words charts stay accurate after deleting sessions. Existing data is repaired in the background.
- Overview totals, charts, and the activity calendar refresh after deleting sessions.
- **Yomitan Data Safety**: Electron is updated to 43.7.7. SubMiner now refuses to load your Yomitan data with an unsupported or downgraded runtime. Automatic character dictionary changes stop if Yomitan suddenly reports no dictionaries.
- **AppImage in Sandboxes**: The AppImage now opens its window when launched in a sandbox such as `firejail --appimage`.
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
### Docs
- **Docs Site**: The docs site is shorter and easier to scan, with a key and default table for each config block. Outdated pages were corrected, and the MeCab install command for Arch Linux is fixed.
- **Stats Docs**: The docs now cover the Overview refresh after deleting sessions and how discovery dates are kept after old sessions are pruned.
<details>
<summary>Internal changes</summary>
### Internal
- **Release Notes**: Release notes and `CHANGELOG.md` are now generated as short items per feature or area.
- **Dev Profile**: Development launches use a separate `SubMiner-dev` profile unless production-profile access is requested.
- **Dependencies**: undici is updated to 7.29.1 to clear the security advisories that were blocking CI.
- **Package Checks**: CI no longer compares package sizes. Package content checks still run.
</details>
## v0.20.0 (2026-09-23)
### Added
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+8 -17
View File
@@ -21,11 +21,10 @@ MACOS_DATA_DIR ?= $(HOME)/Library/Application Support/SubMiner
MACOS_APP_DIR ?= $(HOME)/Applications
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
# If building from source, the AppImage will typically land in release/.
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
PRERELEASE_NOTES := release/prerelease-notes.md
# If building from source, the AppImage will typically land in dist/release/.
APPIMAGE_SRC = $(firstword $(wildcard dist/release/SubMiner-*.AppImage))
MACOS_APP_SRC = $(firstword $(wildcard dist/release/*.app dist/release/*/*.app))
MACOS_ZIP_SRC = $(firstword $(wildcard dist/release/SubMiner-*.zip))
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
ifeq ($(OS),Windows_NT)
@@ -57,7 +56,7 @@ help:
" build-linux Build Linux AppImage" \
" build-macos Build macOS DMG/ZIP (signed if configured)" \
" build-macos-unsigned Build macOS DMG/ZIP without signing/notarization" \
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \
" clean Remove build artifacts (dist/, AppImage, binary)" \
" dev-start Build and launch local Electron app" \
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \
" dev-watch Start fast watch loop (tsc + renderer + Electron dev app)" \
@@ -165,15 +164,7 @@ build-launcher:
clean:
@printf '%s\n' "[INFO] Removing build artifacts"
@if [ -f "$(PRERELEASE_NOTES)" ]; then \
PRERELEASE_NOTES_BACKUP="$$(mktemp -t subminer-prerelease-notes.XXXXXX)" && \
cp "$(PRERELEASE_NOTES)" "$$PRERELEASE_NOTES_BACKUP" && \
rm -rf dist release && \
install -d release && \
mv "$$PRERELEASE_NOTES_BACKUP" "$(PRERELEASE_NOTES)"; \
else \
rm -rf dist release; \
fi
@rm -rf dist
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
generate-config: ensure-bun
@@ -229,7 +220,7 @@ install-linux: build-launcher
@if [ -n "$(APPIMAGE_SRC)" ]; then \
install -m 0755 "$(APPIMAGE_SRC)" "$(BINDIR)/SubMiner.AppImage"; \
else \
printf '%s\n' "[WARN] No release/SubMiner-*.AppImage found; skipping AppImage install"; \
printf '%s\n' "[WARN] No dist/release/SubMiner-*.AppImage found; skipping AppImage install"; \
printf '%s\n' " Build one with: make build"; \
fi
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
@@ -252,7 +243,7 @@ install-macos: build-launcher
ditto -x -k "$(MACOS_ZIP_SRC)" "$(MACOS_APP_DIR)"; \
printf '%s\n' "[INFO] Installed app bundle from $(MACOS_ZIP_SRC)"; \
else \
printf '%s\n' "[WARN] No macOS app bundle or zip found in release/; skipping app install"; \
printf '%s\n' "[WARN] No macOS app bundle or zip found in dist/release/; skipping app install"; \
printf '%s\n' " Build one with: make build"; \
fi
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
+12 -5
View File
@@ -4,7 +4,7 @@
# SubMiner
Look up words with Yomitan, mine them to Anki, and track your immersion without leaving mpv
Look up words with Yomitan or Hachidori, mine them to Anki, and track your immersion without leaving mpv
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
@@ -25,6 +25,8 @@ Look up words with Yomitan, mine them to Anki, and track your immersion without
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](https://github.com/bee-san/hachidori) backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
<div align="center">
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
</div>
@@ -36,7 +38,7 @@ Hover over any word in the subtitles to open the full Yomitan popup with definit
Create an Anki card from the exact playback moment with one key press, click, or controller input. SubMiner fills in the sentence, an audio clip, and a screenshot or animated image.
<div align="center">
<img src="docs-site/public/screenshots/one-key-mining.png" width="800" alt="Anki card created from SubMiner with sentence, audio, and screenshot">
<img src="docs-site/public/screenshots/one-key-mining.png" width="800" alt="Yomitan lookup over an anime scene with Anki mining controls">
</div>
<br>
@@ -46,7 +48,7 @@ Create an Anki card from the exact playback moment with one key press, click, or
Subtitles are annotated as they play with frequency highlighting, JLPT tags, N+1 targeting, and character names from a generated dictionary. Particles and grammar-only tokens stay plain so the words worth learning stand out.
<div align="center">
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency coloring, JLPT underlines, and N+1 targets">
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency colors, character portraits, and JLPT underlines">
</div>
<br>
@@ -100,6 +102,10 @@ A stats dashboard tracks watch time, vocabulary growth, mining throughput, sessi
<td><b>WebSocket</b></td>
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
</tr>
<tr>
<td><b>Plugins</b></td>
<td>Load your own JavaScript at startup to react to playback, run timers, add key-bound actions, and show results in an overlay panel. Bundled examples explain the current line with Codex, sync Anki to AnkiWeb, and tag mined cards by episode. <a href="https://docs.subminer.moe/plugins">Plugin guide</a></td>
</tr>
</table>
<br>
@@ -199,7 +205,7 @@ The AppImage is all you need. First-run setup can install the optional `subminer
Download the latest DMG from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
Then enable SubMiner under **System Settings > Privacy & Security > Accessibility**, or the overlay cannot follow the mpv window. If macOS blocks the first launch, right-click the app and choose **Open**.
Then enable SubMiner under **System Settings > Privacy & Security > Accessibility** (named **Device Control and Data Access** on macOS 27) so the overlay can track the mpv window. Without it, SubMiner falls back to a basic window lookup that cannot tell when mpv is minimized. If macOS blocks the first launch, right-click the app and choose **Open**.
</details>
@@ -265,7 +271,8 @@ SubMiner builds on the work of these open-source projects:
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
## License
+134
View File
@@ -0,0 +1,134 @@
// Sends the current subtitle line, with a few lines of context, to `codex exec` and shows the
// answer in the SubMiner plugin panel. Registers two actions: `explain` and `translate`.
const { mkdtemp, readFile, rm } = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const DEFAULT_SETTINGS = {
codexPath: 'codex',
model: '',
// Passed as -c model_reasoning_effort; low keeps answers to a few seconds. Empty uses your Codex config.
reasoningEffort: 'low',
before: 2,
after: 1,
timeoutMs: 120_000,
pause: true,
prompts: {},
};
const ACTIONS = {
explain: {
title: 'Grammar',
prompt:
'You are a Japanese tutor. Explain the line marked >>> for an intermediate learner. ' +
'Use the other lines only as context. Reply in English markdown: a natural translation, ' +
'then a bullet list of the grammar points and notable vocabulary. Be concise.',
},
translate: {
title: 'Translation',
prompt:
'Translate the line marked >>> into natural English. Use the other lines only as context. ' +
'Reply with the translation, then one short note only if the line is ambiguous.',
},
};
function activate(ctx) {
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
for (const [name, action] of Object.entries(ACTIONS)) {
const prompt = settings.prompts?.[name] ?? action.prompt;
ctx.actions.register(name, () => ask(ctx, settings, action.title, prompt));
}
}
function quote(text) {
return text
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
}
// Subtitles wrap across lines; flatten each cue so the >>> marker covers all of it.
function oneLine(text) {
return text
.split('\n')
.map((part) => part.trim())
.filter(Boolean)
.join(' ');
}
function tail(text, lineCount) {
return text.trim().split('\n').slice(-lineCount).join('\n');
}
async function ask(ctx, settings, title, instructions) {
const { before, current, after } = ctx.subtitles.context({
before: settings.before,
after: settings.after,
});
if (!current) {
ctx.notify('No subtitle line to send.', { variant: 'warning' });
return;
}
if (settings.pause) ctx.mpv.command(['set', 'pause', 'yes']);
const header = quote(current.text);
const panel = ctx.ui.panel({ title, markdown: `${header}\n\n_Asking Codex..._` });
const prompt = [
instructions,
'',
'Subtitles:',
...before.map((line) => oneLine(line.text)),
`>>> ${oneLine(current.text)}`,
...after.map((line) => oneLine(line.text)),
].join('\n');
// Codex runs in an empty read-only directory so any command it tries cannot touch real files.
const workDir = await mkdtemp(path.join(os.tmpdir(), 'subminer-ai-explain-'));
const answerPath = path.join(workDir, 'answer.md');
const args = [
'exec',
'--ephemeral',
'--skip-git-repo-check',
'--sandbox',
'read-only',
'--color',
'never',
'-C',
workDir,
'-o',
answerPath,
];
if (settings.model) args.push('-m', settings.model);
if (settings.reasoningEffort) {
args.push('-c', `model_reasoning_effort=${JSON.stringify(settings.reasoningEffort)}`);
}
args.push('-');
try {
const result = await ctx.exec(settings.codexPath, args, {
stdin: prompt,
timeoutMs: settings.timeoutMs,
signal: panel.signal,
});
if (result.exitCode !== 0) {
throw new Error(tail(result.stderr, 6) || `codex exited with code ${result.exitCode}`);
}
const answer = (await readFile(answerPath, 'utf8')).trim();
panel.update({ markdown: `${header}\n\n${answer || '_Codex returned an empty answer._'}` });
} catch (error) {
// Closing the panel aborts the request; there is nobody left to show the error to.
if (panel.signal.aborted) return;
const message =
error?.code === 'ENOENT'
? `Cannot find \`${settings.codexPath}\`. Set \`plugins.ai-explain.settings.codexPath\` to its full path.`
: String(error?.message ?? error);
ctx.log.warn(`Codex request failed: ${message}`);
panel.update({
markdown: `${header}\n\n**Codex request failed.**\n\n\`\`\`\n${message}\n\`\`\``,
});
} finally {
await rm(workDir, { recursive: true, force: true });
}
}
module.exports = { activate };
+6
View File
@@ -0,0 +1,6 @@
{
"name": "AI Explain",
"version": "1.0.0",
"apiVersion": 1,
"main": "main.js"
}
+61
View File
@@ -0,0 +1,61 @@
// Runs AnkiConnect's `sync` (AnkiWeb) after you mine cards. A burst of mining becomes one sync:
// it waits until no card has been mined for `delayMs`, and syncs right away when playback ends.
// Also registers a `sync` action for a manual sync.
const DEFAULT_SETTINGS = {
delayMs: 30_000,
syncOnEnd: true,
timeoutMs: 60_000,
notify: true,
};
const END_REASONS = new Set(['eof', 'stop', 'quit']);
function activate(ctx) {
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
// Cards mined since the last sync started.
let dirty = false;
let syncing = false;
let cancelTimer = null;
function schedule() {
cancelTimer?.();
cancelTimer = ctx.after(settings.delayMs, flush);
}
async function flush() {
cancelTimer?.();
cancelTimer = null;
// A sync already running picks up new cards when it finishes.
if (!dirty || syncing) return;
dirty = false;
syncing = true;
try {
await ctx.anki.invoke('sync', {}, { timeoutMs: settings.timeoutMs });
ctx.log.info('Synced Anki.');
if (settings.notify) ctx.notify('Anki synced');
} catch (error) {
ctx.log.warn(`Anki sync failed: ${error.message}`);
ctx.notify(`Anki sync failed: ${error.message}`, { variant: 'error' });
} finally {
syncing = false;
if (dirty) schedule();
}
}
ctx.anki.on('card-mined', () => {
dirty = true;
schedule();
});
ctx.mpv.on('end-file', ({ reason }) => {
if (settings.syncOnEnd && END_REASONS.has(reason)) return flush();
});
ctx.actions.register('sync', () => {
dirty = true;
return flush();
});
}
module.exports = { activate };
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Anki Sync",
"version": "1.0.0",
"apiVersion": 1,
"main": "main.js"
}
+110
View File
@@ -0,0 +1,110 @@
// Tags each mined card with the show and episode it came from, e.g. `show::Frieren::S01E07`.
// The show and episode are parsed from the file name, or from mpv's media title for streams.
const path = require('node:path');
const DEFAULT_SETTINGS = {
// Placeholders: {show}, {season}, {episode}, and {ep} (S01E07, or E07 without a season).
// A tag stops before the first `::` level whose placeholder has no value.
tags: ['show::{show}::{ep}'],
notify: false,
};
// Tried in order; the first match wins. Runs on names with brackets and dots already removed.
const EPISODE_PATTERNS = [
/^(?<show>.*?)\s*S(?<season>\d{1,2})\s?E(?<episode>\d{1,4})\b/i,
/^(?<show>.*?)\s*(?:Season\s?(?<season>\d{1,2})\s*)?(?:Episode|Ep)\s?(?<episode>\d{1,4})\b/i,
/^(?<show>.*?)\s+-\s+(?<episode>\d{1,4})(?:v\d)?\b/,
/^(?<show>.*?)\s+(?<episode>\d{1,4})(?:v\d)?$/,
/^(?<show>.*?)\s*第(?<episode>\d{1,4})話/,
];
// `[Group] Show Name - 07 (1080p).mkv` -> { show: 'Show Name', season: null, episode: '07' }
function parseMediaName(name) {
const cleaned = name
.replace(/\.[a-z0-9]{2,4}$/i, '')
.replace(/\[[^\]]*\]|\([^)]*\)|【[^】]*】/g, ' ')
.replace(/[._]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
for (const pattern of EPISODE_PATTERNS) {
const groups = cleaned.match(pattern)?.groups;
const show = groups?.show.replace(/[\s-]+$/, '');
if (!show) continue;
return { show, season: pad(groups.season), episode: pad(groups.episode) };
}
return { show: cleaned || null, season: null, episode: null };
}
function pad(number) {
return number ? String(Number(number)).padStart(2, '0') : null;
}
// Anki tags can't hold spaces, and a `::` in a value would add a hierarchy level.
function tagValue(value) {
return value.replace(/::?/g, ' ').trim().replace(/\s+/g, '_').replace(/"/g, '');
}
function renderTag(template, values) {
const levels = [];
for (const level of template.split('::')) {
let missing = false;
const rendered = level.replace(/\{(\w+)\}/g, (_, key) => {
const value = values[key];
if (!value) missing = true;
return value ? tagValue(value) : '';
});
if (missing) break;
levels.push(rendered);
}
return levels.join('::');
}
function mediaName(mediaPath, title) {
// A stream URL's last segment is usually an id, so streams use the title mpv shows.
if (typeof mediaPath === 'string' && mediaPath && !/^[a-z]+:\/\//i.test(mediaPath)) {
return path.basename(mediaPath);
}
return typeof title === 'string' && title ? title : null;
}
function renderTags(templates, name) {
const { show, season, episode } = parseMediaName(name);
const ep = episode ? `${season ? `S${season}` : ''}E${episode}` : null;
const values = { show, season, episode, ep };
return [...new Set(templates.map((template) => renderTag(template, values)))].filter(Boolean);
}
function activate(ctx) {
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
ctx.anki.on('card-mined', async ({ notes }) => {
// Notes from the same file share one addTags call.
const notesByName = new Map();
for (const { noteId, provenance } of notes) {
const name = mediaName(provenance?.media?.path, provenance?.media?.title);
if (!name) {
ctx.log.debug(`Note ${noteId} has no known media source; not tagging it.`);
continue;
}
if (!notesByName.has(name)) notesByName.set(name, new Set());
notesByName.get(name).add(noteId);
}
for (const [name, notes] of notesByName) {
const tags = renderTags(settings.tags, name);
if (tags.length === 0) {
ctx.log.debug(`No tags for "${name}".`);
continue;
}
try {
await ctx.anki.invoke('addTags', { notes: [...notes], tags: tags.join(' ') });
ctx.log.info(`Tagged ${notes.size} note(s): ${tags.join(' ')}`);
if (settings.notify) ctx.notify(`Tagged ${tags.join(' ')}`);
} catch (error) {
ctx.log.warn(`Tagging failed: ${error.message}`);
ctx.notify(`Episode tagging failed: ${error.message}`, { variant: 'error' });
}
}
});
}
module.exports = { activate };
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Episode Tags",
"version": "1.0.0",
"apiVersion": 1,
"main": "main.js"
}
+10 -123
View File
@@ -5,8 +5,6 @@
"": {
"name": "subminer",
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"@xhayper/discord-rpc": "^1.3.4",
"axios": "^1.20.0",
"commander": "^14.0.3",
@@ -21,25 +19,22 @@
"@electron/asar": "3.4.1",
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "43.7.7",
"electron": "43.8.3",
"electron-builder": "26.16.1",
"esbuild": "^0.25.12",
"eslint": "^10.8.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"undici": "7.29.1",
"undici": "7.30.1",
},
},
},
"patchedDependencies": {
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch",
},
"overrides": {
"@xmldom/xmldom": "0.8.15",
"app-builder-lib": "26.16.1",
"brace-expansion": "5.0.11",
"electron-builder-squirrel-windows": "26.16.1",
"fast-uri": "3.1.7",
"follow-redirects": "1.16.2",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.2",
@@ -48,7 +43,7 @@
"picomatch": "4.0.4",
"tar": "7.5.21",
"tmp": "0.2.7",
"undici": "7.29.1",
"undici": "7.30.1",
},
"packages": {
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
@@ -127,34 +122,6 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="],
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.28", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Lc/b8JXO2W2+H+5UXfw7PCHZCim1jlrB0CmLPsjfVmihMluBpdYafFImhjAHxHlWGfuZ32WzjVPUap5fGmkthw=="],
@@ -203,16 +170,10 @@
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="],
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
@@ -231,13 +192,9 @@
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -317,8 +274,6 @@
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
@@ -347,7 +302,7 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@43.7.7", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-y0pg+KhWLAkW2Ts2njkFFNFl3KtB1FmbUp9Te9i+R2Og2v1fcI/I6MADJ7jvnzB5UKdt+U+0EUoCnVL79xMPMA=="],
"electron": ["electron@43.8.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-KgvSm3bHXz96hSXzqxyRjLeli80rIdszbDQFKlRSfckmGazTPCH/CxUN31Dp9c5rFKTQSNf9gPJRWl1yWNB3pA=="],
"electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="],
@@ -383,45 +338,17 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ=="],
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
"follow-redirects": ["follow-redirects@1.16.2", "", {}, "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow=="],
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
@@ -441,8 +368,6 @@
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
@@ -467,7 +392,7 @@
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
"http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
@@ -475,20 +400,12 @@
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
@@ -503,9 +420,7 @@
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
@@ -521,12 +436,8 @@
"lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.18.0", "", {}, "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
@@ -563,8 +474,6 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="],
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
@@ -581,16 +490,10 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -607,8 +510,6 @@
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="],
@@ -625,8 +526,6 @@
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
@@ -711,13 +610,11 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="],
"undici": ["undici@7.30.1", "", {}, "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -725,8 +622,6 @@
"unzipper": ["unzipper@0.12.5", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@@ -735,8 +630,6 @@
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
@@ -771,8 +664,6 @@
"@electron/windows-sign/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@types/cacheable-request/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
@@ -787,8 +678,6 @@
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"app-builder-lib/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"builder-util/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
@@ -835,8 +724,6 @@
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"app-builder-lib/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"builder-util/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+3 -3
View File
@@ -42,13 +42,13 @@ How fragments turn into a release:
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
- 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 short top-level items with one nested bullet per distinct change, instead of packing a release's worth of detail into a single paragraph bullet. An item with only one thing to say stays inline on the top-level bullet. Release notes and prerelease notes additionally cover user benefit and any useful action note in their nested bullets.
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
- The polished `CHANGELOG.md` and `packaging/release-notes/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
Prerelease notes:
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `packaging/release-notes/prerelease-notes.md`
- from the second prerelease of a base version onward, the notes also open with a `## Changes since <previous tag>` section generated from the fragment diff against the previous beta/RC tag; keep fragment edits meaningful. Editorial-only rewording is filtered out of that section, while genuinely changed behavior and deleted fragments (reverted changes) are reported
- existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: linux
- Fixed the AppImage exiting without opening a window when launched inside a sandbox that mounts the image itself, such as `firejail --appimage` (used by the AppImage catalog test). The detached background app now runs straight from the sandbox's squashfs mount instead of trying to re-mount the AppImage over FUSE.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: app
- SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
-7
View File
@@ -1,7 +0,0 @@
type: docs
area: docs
- Rewrote the docs site to be shorter and easier to scan: pages lead with setup and use, reference material lives in compact tables, and internal detail was cut from user pages.
- The configuration reference now has a short explanation and a key/default table for each config block.
- Fixed docs that no longer matched current behavior.
- Fixed the Arch Linux install command for MeCab, which is only in the AUR (`mecab-git`), and brought the README requirements and quick start in line with the installation guide.
@@ -1,6 +0,0 @@
type: fixed
area: dictionary
- Upgraded the desktop runtime to Electron 43.7.7 and added profile guards that block unsupported runtimes and Electron major or minor downgrades before Yomitan storage is loaded. Patch downgrades within the same major and minor version are allowed.
- Development launches now use a separate `SubMiner-dev` profile unless production-profile access is explicitly requested.
- Automatic character-dictionary changes now stop when a previously non-empty Yomitan profile suddenly reports zero dictionaries.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Keep the subtitle overlay mapped during fullscreen transitions on Hyprland, avoiding focus changes from repeated hide/show calls that can cancel mpv fullscreen entry. Preserve the hide/show refresh on Sway.
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed the overlay (subtitles and sidebar) becoming unclickable after mpv goes fullscreen on Hyprland with `mpv.backend: x11`. The launcher no longer strips `HYPRLAND_INSTANCE_SIGNATURE` from the X11 environment, so the overlay can lift Hyprland's fullscreen input block again.
@@ -1,5 +0,0 @@
type: fixed
area: jellyfin
- Jellyfin playback now selects the Japanese subtitle track, and starts subtitle annotations, as soon as that track downloads instead of waiting for every other subtitle track. Tracks now download in parallel, so a slow embedded track Jellyfin has to extract no longer delays the primary subtitles.
- Jellyfin episodes with image-based embedded subtitles (PGS, DVD, DVB) now auto-select the Japanese and English tracks. SubMiner no longer requests those tracks as text, and a subtitle track that fails to download no longer cancels selection of the others.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: jellyfin
- Timing review previews and mined card audio now use the audio track you are playing on Jellyfin direct-play streams, instead of the track Jellyfin marks as default.
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed mpv key bindings (input.conf and mpv defaults, e.g. `9`/`0` volume) doing nothing while the overlay had focus when the overlay loaded before SubMiner connected to mpv, common in `mpv.backend: x11` mode. The overlay now reloads mpv's bindings once mpv connects.
-5
View File
@@ -1,5 +0,0 @@
type: added
area: overlay
- Added scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`, with modifiers) to `keybindings`, including capture from the settings key editor.
- Mouse button and scroll wheel bindings from mpv (`input.conf` and mpv defaults, e.g. double-click fullscreen, wheel volume, back/forward for playlist) now work while the cursor is over the overlay. On Hyprland, where the overlay always receives input, they previously did nothing. SubMiner bindings and the right-click pause still take priority.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: dependencies
- Updated Electron to 42.10.0 and undici to 7.29.1 to resolve the security advisories blocking CI.
-4
View File
@@ -1,4 +0,0 @@
type: changed
area: release
- Removed package-size JSON reports from future releases and their CI size comparisons. Package-content validation remains enabled.
-4
View File
@@ -1,4 +0,0 @@
type: added
area: overlay
- Session help can run the highlighted command during playback: press `Enter` or double-click a row. Hovering a row with the mouse makes it the `Enter` target. Help opened from the tray with no video loaded stays read-only.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- On macOS with Bluetooth headphones such as AirPods, the timing review preview cursor now moves smoothly across the selection instead of stuttering and jumping to the end partway through. This affected Opus audio, including Jellyfin streams.
-5
View File
@@ -1,5 +0,0 @@
type: fixed
area: overlay
- Media timing review trim handles are now thin lines centered on the clip edge, so it's clear where the clip starts and ends and less of the waveform is covered.
- Original subtitle boundaries use orange bars without text tags, leaving the waveform unobstructed.
-7
View File
@@ -1,7 +0,0 @@
type: added
area: youtube
- Added a YouTube browser window (`subminer youtube`/`subminer yt`, or tray **Browse YouTube**) that keeps your YouTube login across restarts.
- Clicking a video plays it in mpv instead of the page; middle-click, Shift/Ctrl-click, or the right-click menu queues it in mpv's playlist.
- Queued YouTube videos get the same Japanese subtitle setup when mpv reaches them.
- Closing the YouTube window quits a SubMiner started by `subminer youtube` (after mpv closes, if a video is still playing).
@@ -1,4 +0,0 @@
type: fixed
area: youtube
- YouTube Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track; SubMiner now picks the real track and falls back to yt-dlp if a direct subtitle download is refused.
-7
View File
@@ -1,7 +0,0 @@
type: added
area: youtube
- Added `youtube.subtitleSource`: set it to `whisper` to transcribe YouTube videos with Whisper instead of downloading YouTube's captions. The default stays `youtube`.
- Whisper mode keeps the video paused while it transcribes, with progress in the subtitle generation modal; close the modal to keep watching, or cancel to continue without subtitles.
- The subtitle generation modal (`Ctrl+Shift+G`) now also works on YouTube videos, whatever `youtube.subtitleSource` is set to.
- Whisper mode downloads a small audio-only stream and deletes it once generation ends, or when you switch videos, close mpv, or quit.
+353 -18
View File
@@ -6,6 +6,13 @@
*/
{
// ==========================================
// Dictionary Backend
// Select the dictionary lookup backend: yomitan or hachidori.
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
// ==========================================
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
// ==========================================
// Subtitle Selection
// Select primary and secondary mpv subtitle tracks from the overlay.
@@ -101,6 +108,7 @@
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
"faceButtonLabels": "xbox", // Face button style of your controller: xbox (A at the bottom), nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or playstation (╳ ○ □ △, laid out like xbox). Places the A/B/X/Y of the dialog layouts and names buttons in Alt+C and the dialog hints. Alt+C sets it per controller (controller.profiles.<id>.faceButtonLabels); this is the default for the others. Values: xbox | nintendo | playstation
"repeatDelayMs": 320, // Delay before repeating held controller actions.
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
"buttonIndices": {
@@ -120,38 +128,62 @@
"toggleLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"closeLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"toggleKeyboardOnlyMode": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"mineCard": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"quitMpv": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"previousAudio": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"nextAudio": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"playCurrentAudio": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"toggleMpvPause": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"holdSecondarySub": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"previousEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"nextEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"toggleCharacterNavigation": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"previousSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"nextSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"replaySubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"playNextSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action.
@@ -162,17 +194,294 @@
"axisIndex": 1, // Raw axis index captured for this analog controller action.
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
"rightStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 3, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
"rightStickVertical": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 4, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
"reviewBindings": {
"confirm": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Use this timing.
"cancel": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Cancel.
"play": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Play selection.
"resetTiming": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Reset timing.
"showEarlier": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Earlier −2s. Repeats while held.
"showLater": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Later +2s. Repeats while held.
"framePrevious": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Previous screenshot frame. Repeats while held.
"frameNext": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Next screenshot frame. Repeats while held.
"frameReset": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Reset screenshot frame.
"startBack": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Start −0.1s. Repeats while held.
"startForward": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Start +0.1s. Repeats while held.
"previousLineAdd": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Add previous line. Repeats while held.
"previousLineRemove": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Remove previous line. Repeats while held.
"endBack": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: End −0.1s. Repeats while held.
"endForward": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: End +0.1s. Repeats while held.
"nextLineAdd": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Add next line. Repeats while held.
"nextLineRemove": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Remove next line. Repeats while held.
"useOriginal": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Use original timing (after cancel).
"keepEditing": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Keep editing (after cancel).
"skipMedia": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Without media (after cancel).
"discard": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
} // Review dialog: Discard card (after cancel).
}, // Controller layout for the media timing review dialog, one entry per action. Set it with the Review media timing group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.
"fieldGroupingBindings": {
"selectOriginal": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Original card.
"selectNew": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: New card.
"toggleDeleteDuplicate": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Delete duplicate on/off.
"continue": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Continue.
"cancel": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Cancel.
"confirmMerge": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Confirm merge (merge preview).
"back": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Back (merge preview).
"cancelMerge": {
"kind": "none" // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
}, // Duplicate card dialog: Cancel (merge preview).
"toggleView": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Compact / Full (merge preview).
"scrollUp": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Scroll up (merge preview). Repeats while held.
"scrollDown": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
} // Duplicate card dialog: Scroll down (merge preview). Repeats while held.
}, // Controller layout for the duplicate card (field grouping) dialog, one entry per action. Set it with the Duplicate card (field grouping) group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
@@ -211,6 +520,14 @@
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior.
// ==========================================
// Plugins
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
// Restart SubMiner after changing plugins.
// ==========================================
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
@@ -455,6 +772,7 @@
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"autoPauseVideoOnKeyboardMode": false, // Pause mpv when keyboard-only mode turns on (from the keyboard or a controller) and resume when it turns off. Leaving the mode resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, is left as it is. Values: true | false
"primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false
"nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
@@ -534,6 +852,22 @@
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// ==========================================
// Subtitle Filters
// Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
// Applies to the overlay, sidebar, mined sentences and stats.
// Hot-reload: filter changes apply to the current subtitle file immediately.
// ==========================================
"subtitleFilter": {
"presets": {
"parentheses": false, // Remove text in () and (), such as speaker names, readings and notes. A line wrapped entirely in parentheses, usually inner thoughts, is kept. Values: true | false
"brackets": false, // Remove text in [], [], 【】 and 〔〕, such as sound effects and music cues. Values: true | false
"speakerPrefix": false, // Remove a short name followed by a colon at the start of a line, as in 山田:元気? Values: true | false
"musicSymbols": false // Remove ♪, ♫ and ♬. A line left with only symbols such as ~ is removed. Values: true | false
}, // Built-in filters. Each removes its matches and is off by default.
"customRules": [] // Your own regex rules, applied in order after the presets. Each rule is { name, pattern, replacement, enabled }; patterns use the gmu flags, so ^ and $ match per line. Leave replacement empty to remove the match.
}, // Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
// ==========================================
// Shared AI Provider
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
@@ -571,7 +905,7 @@
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
"fields": {
"word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
"audio": "SentenceAudio", // Card field that receives generated sentence audio.
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
"image": "Picture", // Card field that receives the captured screenshot or animated image.
"sentence": "Sentence", // Card field that receives the source sentence text.
@@ -656,7 +990,8 @@
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
"maxEntryResults": 10 // Maximum Jimaku search results returned.
"maxEntryResults": 10, // Maximum Jimaku search results returned.
"autoOpenWhenPrimarySubtitleMissing": false // Pause playback and open the Jimaku modal when a local video has no subtitle track in youtube.primarySubLanguages (Japanese by default). Values: true | false
}, // Jimaku API configuration and defaults.
// ==========================================
@@ -734,10 +1069,10 @@
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
// Set mpv.profile to pass an mpv profile to managed mpv launches; leave it blank to pass none.
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
// Leave mpv.executablePath blank to auto-discover mpv from SUBMINER_MPV_PATH, PATH, or (macOS) Homebrew and /Applications/mpv.app.
// ==========================================
"mpv": {
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"executablePath": "", // Optional absolute path to the mpv binary SubMiner starts itself (Windows launches, Jellyfin casting, timing preview). Leave empty to auto-discover from SUBMINER_MPV_PATH, PATH, and on macOS the Homebrew and /Applications/mpv.app locations.
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
+1
View File
@@ -221,6 +221,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'AniList', link: '/anilist-integration' },
{ text: 'AniSkip', link: '/aniskip-integration' },
{ text: 'Character Dictionary', link: '/character-dictionary' },
{ text: 'Plugins', link: '/plugins' },
],
},
{
+2 -2
View File
@@ -29,7 +29,7 @@ An episode counts as watched after 85% of its length and at least 10 minutes of
2. Finds the matching AniList entry. For season 2 and later, it follows the show's sequels.
3. Sets your progress to that episode and marks the entry Watching, or Completed on the final episode.
The show must already be on your Planning or Watching list. SubMiner does not add new entries, and it never lowers your progress.
If the show is not on your list yet, SubMiner adds it as Watching, but only when the file title exactly matches an AniList title or you pinned the entry with the character dictionary's AniList override. For a looser match, add the show to Planning or Watching yourself. SubMiner never changes entries marked Completed, Paused, Dropped, or Rewatching, and it never lowers your progress.
Failed updates are saved and retried in the background, up to 8 times with growing delays. The queue survives restarts.
@@ -61,7 +61,7 @@ Character dictionary settings live under `anilist.characterDictionary` and are c
**No update after an episode.** Check that `anilist.enabled` is `true` and that you watched at least 85% of the episode.
**"AniList update not possible."** Add the show to your Planning or Watching list, then mark the episode watched again.
**"AniList update not possible."** The show is marked Completed, Paused, Dropped, or Rewatching, or it is not on your list and the title match was not exact. Add the show to your Planning or Watching list, or pin it with the character dictionary's AniList override, then mark the episode watched again.
**Wrong show or episode.** Install guessit and make sure it is on your `PATH`. Unusual file names parse poorly without it.
+66 -3
View File
@@ -15,9 +15,9 @@ If you changed AnkiConnect's port, set `ankiConnect.url` to match.
## How cards get filled
When Yomitan adds a note, SubMiner fills the sentence, audio, image, and MiscInfo fields. It finds new notes in one of two ways:
When Yomitan or Hachidori adds a note, SubMiner fills the sentence, audio, image, and MiscInfo fields. It finds new notes in one of two ways:
- **Proxy (default).** SubMiner runs a local AnkiConnect-compatible server. Yomitan sends notes through it, and SubMiner fills each one right after Anki accepts it.
- **Proxy (default).** SubMiner runs a local AnkiConnect-compatible server. The dictionary sends notes through it, and SubMiner fills each one right after Anki accepts it.
- **Polling.** With `ankiConnect.proxy.enabled` set to `false`, SubMiner asks AnkiConnect for recently added notes every `ankiConnect.pollingRate` milliseconds.
Set `ankiConnect.behavior.autoUpdateNewCards` to `false` to stop automatic filling and update cards by hand with `Ctrl/Cmd+V` instead.
@@ -42,6 +42,20 @@ Clients must send notes to the proxy (`http://127.0.0.1:8766` here), not to Anki
- **Bundled Yomitan.** SubMiner sets the active Yomitan profile's Anki server for you. With the proxy on, it always points the profile at the proxy. With the proxy off, it sets `ankiConnect.url`, but only if the profile's server is blank or the stock `http://127.0.0.1:8765`.
- **Browser Yomitan or other clients.** Set the Anki server to the proxy URL yourself. To leave your main profile alone, create a separate Yomitan profile for SubMiner, set its Anki server (Settings, Anki) to the proxy URL, and make it active while you mine.
- **Hachidori.** SubMiner routes Hachidori to the proxy while it is active. Keep the proxy on for screenshots and sentence audio.
### Hachidori settings from SubMiner
With the [Hachidori backend](/usage#hachidori-setup), SubMiner fills Hachidori's first Anki template from your `ankiConnect` settings on startup and whenever you open Hachidori Settings:
- The deck always follows `ankiConnect.deck`, because polling only looks for new cards in that deck.
- While the template still has Hachidori's default `hachidori` tag, the configured tags are added ahead of it so cards carry both. Tags you edit in Hachidori settings are kept as written.
- Missing word, sentence, pronunciation-audio, and picture mappings are filled with fields that exist in Anki. Pronunciation uses `fields.wordAudio`, or `fields.audio` when no word-audio field is set.
- If the note type is unset, SubMiner picks the one note type that has your word and sentence fields. Enabled Lapis, Kiku, or Senren narrows the search. A fresh mapping also gets Hachidori's matching preset for readings, definitions, and other known fields.
Apart from the deck, SubMiner only fills missing settings. Custom tags, field mappings, advanced templates, and extra templates stay as you set them. If several note types match, pick one in Hachidori Settings. If Anki was closed, start it and open Hachidori Settings again to retry.
Sentence audio, image timing, translation, metadata, and field grouping stay under SubMiner's control. Pronunciation sources are set in Hachidori. Linking an external dictionary host does not change any of this.
### Proxy troubleshooting
@@ -87,7 +101,9 @@ If cards are not getting filled:
Field names are matched case-insensitively. A mapped field that is missing from the note type is skipped.
`fields.audio` gets sentence audio, not word audio. Yomitan writes its own dictionary audio into your note, so point `fields.audio` at a separate field such as `SentenceAudio`. The default, `ExpressionAudio`, is the field many note types use for Yomitan's word audio, so leaving it would overwrite that audio.
Hachidori prepares downloadable word audio before it saves a note through the proxy, so the animated image delay works on the first mine. Set a downloadable pronunciation source in Hachidori's Audio settings. Browser speech cannot be saved to Anki. Without word audio, Hachidori shows a warning and the card gets no word-audio hold.
`fields.audio` gets sentence audio, not word audio. Yomitan writes its own dictionary audio into your note (often `ExpressionAudio`), so keep `fields.audio` pointed at a separate field such as the default `SentenceAudio`. Pointing it at Yomitan's word-audio field would overwrite that audio.
`ankiConnect.tags` adds tags to every mined or updated card. Set it to `[]` to add none.
@@ -122,6 +138,8 @@ Media settings apply to the next card without a restart.
With `media.reviewTiming` on, SubMiner pauses before making media for word, sentence, and audio cards and opens a review dialog. You can also toggle it for the current session with **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`). Clipboard updates and stats-dashboard mining skip the review.
Playback stays paused while the dialog is open, even if the popup or hover that paused it goes away. When the dialog closes, playback resumes if it was playing before, or if the popup closed in the meantime. A popup that is still open keeps it paused.
The dialog shows the clip over a speech waveform. When the waveform loads, an untouched clip end moves back to just after the last speech in the line. The orange bars still mark the subtitle's original start and end.
During preview, the yellow cursor follows the audio player's position through the selected range and stays at the end when playback finishes. Buffering pauses the cursor too. If the player stops reporting progress for 15 seconds, the preview stops so you can retry.
@@ -142,6 +160,34 @@ The confirmed range is used as is, with no extra padding. Added lines go into th
When you cancel, you can go back to editing, keep the original timing, create the card without media, or discard it. Discard deletes the Yomitan note or audio card, and skips creation for a sentence card.
#### With a controller
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each button shows its controller shortcut once SubMiner has seen a controller, which takes one button press after connecting it. It works with or without keyboard-only mode, and the controller's usual actions pause until the dialog closes. Buttons that were already held when the dialog opened, such as the mine button, wait for a fresh press.
`A`, `B`, `X` and `Y` mean the letters printed on your controller. Pick your controller's **Button Style** in `Alt+C`: choose Nintendo if A is on the right and B at the bottom (Switch-style and many 8BitDo pads). On PlayStation, `A` is ╳, `B` ○, `X` □ and `Y` △, and the shortcuts on screen show those symbols.
Hold `L1` or `R1` and press the D-pad for the combos. `L1` covers the clip start and the lines before it, and `R1` the clip end and the lines after it. D-pad actions repeat while held.
This is the default layout:
| Controller | Editing | After Cancel |
| ----------------------- | -------------------------------- | ------------------- |
| `A` | Use this timing | Use original timing |
| `B` | Cancel | Keep editing |
| `X` | Play selection | Without media |
| `Y` | Reset timing | Discard |
| D-pad left / right | Earlier −2s / Later +2s | |
| D-pad up / down | Previous / next screenshot frame | |
| `L1` + D-pad left/right | Start −0.1s / +0.1s | |
| `L1` + D-pad up/down | Add / remove a previous line | |
| `R1` + D-pad left/right | End −0.1s / +0.1s | |
| `R1` + D-pad up/down | Add / remove a next line | |
| `L1` + `R1` | Reset the screenshot frame | |
To change it, open `Alt+C` and expand **Review media timing**. Click an action's pencil, then press a button, or hold one button and press another to make a combo; the first button becomes the one you hold. Any button can be a hold button. Changes are saved per controller, and **Reset all** brings back the default layout.
Nudges, Earlier/Later, lines and frames repeat while held whichever buttons they are on. A row warns you when two actions on the same step share an input, or when an action uses a button alone that other actions hold.
### Update behavior
| Key | What it does |
@@ -208,6 +254,8 @@ For Senren, use the same keys under `isSenren`.
| `auto` | Merge into the existing card. With `deleteDuplicateInAuto`, delete the new card |
| `manual` | Show both cards, let you choose which to keep and preview the merge |
Grouping runs when a new note is added while duplicates exist. With Hachidori, that is the popup's **Add anyway** choice. **Overwrite** updates the existing note in place and only gets media.
The manual dialog cancels itself after 90 seconds. Identical entries are not deduplicated. Press `Ctrl/Cmd+G` to run the duplicate check on the last card yourself.
| Key | Action |
@@ -217,6 +265,21 @@ The manual dialog cancels itself after 90 seconds. Identical entries are not ded
| `Backspace` | Back from the merge preview |
| `Esc` | Cancel and leave both cards unchanged |
### With a controller {#field-grouping-with-a-controller}
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each control shows its controller shortcut once SubMiner has seen a controller. Like the [media timing review](#with-a-controller), it works with or without keyboard-only mode, `A`/`B`/`X`/`Y` are the printed letters (set by **Button Style** in `Alt+C`), and a button already held when the dialog opens waits for a fresh press.
| Controller | Choosing the card | Merge preview |
| ------------------ | --------------------------------- | ----------------------- |
| D-pad left / right | Original card / New card | |
| D-pad up / down | | Scroll (repeats) |
| `A` | Continue | Confirm merge |
| `B` | Cancel | Back to the card choice |
| `X` | Turn "Delete duplicate" on or off | |
| `Y` | | Compact / Full |
To cancel from the preview, press `B` twice, or bind the preview's **Cancel** row (unbound by default). Change any of it in the **Duplicate card (field grouping)** group of `Alt+C`, the same way as the review layout.
## Config validation
Invalid `ankiConnect` values produce a warning and fall back to the default. Use JSON booleans (`true`, not `"true"`) and a positive number for `pollingRate`.
+2 -1
View File
@@ -24,6 +24,7 @@ src/
preload*.ts # preload bridges (overlay, settings, stats, sync, Jellyfin setup)
main/ # main-process runtime modules and IPC/CLI wiring
boot/ # pre-ready boot helpers
assemblies/ # per-domain assemblies main.ts builds once (startup, overlay, playback, mining, stats, Yomitan, shortcuts, session actions, plugins, subtitle generation, tray, first-run setup, YouTube, Jellyfin, AniList, settings, sync, dictionaries, overlay modals)
runtime/composers/ # larger runtime clusters assembled for main.ts
runtime/domains/ # domain barrels (startup, overlay, mpv, ipc, shortcuts, anilist, jellyfin, mining)
core/services/ # focused services: mpv client, overlay, tokenizer, mining, integrations, stats
@@ -138,7 +139,7 @@ Runtime code uses dependency injection:
2. Build its runtime dependencies in a `src/main/` module. Pass simple dependencies inline; extract an adapter only when it adds behavior or gets reused.
3. Call the service from lifecycle or command wiring.
`main.ts` gets domain handlers through `createMainRuntimeRegistry()` (`src/main/runtime/registry.ts`), which exposes the barrels in `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. Many handlers take a `*MainDeps` object built by a `createBuild*MainDepsHandler` builder, which keeps side effects out of the unit under test.
Domain handlers live in the barrels under `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. A domain whose runtimes share state, such as app startup, the overlay windows, mpv playback and subtitle state, card mining, stats, plugins, YouTube, Jellyfin, AniList, config settings, sync, or the character dictionary, is grouped into one assembly in `src/main/assemblies/` that owns that state and returns its runtimes. An assembly that answers renderer IPC or CLI commands also returns typed `ipc`/`cli` slices, and `main.ts` registers the handlers by combining those slices. Handlers take their dependencies as a plain object passed straight to the handler factory, which keeps side effects out of the unit under test.
Composers declare their inputs with `ComposerInputs<T>` and results with `ComposerOutputs<T>` from `src/main/runtime/composers/contracts.ts`. A missing dependency then fails at compile time.
+180 -2
View File
@@ -1,6 +1,184 @@
# Changelog
## v0.20.0 (2026-09-23)
## v0.22.0 (2026-11-06)
**Added**
- **Plugins**:
- JavaScript plugins load at startup from `<config dir>/plugins/<id>/` and are enabled under `plugins` in the config.
- Plugins can react to playback and mined cards, add bindable actions, run programs, and show results in overlay panels or their own windows.
- Example plugins cover AI line explanations (`ai-explain`), AnkiWeb sync (`anki-sync`), and show and episode tags on mined cards (`episode-tags`).
- Plugin authors get a versioned API with standalone types.
- **Subtitle Filters**: Remove text in parentheses, sound descriptions, speaker names, music notes, or custom regex matches from primary subtitles. Configure them in Settings under Behavior → Subtitle Filters.
- **Controller Combos**: Controller actions can now be combos, such as holding `L1` and pressing D-pad right. Click **Learn** in `Alt+C`, then hold one button and press another.
- **Controller Actions**: New bindings cover previous, next, replay, and play-next subtitle, hold-to-show secondary subtitle, and previous or next Yomitan entry. All are unbound by default and can be assigned in `Alt+C`.
- **Controller Dialog Layouts**: The media timing review and duplicate card dialogs can now be used with a controller and show button hints while one is connected. Remap both layouts per controller in `Alt+C`.
- **Controller Button Style**: Choose Xbox, Nintendo, or PlayStation button names per controller in `Alt+C`, or set a default with `controller.faceButtonLabels`.
- **Controller Clash Warnings**: `Alt+C` now warns when two actions share the same input.
- **Keyboard-Only Mode**:
- Press `C` or use the new controller binding to move by character, reaching words split by the subtitle parser.
- The new **Pause Video In Keyboard-Only Mode** setting pauses on entry and resumes on exit.
- **Jimaku Auto-Open**: Set `jimaku.autoOpenWhenPrimarySubtitleMissing` to pause and open Jimaku when a local video has no Japanese subtitle track. Reopening Jimaku after the video changes now refreshes the title and episode.
**Changed**
- **Stats Trends and Vocabulary**:
- Trends adds a learning section, heatmap, title timeline, custom date ranges, and a page-wide title filter.
- Vocabulary now has one searchable word table, known words by frequency band, a growth chart, and a full kanji view.
- New word counts now count dictionary words, so a word's conjugations count once and totals are lower than before.
- Stats now explains when streamed words or lines can't be mined.
- **Bundled Hachidori**: Updated to upstream 0.4.0, adding every pitch accent in popup entries, per-kanji headword furigana, and Yomitan-style definition tags.
- **Controller Behavior**: **Toggle MPV Pause** now works outside keyboard-only mode. Connected and disabled messages now follow your `ankiConnect.behavior.notificationType` setting.
- **Dictionary Popup**: Turning keyboard-only mode off now also closes the popup.
**Fixed**
- **Controller Settings (`Alt+C`)**:
- Remaps now apply right away, and Learn works on **(D-pad)** rows.
- Clicking a row shows **Learn**, **Clear**, and **Reset** again.
- The window previews your bindings until a press, then selects the pressed controller.
- Button names are now correct on standard-layout controllers.
- **Controller Input on macOS and Windows**: One press no longer runs its action twice. On macOS, buttons now work while mpv is in front.
- **Dictionary Popup**: Keyboard and controller lookups now pause and resume playback reliably. Moving between words right after closing a popup no longer reopens it.
- **Playback**: Reloading the current file no longer leaves playback paused. The "Overlay loading" message no longer sticks or turns into a false error when a playlist advances.
- **Stats**:
- The dashboard, Vocabulary tab, and sentence search load much faster on large databases, and Trends downloads far less data.
- Trends now counts each finished episode once, and its Library totals match the selected range.
- Word totals, readings, Hide Names, and rate charts are now accurate.
- Date handling is now correct in time zones ahead of UTC.
- **AniList**: Progress sync now adds a missing show to Watching, or Completed on the final episode, when the title matches exactly or is pinned. Entries marked Completed, Paused, Dropped, or Rewatching are still never changed.
- **Anki Sentence Audio**: Generated sentence audio no longer overwrites Yomitan's word audio on default configs. If you relied on the old default, set `ankiConnect.fields.audio` to `ExpressionAudio`.
- **AppImage**: Fixed "Service Crash" notifications on quit on Ubuntu 26.04 and newer.
**Docs**
- **Keyboard and Controller Reference**: Keyboard shortcuts now list the keyboard-only `C` key, and the configuration reference points to Controller support for default button actions.
## Previous Versions
<details>
<summary>v0.21.x</summary>
<h2>v0.21.1 (2026-10-26)</h2>
**Changed**
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
- A reading density chart of known and unknown words replaces the cumulative chart.
- Yomitan lookups and mined cards now appear in their own lane below the chart.
**Fixed**
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
**Docs**
- **README**: Refreshed the anime screenshots using the current build.
<details>
<summary>Internal changes</summary>
**Internal**
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
</details>
<h2>v0.21.0 (2026-10-24)</h2>
**Added**
- **Hachidori Dictionary Backend**:
- Hachidori is now bundled as an alternative to Yomitan. Select it with `dictionaryBackend`, then restart SubMiner.
- It works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
- First-run setup builds Hachidori's Anki template from your SubMiner settings. It can also link an external Hachidori app, browser, or Docker host.
- The tray and dictionary-settings shortcut open settings for the active backend. Use `--hachidori` or `--yomitan` to open a specific one.
- **Jellyfin Subtitle Generation**: You can now generate Japanese subtitles while playing a Jellyfin episode, with both direct play and transcoded playback.
**Fixed**
- **Jellyfin**:
- `subminer jellyfin -p` now lists libraries and shows image previews when SubMiner is already running. Real errors are shown instead of an empty list.
- Watch progress from terminal playback is reported to Jellyfin even with cast discovery off.
- When `subminer jellyfin -p` starts SubMiner, the app shows its tray icon and quits when mpv closes.
- Casting on macOS now finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight.
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and older stats lines. Stats sentence search matches katakana in either width.
- **Media Timing Review**: The review opens on the first attempt instead of after a delay. Playback stays paused while it is open.
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys now work for typing in modal text fields instead of seeking mpv.
- **Stats Dashboard Mining**: Cards mined from the stats dashboard keep the selected history line and media.
- **Hyprland and Sway**: The app no longer freezes when `hyprctl` or `swaymsg` is slow or hangs.
- **Subtitle Sidebar**: The open sidebar does much less background work during playback.
- **macOS Launcher Status**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
- **Security Updates**: Updated Electron, undici, and follow-redirects to address security advisories.
**Docs**
- **Hachidori Setup**: Documented backend selection, Hachidori dictionary and Anki setup, settings shortcuts, and external host linking.
- **Jellyfin Casting**: Explained when SubMiner joins the cast menu at startup, and how casting finds mpv on each platform. Added a troubleshooting entry for casts that never start mpv.
- **Jimaku Key**: Removed environment variables that never had any effect. Set the key with `jimaku.apiKey` or `jimaku.apiKeyCommand`.
<details>
<summary>Internal changes</summary>
**Internal**
- **End-to-End Harness**: Added a headless e2e harness (`bun run test:e2e`, `bun run e2e`) with real mpv, a fixture dictionary, and a fake AnkiConnect.
- **Hachidori Fork**: The bundled Hachidori integration lives in a pinned fork submodule.
- **Dead Code Removal**:
- Removed the unused launcher Jimaku client, the mpv plugin hover-highlight module, and the unused character dictionary config keys.
- Removed 16 unused overlay IPC methods. SubMiner no longer forwards every ASS line to the overlay.
- Typecheck now runs with `noUnusedLocals`.
- **Setup Launcher Label**: First-run setup now labels a missing launcher "Not set up" instead of "Not installed".
</details>
</details>
<details>
<summary>v0.20.x</summary>
<h2>v0.20.1 (2026-10-03)</h2>
**Added**
- **YouTube Browser Window**:
- Browse YouTube with your login kept across restarts. Open it with `subminer youtube`, `subminer yt`, or tray **Browse YouTube**.
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
- Queued videos get the same Japanese subtitle setup, and the rest of the queue is kept on Windows.
- Closing the window quits a SubMiner started by `subminer youtube`.
- **Whisper Subtitles for YouTube**:
- Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions.
- The video stays paused while Whisper transcribes, and the subtitle generation modal shows progress.
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including Windows stream playback.
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay, including on Hyprland.
- **Run Commands from Session Help**: During playback, press `Enter` or double-click a row in session help to run that command.
**Changed**
- **Release Assets**: Releases no longer include package-size reports.
**Fixed**
- **Hyprland Overlay**: The overlay stays clickable after mpv goes fullscreen with `mpv.backend: x11`, and it no longer interrupts mpv while mpv enters fullscreen.
- **mpv Key Bindings**: Bindings from mpv such as `9`/`0` for volume now work while the overlay has focus, even if the overlay loaded before mpv connected.
- **Jellyfin**:
- The Japanese subtitle track is selected as soon as it downloads, without waiting for the other tracks.
- Japanese and English tracks are auto-selected on episodes whose embedded subtitles are images.
- Timing review previews and mined card audio use the audio track you are playing.
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and original subtitle boundaries no longer cover the waveform. The preview cursor no longer stutters on macOS with Bluetooth headphones.
- **YouTube Subtitles**: Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track.
- **Stats**:
- Mined cards are counted once in session and lifetime stats.
- Word and kanji dates and the new-words charts stay accurate after deleting sessions. Existing data is repaired in the background.
- Overview totals, charts, and the activity calendar refresh after deleting sessions.
- **Yomitan Data Safety**: Electron is updated to 43.7.7. SubMiner now refuses to load your Yomitan data with an unsupported or downgraded runtime. Automatic character dictionary changes stop if Yomitan suddenly reports no dictionaries.
- **AppImage in Sandboxes**: The AppImage now opens its window when launched in a sandbox such as `firejail --appimage`.
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
**Docs**
- **Docs Site**: The docs site is shorter and easier to scan, with a key and default table for each config block. Outdated pages were corrected, and the MeCab install command for Arch Linux is fixed.
- **Stats Docs**: The docs now cover the Overview refresh after deleting sessions and how discovery dates are kept after old sessions are pruned.
<details>
<summary>Internal changes</summary>
**Internal**
- **Release Notes**: Release notes and `CHANGELOG.md` are now generated as short items per feature or area.
- **Dev Profile**: Development launches use a separate `SubMiner-dev` profile unless production-profile access is requested.
- **Dependencies**: undici is updated to 7.29.1 to clear the security advisories that were blocking CI.
- **Package Checks**: CI no longer compares package sizes. Package content checks still run.
</details>
<h2>v0.20.0 (2026-09-23)</h2>
**Added**
- **Japanese Subtitle Generation**:
@@ -121,7 +299,7 @@
</details>
## Previous Versions
</details>
<details>
<summary>v0.19.x</summary>
+124 -79
View File
@@ -52,7 +52,7 @@ SubMiner watches the config file while running. When it changes, live settings a
These apply live:
- `subtitleStyle`, `subtitleSidebar`, `subtitleSelection`, `keybindings`, `shortcuts`
- `subtitleStyle`, `subtitleSidebar`, `subtitleSelection`, `subtitleFilter`, `keybindings`, `shortcuts`
- `logging.level`, `logging.rotation`, `logging.files`
- `secondarySub.defaultMode`, `youtube.primarySubLanguages`, `youtube.subtitleSource`
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`, `stats.toggleKey`, `stats.markWatchedKey`
@@ -67,6 +67,12 @@ Everything else needs a restart.
## Core settings
### Dictionary backend
`dictionaryBackend` accepts `"yomitan"` or `"hachidori"`. The default is defined in the [generated configuration example](/config.example.jsonc). Restart SubMiner after changing it. The tray shows the selected backend's settings, and the global dictionary-settings shortcut follows the same selection.
Each backend stores its own dictionaries and mining settings. `yomitan.externalProfilePath` applies only to Yomitan. See [Hachidori setup](./usage.md#hachidori-setup) before switching an existing installation.
### Logging
Log files are named by date (`app-YYYY-MM-DD.log`, `launcher-...`, `mpv-...`). Log export writes a sanitized copy and leaves the originals alone.
@@ -100,6 +106,15 @@ Overlay notifications are also kept in a session-only history panel. Toggle it w
Mining and startup status notifications use `ankiConnect.behavior.notificationType` (see [AnkiConnect](#ankiconnect)).
### Plugins
`plugins` enables [plugins](/plugins) from the `plugins/` folder next to this config file. Each key is a plugin folder name. Changes apply after a restart.
| Key | Default | What it does |
| ----------------------- | ------- | -------------------------------------- |
| `plugins.<id>.enabled` | `false` | Load the plugin at startup |
| `plugins.<id>.settings` | `{}` | Passed to the plugin as `ctx.settings` |
### Auto-start overlay
When mpv is started by SubMiner or the `subminer` launcher, the launcher passes these settings to the bundled mpv plugin. There is no separate plugin config file. `mpv.autoStartSubMiner` and `mpv.pauseUntilOverlayReady` (see [MPV launcher](#mpv-launcher)) control the background start and the initial pause.
@@ -160,30 +175,31 @@ Controls how primary and secondary subtitles look and which annotations they sho
}
```
| Key | Default | What it does |
| ------------------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------- |
| `subtitleStyle.primaryDefaultMode` | `"visible"` | Primary bar at startup: `hidden`, `visible`, or `hover` |
| `subtitleStyle.css` | see example | CSS for primary subtitles (font, size `35px`, color, shadow, and so on) |
| `subtitleStyle.secondary.css` | see example | CSS for secondary subtitles (size `24px`) |
| `subtitleStyle.preserveLineBreaks` | `false` | Keep line breaks as mpv shows them instead of one line |
| `subtitleStyle.autoPauseVideoOnHover` | `true` | Pause while the mouse is over subtitle text |
| `subtitleStyle.autoPauseVideoOnYomitanPopup` | `true` | Pause while a Yomitan popup is open |
| `subtitleStyle.primaryVisibleOnYomitanPopup` | `true` | In hover mode, keep the primary bar visible while a popup is open |
| `subtitleStyle.knownWordColor` | `#a6da95` | Known-word highlight color |
| `subtitleStyle.knownWordMaturityColors` | see example | `new`, `learning`, `young`, `mature` colors, used when `ankiConnect.knownWords.maturityEnabled` is on |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | N+1 target word color |
| `subtitleStyle.enableJlpt` | `false` | Underline words by JLPT level |
| `subtitleStyle.jlptColors` | see example | Underline colors for `N1` to `N5` |
| `subtitleStyle.nameMatchEnabled` | `false` | Sync the character dictionary and color character names |
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small character portraits next to matched names |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Character-name color |
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Color words by frequency rank |
| `subtitleStyle.frequencyDictionary.sourcePath` | `""` | Folder with `term_meta_bank_*.json` files. Empty searches the default locations |
| `subtitleStyle.frequencyDictionary.topX` | `10000` | Only color words ranked at or below this |
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `single` (one color) or `banded` (five colors, common to rare) |
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | Look up by `headword` (dictionary form) or `surface` (text as shown) |
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for `single` mode |
| `subtitleStyle.frequencyDictionary.bandedColors` | see example | Five colors for `banded` mode |
| Key | Default | What it does |
| ------------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `subtitleStyle.primaryDefaultMode` | `"visible"` | Primary bar at startup: `hidden`, `visible`, or `hover` |
| `subtitleStyle.css` | see example | CSS for primary subtitles (font, size `35px`, color, shadow, and so on) |
| `subtitleStyle.secondary.css` | see example | CSS for secondary subtitles (size `24px`) |
| `subtitleStyle.preserveLineBreaks` | `false` | Keep line breaks as mpv shows them instead of one line |
| `subtitleStyle.autoPauseVideoOnHover` | `true` | Pause while the mouse is over subtitle text |
| `subtitleStyle.autoPauseVideoOnYomitanPopup` | `true` | Pause while a Yomitan popup is open |
| `subtitleStyle.autoPauseVideoOnKeyboardMode` | `false` | Pause while keyboard-only mode is on (keyboard or controller toggle). Leaving resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, stays as it is |
| `subtitleStyle.primaryVisibleOnYomitanPopup` | `true` | In hover mode, keep the primary bar visible while a popup is open |
| `subtitleStyle.knownWordColor` | `#a6da95` | Known-word highlight color |
| `subtitleStyle.knownWordMaturityColors` | see example | `new`, `learning`, `young`, `mature` colors, used when `ankiConnect.knownWords.maturityEnabled` is on |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | N+1 target word color |
| `subtitleStyle.enableJlpt` | `false` | Underline words by JLPT level |
| `subtitleStyle.jlptColors` | see example | Underline colors for `N1` to `N5` |
| `subtitleStyle.nameMatchEnabled` | `false` | Sync the character dictionary and color character names |
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small character portraits next to matched names |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Character-name color |
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Color words by frequency rank |
| `subtitleStyle.frequencyDictionary.sourcePath` | `""` | Folder with `term_meta_bank_*.json` files. Empty searches the default locations |
| `subtitleStyle.frequencyDictionary.topX` | `10000` | Only color words ranked at or below this |
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `single` (one color) or `banded` (five colors, common to rare) |
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | Look up by `headword` (dictionary form) or `surface` (text as shown) |
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for `single` mode |
| `subtitleStyle.frequencyDictionary.bandedColors` | see example | Five colors for `banded` mode |
Two CSS custom properties style the hovered word: `--subtitle-hover-token-color` (`#f4dbd6`) and `--subtitle-hover-token-background-color` (`transparent`). Set them inside `subtitleStyle.css`.
@@ -205,6 +221,29 @@ Sidebar custom properties: `--subtitle-sidebar-max-width` (`420px`), `--subtitle
If `embedded` layout places the video oddly on your system, switch back to `overlay`.
### Subtitle filters {#subtitle-filters}
Removes unwanted text from primary subtitles, such as speaker names, readings and sound descriptions. Filtering happens as the line arrives, so the overlay, sidebar, mined sentences and stats all see the filtered text. A line left with no words is hidden. In the settings window, find it under **Behavior → Subtitle Filters**.
| Key | Default | What it does |
| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------ |
| `subtitleFilter.presets.parentheses` | `false` | Remove `(…)` and `(…)` anywhere in a line. A line wrapped entirely in parentheses (inner thoughts) is kept |
| `subtitleFilter.presets.brackets` | `false` | Remove `[…]`, `[…]`, `【…】` and `〔…〕`, such as sound effects |
| `subtitleFilter.presets.speakerPrefix` | `false` | Remove a short name before a colon at the start of a line, as in `山田:` |
| `subtitleFilter.presets.musicSymbols` | `false` | Remove `♪`, `♫` and `♬` |
| `subtitleFilter.customRules` | `[]` | Your own regex rules, applied in order after the presets |
Each custom rule is `{ "name", "pattern", "replacement", "enabled" }`. Patterns are JavaScript regular expressions compiled with the `gmu` flags, so `^` and `$` match each line. An empty `replacement` removes the match.
```jsonc
{
"subtitleFilter": {
"presets": { "parentheses": true },
"customRules": [{ "name": "Join lines", "pattern": "\\n", "replacement": "", "enabled": true }],
},
}
```
### Subtitle position
You can also drag subtitles with `Right-click + drag` while watching.
@@ -262,7 +301,7 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`. Scroll wheel keys are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`.
- `command` is any mpv JSON IPC command array. Set it to `null` to disable a default.
- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, and `__runtime-option-cycle:<id>[:next|prev]`.
- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, `__runtime-option-cycle:<id>[:next|prev]`, and `__plugin-action:<plugin id>/<action>` (see [Plugins](/plugins)).
- Unused single-key, mouse button, and scroll wheel bindings from your mpv config also work in the overlay. Your SubMiner bindings win on conflicts.
### Shortcuts configuration
@@ -275,21 +314,26 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
### Controller support
Gamepad input for the overlay, through the browser Gamepad API. It only works while keyboard-only mode is on. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are on [Keyboard shortcuts](/shortcuts).
Gamepad input for the overlay, through the browser Gamepad API. Most actions only work while keyboard-only mode is on; the toggle for that mode, the secondary subtitle hold (`controller.bindings.holdSecondarySub`), pause (`toggleMpvPause`) and the subtitle line actions (`previousSubtitle`, `nextSubtitle`, `replaySubtitle`, `playNextSubtitle`) work either way. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are in [Controller support](/usage#controller-support).
| Key | Default | What it does |
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------- |
| `controller.enabled` | `false` | Enable controller support. The `Alt+C` and `Alt+Shift+C` modals stay closed while off |
| `controller.smoothScroll` | `true` | Smooth popup scrolling |
| `controller.scrollPixelsPerSecond` | `900` | Popup scroll speed |
| `controller.horizontalJumpPixels` | `160` | Popup page-jump distance |
| `controller.stickDeadzone` | `0.2` | Stick deadzone |
| `controller.triggerInputMode` | `"auto"` | `auto`, `digital`, or `analog`. Use `analog` if your L2/R2 report analog values |
| `controller.triggerDeadzone` | `0.5` | Trigger threshold for `auto` and `analog` |
| `controller.repeatDelayMs` | `320` | Delay before a held button repeats |
| `controller.repeatIntervalMs` | `120` | Repeat interval for held buttons |
| Key | Default | What it does |
| ---------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `controller.enabled` | `false` | Enable controller support. The `Alt+C` and `Alt+Shift+C` modals stay closed while off |
| `controller.smoothScroll` | `true` | Smooth popup scrolling |
| `controller.scrollPixelsPerSecond` | `900` | Popup scroll speed |
| `controller.horizontalJumpPixels` | `160` | Popup page-jump distance |
| `controller.stickDeadzone` | `0.2` | Stick deadzone |
| `controller.triggerInputMode` | `"auto"` | `auto`, `digital`, or `analog`. Use `analog` if your L2/R2 report analog values |
| `controller.triggerDeadzone` | `0.5` | Trigger threshold for `auto` and `analog` |
| `controller.faceButtonLabels` | `"xbox"` | Face button style: `xbox`, `nintendo` (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or `playstation` (╳ ○ □ △). **Button Style** in `Alt+C` sets it per controller; this is the default for the others |
| `controller.repeatDelayMs` | `320` | Delay before a held button repeats |
| `controller.repeatIntervalMs` | `120` | Repeat interval for held buttons |
Bindings are set with `Alt+C` learn mode, which saves them per controller.
Bindings, including the media timing review and duplicate card dialog layouts, are set with `Alt+C` learn mode, which saves them per controller. Learn can also record a [combo](/usage#controller-support), saved on the binding as an optional `hold` input in the same form:
```jsonc
"nextAudio": { "kind": "button", "buttonIndex": 15, "hold": { "kind": "button", "buttonIndex": 4 } }
```
## Anki integration
@@ -323,15 +367,15 @@ Creates and updates Anki cards with sentence, audio, and screenshot. Needs the [
**Fields**
| Key | Default | What it does |
| ------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `ankiConnect.fields.word` | `"Expression"` | Word field |
| `ankiConnect.fields.audio` | `"ExpressionAudio"` | Field that receives sentence audio. Set a separate field such as `SentenceAudio` so it does not overwrite Yomitan's word audio |
| `ankiConnect.fields.wordAudio` | `"ExpressionAudio"` | Existing word-audio field, read only to time animated images |
| `ankiConnect.fields.image` | `"Picture"` | Screenshot field |
| `ankiConnect.fields.sentence` | `"Sentence"` | Sentence field |
| `ankiConnect.fields.miscInfo` | `"MiscInfo"` | Metadata field. `null` disables |
| `ankiConnect.metadata.pattern` | `"[SubMiner] %f (%t)"` | MiscInfo template: `%f` filename, `%F` filename with extension, `%t` time, `%T` time with ms, `<br>` newline |
| Key | Default | What it does |
| ------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `ankiConnect.fields.word` | `"Expression"` | Word field |
| `ankiConnect.fields.audio` | `"SentenceAudio"` | Field that receives sentence audio. Keep it separate from Yomitan's word-audio field so it does not overwrite that audio |
| `ankiConnect.fields.wordAudio` | `"ExpressionAudio"` | Existing word-audio field, read only to time animated images |
| `ankiConnect.fields.image` | `"Picture"` | Screenshot field |
| `ankiConnect.fields.sentence` | `"Sentence"` | Sentence field |
| `ankiConnect.fields.miscInfo` | `"MiscInfo"` | Metadata field. `null` disables |
| `ankiConnect.metadata.pattern` | `"[SubMiner] %f (%t)"` | MiscInfo template: `%f` filename, `%F` filename with extension, `%t` time, `%T` time with ms, `<br>` newline |
**Media**
@@ -431,13 +475,14 @@ When SubMiner fills the sentence on a word card, it sets one card-type flag and
Search and download Japanese subtitles from [Jimaku](https://jimaku.cc). See [Jimaku integration](/jimaku-integration).
| Key | Default | What it does |
| --------------------------- | --------------------- | ---------------------------------------------------------- |
| `jimaku.apiKey` | `""` | API key. Optional, but raises your rate limit |
| `jimaku.apiKeyCommand` | `""` | Shell command that prints the key. Use instead of `apiKey` |
| `jimaku.apiBaseUrl` | `"https://jimaku.cc"` | API base URL |
| `jimaku.languagePreference` | `"ja"` | Preferred language: `ja`, `en`, or `none` |
| `jimaku.maxEntryResults` | `10` | Maximum search results |
| Key | Default | What it does |
| ------------------------------------------- | --------------------- | --------------------------------------------------------------------------------- |
| `jimaku.apiKey` | `""` | API key. Optional, but raises your rate limit |
| `jimaku.apiKeyCommand` | `""` | Shell command that prints the key. Use instead of `apiKey` |
| `jimaku.apiBaseUrl` | `"https://jimaku.cc"` | API base URL |
| `jimaku.languagePreference` | `"ja"` | Preferred language: `ja`, `en`, or `none` |
| `jimaku.maxEntryResults` | `10` | Maximum search results |
| `jimaku.autoOpenWhenPrimarySubtitleMissing` | `false` | Pause and open the Jimaku modal when a local video has no Japanese subtitle track |
### TsukiHime
@@ -514,18 +559,18 @@ In external-profile mode, SubMiner only reads the profile. It does not open its
Log in to a Jellyfin server, browse libraries, and play or cast to SubMiner. Login tokens are stored encrypted, not in this file. See [Jellyfin integration](/jellyfin-integration).
| Key | Default | What it does |
| ----------------------------------- | -------------------------------- | ----------------------------------------------- |
| `jellyfin.enabled` | `false` | Enable Jellyfin |
| `jellyfin.serverUrl` | `""` | Server URL, for example `http://localhost:8096` |
| `jellyfin.username` | `""` | Default username for `subminer jellyfin -l` |
| `jellyfin.remoteControlEnabled` | `true` | Let Jellyfin apps cast to SubMiner |
| `jellyfin.remoteControlAutoConnect` | `true` | Connect the cast session on startup |
| `jellyfin.autoAnnounce` | `false` | Announce SubMiner as a cast target on connect |
| `jellyfin.pullPictures` | `false` | Fetch posters for launcher pickers |
| `jellyfin.iconCacheDir` | `"/tmp/subminer-jellyfin-icons"` | Poster cache folder |
| `jellyfin.directPlayPreferred` | `true` | Try direct play before transcoding |
| `jellyfin.transcodeVideoCodec` | `"h264"` | Codec requested when transcoding |
| Key | Default | What it does |
| ----------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------- |
| `jellyfin.enabled` | `false` | Enable Jellyfin |
| `jellyfin.serverUrl` | `""` | Server URL, for example `http://localhost:8096` |
| `jellyfin.username` | `""` | Default username for `subminer jellyfin -l` |
| `jellyfin.remoteControlEnabled` | `true` | Let Jellyfin apps cast to SubMiner |
| `jellyfin.remoteControlAutoConnect` | `true` | Connect the cast session on startup (also needs `startupWarmups.jellyfinRemoteSession`) |
| `jellyfin.autoAnnounce` | `false` | Announce SubMiner as a cast target on connect |
| `jellyfin.pullPictures` | `false` | Fetch posters for launcher pickers |
| `jellyfin.iconCacheDir` | `"/tmp/subminer-jellyfin-icons"` | Poster cache folder |
| `jellyfin.directPlayPreferred` | `true` | Try direct play before transcoding |
| `jellyfin.transcodeVideoCodec` | `"h264"` | Codec requested when transcoding |
### Discord rich presence
@@ -579,18 +624,18 @@ A local web dashboard at `http://127.0.0.1:<serverPort>`, also available as an o
Settings for mpv instances that SubMiner starts, and for the bundled mpv plugin. See [mpv plugin](/mpv-plugin).
| Key | Default | What it does |
| ---------------------------- | ----------------- | --------------------------------------------------------------------------- |
| `mpv.executablePath` | `""` | Path to `mpv.exe` on Windows. Empty checks `SUBMINER_MPV_PATH`, then `PATH` |
| `mpv.launchMode` | `"normal"` | Window state: `normal`, `maximized`, or `fullscreen` |
| `mpv.profile` | `""` | mpv profile to pass. Combined with a launcher `--profile` if both are set |
| `mpv.socketPath` | platform-specific | mpv IPC socket. See the warning under [Config file](#configuration-file) |
| `mpv.backend` | `"auto"` | Window tracking: `auto`, `hyprland`, `sway`, `x11`, `macos`, `windows` |
| `mpv.autoStartSubMiner` | `true` | Start SubMiner in the background when mpv loads a file |
| `mpv.pauseUntilOverlayReady` | `true` | Keep mpv paused until subtitles are ready, up to 30 seconds |
| `mpv.subminerBinaryPath` | `""` | SubMiner app path for the plugin. Empty uses the detected path |
| `mpv.aniskipEnabled` | `true` | Detect intros with AniSkip and show a skip prompt |
| `mpv.aniskipButtonKey` | `"TAB"` | mpv key that skips the intro while the prompt is shown |
| Key | Default | What it does |
| ---------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------- |
| `mpv.executablePath` | `""` | Full path to mpv. Empty auto-discovers it ([lookup order](/jellyfin-integration#casting-from-jellyfin)) |
| `mpv.launchMode` | `"normal"` | Window state: `normal`, `maximized`, or `fullscreen` |
| `mpv.profile` | `""` | mpv profile to pass. Combined with a launcher `--profile` if both are set |
| `mpv.socketPath` | platform-specific | mpv IPC socket. See the warning under [Config file](#configuration-file) |
| `mpv.backend` | `"auto"` | Window tracking: `auto`, `hyprland`, `sway`, `x11`, `macos`, `windows` |
| `mpv.autoStartSubMiner` | `true` | Start SubMiner in the background when mpv loads a file |
| `mpv.pauseUntilOverlayReady` | `true` | Keep mpv paused until subtitles are ready, up to 30 seconds |
| `mpv.subminerBinaryPath` | `""` | SubMiner app path for the plugin. Empty uses the detected path |
| `mpv.aniskipEnabled` | `true` | Detect intros with AniSkip and show a skip prompt |
| `mpv.aniskipButtonKey` | `"TAB"` | mpv key that skips the intro while the prompt is shown |
### YouTube playback settings
-3
View File
@@ -175,9 +175,6 @@ Run `make help` for the full list.
| `SUBMINER_MPV_PLUGIN_PATH` | mpv plugin directory the launcher injects |
| `SUBMINER_LOG_LEVEL` | App log level (`debug`, `info`, `warn`, `error`) |
| `SUBMINER_MPV_LOG` | Shared mpv/app log file path |
| `SUBMINER_JIMAKU_API_KEY` | Jimaku API key for launcher subtitle downloads |
| `SUBMINER_JIMAKU_API_KEY_COMMAND` | Command that prints the Jimaku API key |
| `SUBMINER_JIMAKU_API_BASE_URL` | Jimaku API base URL |
| `SUBMINER_JELLYFIN_ACCESS_TOKEN` | Jellyfin access token, used before the stored encrypted session |
| `SUBMINER_JELLYFIN_USER_ID` | Jellyfin user ID |
| `SUBMINER_SKIP_MACOS_HELPER_BUILD` | Set to `1` to skip the macOS helper build during `bun run build` |
+30 -9
View File
@@ -42,6 +42,8 @@ To share stats and watch history between machines, use [`subminer sync <host>`](
Recent sessions, a streak calendar, watch-time history, and totals for completed episodes and shows.
Deleting a session or session group refreshes the Overview totals, charts, calendar, and vocabulary summary automatically. If a refresh fails, Overview keeps the previous totals visible and offers **Retry**.
![Stats Overview](/screenshots/stats-overview.png)
### Library
@@ -62,22 +64,37 @@ The same show can end up on several cards when release names disagree. To fix th
### Trends
Charts for watch time, cards, words, and sessions per day or month, running totals, efficiency (words per minute, cards per hour), and viewing patterns by weekday and hour. Each chart has its own date range and grouping.
Pick a date range (or set **Custom** dates), group by day, week, or month, and the whole page follows. Your choices are remembered.
- **Summary**: totals for the range, compared with the period just before it.
- **Activity**: watch time, cards, words, and finished episodes. Click a bar to open that period's sessions.
- **Learning**: new words per 1,000 words seen, and how common those new words are. Conjugations of a word count once, and names are left out.
- **Efficiency**: words per minute, cards per hour, and lookups per 100 words. Daily views use a rolling 7 days, and gaps mean too little data to rate.
- **Running total**: one chart with a switcher for each metric, climbing from zero at the start of the range.
- **Patterns**: a day and hour heatmap of when you watch.
- **Titles**: a timeline of what you watched when, and a sortable summary table. Click a title, or use the filter box, to narrow the page to it. **↗** opens the title.
An episode counts as finished once, on the last day you watched it.
![Stats Trends](/screenshots/stats-trends.png)
### Sessions
Session history with new-word activity and pause, seek, and card markers. The **↗** button on a row opens that show's detail view.
Session history. Expand a session for its pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words over the session with Yomitan lookup ticks and clickable card-mined markers underneath. The **↗** button on a row opens that show's detail view.
![Stats Sessions](/screenshots/stats-sessions.png)
### Vocabulary
Unique words and kanji you have seen, new words per day, frequency rank tables with Hide Known and Hide Kana filters, and a kanji breakdown. Click a word to see every line it appeared in.
Words are counted as dictionary words, so a word's conjugations count once. Names are left out unless you turn off **Hide names**.
- **Exclusions** hides words from every vocabulary view. You can restore them from the same dialog.
- **Duplicates** cleans up lines repeated by karaoke openings and animated signs (see [Repeated lines](#repeated-lines)).
- **Summary**: words seen, how many are in Anki, new words this week, and kanji seen.
- **Known Words by Frequency**: how much of each frequency band you already know.
- **Vocabulary Growth**: your running total of words seen.
- **Words**: one table for every word you have seen. **Worth mining** lists common words you haven't mined, most seen first. **All words**, **Across titles**, and **Newest** are the other views. Sort by any column, or filter by known words, names, kana, and part of speech. Searching looks through everything, including conjugated forms and readings. Click a word for its forms, where it appeared, and example lines you can mine from. **Hide** excludes a word.
- **Kanji**: every kanji you have seen, shaded by how often. Dashed kanji are not in any word you know.
**Exclusions** at the bottom of the page lists hidden words so you can restore them. **Clean up duplicates** removes lines repeated by karaoke openings and animated signs (see [Repeated lines](#repeated-lines)).
![Stats Vocabulary](/screenshots/stats-vocabulary.png)
@@ -87,19 +104,21 @@ Searches the primary subtitle lines and titles in your history. **Search by head
## Mining from the dashboard
Search results and the Vocabulary word panel can create cards from past lines, as long as the source video file is still available:
Search results and the Vocabulary word panel can create cards from past lines, as long as the source video file is still available. Lines from a stream, such as Jellyfin or YouTube, have no file to cut clips from, so they say so instead of offering mining. Lines you can mine are listed first. The word panel counts how many of a word's lines this affects, and the word table marks words with only streamed lines as **Stream only**.
- **Mine Word**: full Yomitan lookup for the word, plus sentence, audio, and image.
- **Mine Word**: looks up the word with the selected dictionary backend (Yomitan or Hachidori), plus sentence, audio, and image. The history line you picked is the card's sentence, even while mpv plays another line. Hachidori uses its own Anki template, dictionary aliases, and frequency data.
- **Mine Sentence**: a sentence card with `IsSentenceCard` set, for Lapis and Kiku note types.
- **Mine Audio**: an audio card with `IsAudioCard` set.
Word and audio mining appear only when the word occurs in the sentence. All three use your `ankiConnect` deck, note type, fields, and media settings. Anki must be running, and Mine Word needs Yomitan dictionaries.
Word and audio mining appear only when the word occurs in the sentence. All three use your `ankiConnect` deck, note type, fields, and media settings. Anki must be running, and Mine Word needs dictionaries in the selected backend.
Dashboard cards also get the `SubMiner::Stats` tag. With the Anki proxy off, SubMiner uses it to keep the chosen history line when polling picks up the card.
## Repeated lines
Karaoke openings and animated signs repeat the same text once per frame. SubMiner collapses these as it records, so one lyric is stored once. Stats recorded before that can hold hundreds of copies and skew Top Repeated Words.
To clean them, use **Duplicates** in the Vocabulary tab: pick a time window, **Scan** to preview, then **Clean Up**. Or from the terminal:
To clean them, use **Clean up duplicates** in the Vocabulary tab: pick a time window, **Scan** to preview, then **Clean Up**. Or from the terminal:
```bash
subminer stats cleanup --duplicate-lines --dry-run --lookback-days 30
@@ -122,4 +141,6 @@ Leave out `--lookback-days` to scan all history. Word and kanji counts are corre
By default SubMiner keeps everything. To limit history, set `immersionTracking.retentionPreset` to `minimal`, `balanced`, or `deep-history`, or set the `immersionTracking.retention.*Days` values yourself (`0` keeps all). Lifetime totals and vocabulary counts are stored separately and stay exact when old sessions are pruned.
Deleting retained sessions preserves earlier word and kanji discovery dates from pruned history. New occurrences record observation time so deletion can update their dates exactly. Older history used write time, so an earlier discovery date is preserved when its origin cannot be determined.
See [Immersion tracking](/configuration#immersion-tracking) and [Stats dashboard](/configuration#stats-dashboard) in the config reference for every option and default.
+2 -2
View File
@@ -187,7 +187,7 @@ First-run setup can install the `subminer` command for you.
xattr -d com.apple.quarantine /Applications/SubMiner.app
```
3. Open **System Settings > Privacy & Security > Accessibility** and enable SubMiner (add it if it is missing). The overlay cannot follow the mpv window without this.
3. Open **System Settings > Privacy & Security > Accessibility** and enable SubMiner (add it if it is missing). On macOS 27 this list is named **Device Control and Data Access**. SubMiner uses it to track the mpv window. Without it, SubMiner falls back to a basic window lookup that cannot tell when mpv is minimized.
First-run setup can install the `subminer` command for you.
@@ -226,7 +226,7 @@ make deps
make build-macos
```
The `.dmg` and `.zip` land in `release/`. For an unsigned local build, run `bun run build:mac:unsigned`.
The `.dmg` and `.zip` land in `dist/release/`. For an unsigned local build, run `bun run build:mac:unsigned`.
</details>
+13 -11
View File
@@ -14,7 +14,7 @@ SubMiner stores an encrypted session token, not your password, and turns the int
## Casting from Jellyfin
After you sign in, SubMiner connects to Jellyfin at startup and shows up in the cast ("Play on") menu under your computer's hostname. To connect for the current session only, tick **Jellyfin Discovery** in the tray menu.
After you sign in, tick **Jellyfin Discovery** in the tray menu. SubMiner then shows up in the cast ("Play on") menu under your computer's hostname until it quits. To connect every time SubMiner starts, turn on `startupWarmups.jellyfinRemoteSession` and leave `remoteControlAutoConnect` on.
1. In the Jellyfin web or mobile app, start playing an episode.
2. Open the cast menu and pick your computer.
@@ -23,7 +23,9 @@ SubMiner starts mpv if it is not already running. Pause, seek, stop, and track c
SubMiner selects a Japanese subtitle track automatically and resets mpv's subtitle delay to zero. It direct-plays files when it can and asks Jellyfin to transcode the rest.
On Windows, casting finds mpv through `mpv.executablePath`, then `SUBMINER_MPV_PATH`, then `PATH`. An invalid `mpv.executablePath` stops mpv from starting.
If an episode has no Japanese subtitles, you can [generate them with Whisper](/subtitle-generation#generating-from-the-overlay) while it plays.
Casting finds mpv through `mpv.executablePath`, then `SUBMINER_MPV_PATH`, then `PATH`. On macOS it then checks `/opt/homebrew/bin/mpv`, `/usr/local/bin/mpv`, and `/Applications/mpv.app`, because an app opened from Finder, the Dock, or Spotlight does not get your shell's `PATH`. If mpv lives somewhere else, set `mpv.executablePath` to its full path (`command -v mpv` prints it). An invalid `mpv.executablePath` stops mpv from starting.
## Playing from the terminal
@@ -34,20 +36,20 @@ subminer jellyfin -p # fzf picker; `jf` is an alias for `jellyfin`
subminer -R jellyfin -p # rofi picker
```
Sign in first. See [Launcher script](/launcher-script) for the other `jellyfin` subcommands.
Sign in first. Playback resumes from Jellyfin's saved position, and watch progress syncs back the same way it does for casting, whether or not discovery is on. If SubMiner is already running, the picker uses it and leaves it running. Otherwise the launcher starts SubMiner and quits it when you close mpv, the same as playing a local file. See [Launcher script](/launcher-script) for the other `jellyfin` subcommands.
## Options
All options are under **Settings > Integrations > Jellyfin**, or `jellyfin` in `config.jsonc`. See [Configuration](/configuration#jellyfin) for the full list and defaults.
| Key | What it does |
| -------------------------- | -------------------------------------------------------------------- |
| `enabled` | Turns the integration on. Set for you when you sign in. |
| `serverUrl` | Your Jellyfin server. Filled in when you sign in. |
| `remoteControlEnabled` | Lets SubMiner act as a cast target. |
| `remoteControlAutoConnect` | Connects at startup. Turn off to start discovery from the tray. |
| `autoAnnounce` | Re-announces the device on connect. Try it if SubMiner appears late. |
| `transcodeVideoCodec` | Video codec requested when Jellyfin transcodes. |
| Key | What it does |
| -------------------------- | ---------------------------------------------------------------------- |
| `enabled` | Turns the integration on. Set for you when you sign in. |
| `serverUrl` | Your Jellyfin server. Filled in when you sign in. |
| `remoteControlEnabled` | Lets SubMiner act as a cast target. |
| `remoteControlAutoConnect` | Connects at startup when `startupWarmups.jellyfinRemoteSession` is on. |
| `autoAnnounce` | Re-announces the device on connect. Try it if SubMiner appears late. |
| `transcodeVideoCodec` | Video codec requested when Jellyfin transcodes. |
For headless setups, `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID` supply a session without the sign-in window. Treat the token store and `config.jsonc` as secrets.
+22 -7
View File
@@ -37,17 +37,32 @@ The file is saved next to the video (or to a temp directory for streams) and loa
You can also open the modal with `subminer app --open-jimaku`, or change the shortcut with `shortcuts.openJimaku`.
### Open automatically when a video has no Japanese subtitles
Set `jimaku.autoOpenWhenPrimarySubtitleMissing` to `true` to skip the shortcut. When a local video loads without a subtitle track in `youtube.primarySubLanguages` (Japanese by default), SubMiner pauses playback about a second after the video loads and opens the Jimaku modal, so you can pick a subtitle before watching. Playback stays paused while the modal is open and resumes when it closes, whether or not you picked a subtitle. On a cold start it resumes once subtitle processing is ready, as usual, and a pause you made yourself before the modal opened is kept.
A single unlabeled sidecar file (such as `episode.srt` next to the video) counts as a Japanese track, matching how SubMiner auto-selects local subtitles. An embedded track with no language tag does not, so the modal still opens for it. Audio files, streams, YouTube, and Jellyfin playback are not checked.
```jsonc
{
"jimaku": {
"autoOpenWhenPrimarySubtitleMissing": true,
},
}
```
The file name parser understands `S01E03`, `1x03`, `E03`, `EP03`, and `Title - 03 -` patterns, and reads the season from a parent folder such as `Season 2`. It ignores bracket tags like `[SubGroup]` and year tags like `(2024)`.
## Options
| Key | What it does |
| --------------------------- | ------------------------------------------------------------------- |
| `jimaku.apiKey` | API key in plain text. |
| `jimaku.apiKeyCommand` | Shell command that prints the API key. |
| `jimaku.languagePreference` | Sorts files tagged with this language first: `ja`, `en`, or `none`. |
| `jimaku.maxEntryResults` | Maximum entries per search. |
| `jimaku.apiBaseUrl` | API address. Change only for a mirror. |
| Key | What it does |
| ------------------------------------------- | -------------------------------------------------------------------- |
| `jimaku.apiKey` | API key in plain text. |
| `jimaku.apiKeyCommand` | Shell command that prints the API key. |
| `jimaku.languagePreference` | Sorts files tagged with this language first: `ja`, `en`, or `none`. |
| `jimaku.maxEntryResults` | Maximum entries per search. |
| `jimaku.apiBaseUrl` | API address. Change only for a mirror. |
| `jimaku.autoOpenWhenPrimarySubtitleMissing` | Pauses and opens the modal when a local video has no Japanese track. |
See [Configuration](/configuration#jimaku) for defaults.
+1 -1
View File
@@ -124,7 +124,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
`log-level` sets SubMiner's log verbosity. Do not use `--debug` for this; it turns on the app's dev mode.
The plugin also handles messages the SubMiner app sends it (`subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, `subminer-reload-session-bindings`). You do not need to send these yourself. The AniSkip messages are listed on the [AniSkip page](/aniskip-integration#triggering-from-mpv).
The plugin also handles messages the SubMiner app sends it (`subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, `subminer-overlay-loading-dismissed`, `subminer-reload-session-bindings`). You do not need to send these yourself. The AniSkip messages are listed on the [AniSkip page](/aniskip-integration#triggering-from-mpv).
## Auto-start behavior
+188
View File
@@ -0,0 +1,188 @@
# Plugins
Plugins are JavaScript files that SubMiner loads at startup. A plugin can react to playback, run code on a timer, add actions you bind to keys, run programs, and show results in an overlay panel, all without rebuilding SubMiner.
::: warning Only run plugins you trust
Plugins run inside SubMiner with the same access to your files and system as SubMiner itself. There is no sandbox.
:::
These are SubMiner app plugins. The Lua script that runs inside mpv is the separate [mpv plugin](/mpv-plugin).
## Install a plugin
1. Put the plugin in its own folder in `plugins/` next to your [config file](/configuration#configuration-file), for example `~/.config/SubMiner/plugins/ai-explain/`. The folder name is the plugin id.
2. Enable it in `config.jsonc`. `settings` is passed to the plugin as-is:
```jsonc
{
"plugins": {
"ai-explain": { "enabled": true, "settings": { "reasoningEffort": "medium" } },
},
}
```
3. Bind its actions to keys with the `__plugin-action:<plugin id>/<action>` command:
```jsonc
{
"keybindings": [
{ "key": "Ctrl+KeyE", "command": ["__plugin-action:ai-explain/explain"] },
{ "key": "Ctrl+KeyT", "command": ["__plugin-action:ai-explain/translate"] },
],
}
```
To run an action from a script or a window-manager shortcut, use `subminer app --plugin-action ai-explain/explain`. It goes to the running SubMiner, or starts one if none is running.
4. Restart SubMiner. Plugin changes apply on the next start; keybinding changes apply immediately.
Load errors and plugin log output go to the app log with a `plugin:<id>` scope.
## Example: explain the current line with Codex
The repository ships [`assets/plugins/ai-explain`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/ai-explain). It sends the current line, plus the lines around it, to [`codex exec`](https://github.com/openai/codex) and shows the answer in a panel. It needs a logged-in Codex CLI.
| Action | What it does |
| ----------- | --------------------------------------------------------------- |
| `explain` | Translation plus a breakdown of the grammar and vocabulary |
| `translate` | Natural English translation, with a note only when it's unclear |
| Setting | Default | What it does |
| ----------------- | --------- | ----------------------------------------------------------------- |
| `codexPath` | `"codex"` | Codex binary. Use a full path if SubMiner can't find it on `PATH` |
| `model` | `""` | Model passed to `codex exec -m`; empty uses your Codex config |
| `reasoningEffort` | `"low"` | Codex reasoning effort; empty uses your Codex config |
| `before`, `after` | `2`, `1` | Lines of context sent before and after the current line |
| `pause` | `true` | Pause playback when the panel opens |
| `timeoutMs` | `120000` | Give up after this long |
| `prompts` | `{}` | Replace an action's instructions, e.g. `{ "translate": "..." }` |
Press `Esc` to close the panel. Closing it, or starting another request, stops the running Codex process.
## Example: sync Anki after mining
[`assets/plugins/anki-sync`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/anki-sync) runs AnkiConnect's `sync` after you mine cards, so they reach AnkiWeb without opening Anki. It waits until you stop mining for a while, so a burst of cards becomes one sync. It also adds a `sync` action you can bind to a key.
| Setting | Default | What it does |
| ----------- | ------- | ------------------------------------------------------- |
| `delayMs` | `30000` | Sync after no card has been mined for this long |
| `syncOnEnd` | `true` | Sync right away when playback ends or mpv quits |
| `timeoutMs` | `60000` | Give up on a sync after this long |
| `notify` | `true` | Show a notification after each sync. Failures always do |
Anki must be running and logged in to AnkiWeb. Cards mined from the stats dashboard don't trigger a sync.
## Example: tag cards with the episode
[`assets/plugins/episode-tags`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/episode-tags) tags each mined card with the show and episode it came from, such as `show::Sousou_no_Frieren::E07`. It reads them from the file name (`[Group] Show - 07 (1080p).mkv`, `Show.S01E07.mkv`, `Show 第7話.mkv`), or from the title mpv shows for streams. It uses the source captured when mining started, so delayed media and field grouping into an older note keep the correct episode. Notes with unknown provenance, including cards discovered by polling, are left alone. Spaces become `_`.
| Setting | Default | What it does |
| -------- | ------------------------ | ----------------------------------------------- |
| `tags` | `["show::{show}::{ep}"]` | Tags to add, one template each |
| `notify` | `false` | Show a notification with the tags for each card |
Templates can use `{show}`, `{season}`, `{episode}`, and `{ep}` (`S01E07`, or `E07` when there is no season). When a placeholder has no value, the tag stops before that `::` level, so a movie gets `show::<title>`. The parsing is a guess from the name: a title ending in a number, like `Mob Psycho 100`, reads that number as the episode.
## Write a plugin
A plugin folder needs a `plugin.json`:
```json
{ "name": "My Plugin", "version": "1.0.0", "apiVersion": 1, "main": "main.js" }
```
`apiVersion` declares the required SubMiner plugin API version. The current version is `1`; omitted values also mean v1 for existing plugins. An unsupported version is rejected before the plugin's entry module runs. `version` is the plugin's own version and does not control compatibility.
`main` points to a CommonJS module (`.js`) or an ES module (`.mjs`) that exports `activate(ctx)`. Activation may be asynchronous and has a 10-second timeout. A plugin's actions wait for its own activation, so a slow plugin does not hold up other plugins. If activation fails or times out, its registrations are removed and its signal aborts. Synchronous code still shares the app's main thread.
Activation may return a cleanup function, or the module can export `deactivate()`. On quit, SubMiner aborts `ctx.signal` first and waits up to 2 seconds in total for loading and deactivation. A plugin still reading its files is skipped. Everything a plugin registers through `ctx` is cleaned up automatically. A late activation cannot leave new listeners, timers, or actions behind, and its returned cleanup still runs once. Deactivation can call Anki, mpv, built-in actions, and notifications until disposal. After disposal, those calls throw or reject, including work that resumes after an activation or deactivation timeout.
```js
function activate(ctx) {
ctx.media.on('change', (media) => {
if (media) ctx.log.info(`Now playing ${media.path}`);
});
ctx.every(60_000, () => ctx.log.info('Still watching'));
ctx.actions.register('hello', () => {
const line = ctx.subtitles.current();
ctx.ui.panel({ title: 'Hello', markdown: line ? `> ${line.text}` : '_No subtitle_' });
});
}
module.exports = { activate };
```
An error thrown by a plugin is logged and does not crash SubMiner. A plugin that blocks with a long synchronous loop still freezes the app, so keep slow work asynchronous.
### Types and cleanup
The public types live in [`src/plugin-api.ts`](https://github.com/ksyasuda/SubMiner/blob/main/src/plugin-api.ts). A build emits `dist/plugin-api.d.ts`; copy that file into your plugin as `subminer.d.ts`, or copy the source file with that name. It has no imports from SubMiner internals. Electron's types must be available to your editor because `ui.openWindow` returns a `BrowserWindow`.
JavaScript plugins can use the types through JSDoc:
```js
/** @param {import('./subminer').PluginContext} ctx */
function activate(ctx) {
const { ipcMain } = require('electron');
ipcMain.handle(`${ctx.id}:info`, () => ({ media: ctx.media.current() }));
ctx.onDispose(() => ipcMain.removeHandler(`${ctx.id}:info`));
}
module.exports = { activate };
```
Use `ctx.signal` with `fetch` or other cancellable work. It aborts when unloading starts, before `deactivate` runs. `ctx.onDispose(fn)` registers synchronous cleanup for resources outside `ctx`, such as file watchers and Electron IPC handlers. The returned function runs the cleanup early, at most once. Registering cleanup after unloading starts runs it immediately. Timers and listeners registered after that point are removed immediately too. Use the returned activation cleanup or `deactivate` for asynchronous teardown, within the shared quit timeout.
### `ctx` reference
| Member | What it does |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `id`, `dir`, `settings` | Plugin id, plugin folder path, and `plugins.<id>.settings` |
| `apiVersion` | The host's plugin API version, currently `1` |
| `signal`, `onDispose(fn)` | Lifetime cancellation and cleanup for resources created outside `ctx` |
| `dataDir` | `plugin-data/<id>/` next to your config file, for caches and state. Exists before `activate` runs |
| `log.debug/info/warn/error(message)` | Write to the app log |
| `every(ms, fn)`, `after(ms, fn)` | Repeating and one-shot timers. Return a function that cancels them |
| `mpv.on(event, fn)` | Listen to mpv events (below). Returns an unsubscribe function |
| `mpv.command(args)` | Send an mpv command, e.g. `['set', 'pause', 'yes']` |
| `mpv.request(args)` | Await an mpv command result; rejects on command error, disconnect, or timeout |
| `mpv.getProperty(name)` | Read an mpv property (Promise) |
| `mpv.connected` | Whether mpv is connected |
| `mpv.ensureRunning()` | Connect to mpv, starting an idle mpv if none is running. Resolves `false` if that fails |
| `media.current()` | Cached `{ playbackId, path, title }`, or `null` when there is no connected media |
| `media.on('change', fn, options)` | Subscribe to playback snapshots; immediately delivers the current snapshot unless `emitCurrent: false` |
| `anki.on(event, fn)` | Listen to Anki events (below). Returns an unsubscribe function |
| `anki.invoke(action, params, options)` | Send an AnkiConnect action to `ankiConnect.url`, e.g. `('sync')`. Resolves its result (below) |
| `subtitles.current()` | The displayed line as `{ text, startTime, endTime }`, or `null` |
| `subtitles.context({ before, after })` | `{ current, before, after }`. Neighbors come from the loaded subtitle file, or recent lines for streams |
| `actions.register(name, fn)` | Add an action, bindable as `__plugin-action:<id>/<name>` |
| `actions.run(actionId, payload)` | Run a built-in action, the same ids keybindings use, e.g. `openJimaku` or `mineSentence` |
| `notify(message, { variant })` | Overlay notification. `variant` is `info`, `warning`, or `error` |
| `ui.panel({ title, markdown })` | Open the plugin panel. Returns `{ update(content), close(), closed, signal }` |
| `ui.openWindow({ html, preload })` | Open a window from the plugin folder (below). Returns the Electron `BrowserWindow` |
| `exec(command, args, options)` | Run a program without a shell (Windows `.cmd`/`.bat` shims go through `cmd.exe`). Resolves `{ exitCode, stdout, stderr }` |
| `unstable` | SubMiner internals. These change between releases without notice |
A panel's `signal` aborts when the user closes it or another panel replaces it. Pass it to `exec` to stop work nobody will see. Closing or replacing a panel also cancels pending open requests, including while its window loads. Only one panel is open at a time. Panel markdown supports headings, lists, quotes, code, bold, and italics; HTML is shown as plain text.
`exec` options are `stdin`, `cwd`, `env`, `timeoutMs`, and `signal`. It resolves for any exit code and rejects when the program can't start, times out, or is aborted.
`ui.openWindow` takes `html` and an optional `preload`, both relative to the plugin folder, plus `title`, `width`, and `height`. Opening the same `html` again focuses the open window. The page runs with context isolation and no Node access. The preload is sandboxed too: it can `require('electron')` but nothing else, so bundle anything more into it. Expose what the page needs from the preload with `contextBridge`, and handle its messages with `ipcMain` from `require('electron')` in the plugin. Prefix channel names with the plugin id. Plugin windows close when SubMiner quits.
`media` snapshots are immutable. `playbackId` changes on a new load, including reopening the same path. A title may arrive later with the same id. Disconnecting or ending playback delivers `null`. Use the id to discard stale asynchronous results. Snapshots describe cached playback state; they do not promise that external subtitle discovery has finished.
`mpv.request` accepts string, number, and boolean arguments, e.g. `await ctx.mpv.request(['set_property', 'pause', true])`. It returns mpv's result data and rejects after 4 seconds. A timeout does not cancel the command in mpv, so a slow command may still complete. `mpv.command` remains available for commands whose result you do not need.
mpv events: `connection-change`, `subtitle-change`, `subtitle-timing`, `time-pos-change`, `duration-change`, `pause-change`, `fullscreen-change`, `media-path-change`, `media-title-change`, `subtitle-track-change`, `subtitle-track-list-change`, `secondary-subtitle-change`, `secondary-subtitle-track-change`, `secondary-subtitle-delay-change`, `secondary-subtitle-visibility`, `client-message`, `file-loaded`, and `end-file`. `file-loaded` has no payload. `end-file` gives `{ reason, playlistEntryId, error }`, where `reason` is `eof`, `stop`, `quit`, `error`, `redirect`, or `unknown`.
Anki events: `card-mined` gives `{ noteIds, notes }` once SubMiner has finished filling in a mined card (sentence, audio, image, and field grouping). For YouTube cards whose media waits on the download, it fires once that media is added or the download fails. With `ankiConnect.behavior.autoUpdateNewCards` off, it fires as soon as SubMiner sees the card. If field grouping merged the card into an older one, `noteIds` contains the older card's id.
Each `notes` entry has `{ originalNoteId, noteId, provenance }`. `originalNoteId` identifies the note before merges; `noteId` is the note that kept its content. `provenance` is `{ minedAt, media, subtitle }`, captured before asynchronous creation or the AnkiConnect proxy's upstream request. `minedAt` is Unix time in milliseconds. `media` uses the snapshot format above, and `subtitle` uses `{ text, startTime, endTime }`. Either may be `null`. Polling cannot establish the original source, so its provenance is `null` rather than the currently playing file.
`subtitles.current()` resolves parsed cues using the current subtitle delay and returns their times on the subtitle file's timeline.
Sentence-card provenance also uses the subtitle file's timeline. Multi-line mining converts the history range using the subtitle delay when mining starts; changing the delay between those lines can affect the reported range.
Several original notes can merge into one kept note. `notes` preserves each source, while `noteIds` contains unique kept ids. Existing listeners using only `noteIds` continue to work. Cards mined from the stats dashboard do not emit this event.
`anki.invoke` takes `{ timeoutMs }` (default 10 seconds), rejects on an Anki error, and never retries, so an action like `sync` or `addNote` runs at most once.
+353 -18
View File
@@ -6,6 +6,13 @@
*/
{
// ==========================================
// Dictionary Backend
// Select the dictionary lookup backend: yomitan or hachidori.
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
// ==========================================
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
// ==========================================
// Subtitle Selection
// Select primary and secondary mpv subtitle tracks from the overlay.
@@ -101,6 +108,7 @@
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
"faceButtonLabels": "xbox", // Face button style of your controller: xbox (A at the bottom), nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or playstation (╳ ○ □ △, laid out like xbox). Places the A/B/X/Y of the dialog layouts and names buttons in Alt+C and the dialog hints. Alt+C sets it per controller (controller.profiles.<id>.faceButtonLabels); this is the default for the others. Values: xbox | nintendo | playstation
"repeatDelayMs": 320, // Delay before repeating held controller actions.
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
"buttonIndices": {
@@ -120,38 +128,62 @@
"toggleLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"closeLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"toggleKeyboardOnlyMode": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"mineCard": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"quitMpv": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"previousAudio": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"nextAudio": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"playCurrentAudio": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"toggleMpvPause": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
}, // Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"holdSecondarySub": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"previousEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"nextEntry": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"toggleCharacterNavigation": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"previousSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"nextSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"replaySubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"playNextSubtitle": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
"leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action.
@@ -162,17 +194,294 @@
"axisIndex": 1, // Raw axis index captured for this analog controller action.
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
"rightStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 3, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
"rightStickVertical": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 4, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
"reviewBindings": {
"confirm": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Use this timing.
"cancel": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Cancel.
"play": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Play selection.
"resetTiming": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Reset timing.
"showEarlier": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Earlier −2s. Repeats while held.
"showLater": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Later +2s. Repeats while held.
"framePrevious": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Previous screenshot frame. Repeats while held.
"frameNext": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Next screenshot frame. Repeats while held.
"frameReset": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Reset screenshot frame.
"startBack": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Start −0.1s. Repeats while held.
"startForward": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Start +0.1s. Repeats while held.
"previousLineAdd": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Add previous line. Repeats while held.
"previousLineRemove": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 4 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Remove previous line. Repeats while held.
"endBack": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: End −0.1s. Repeats while held.
"endForward": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: End +0.1s. Repeats while held.
"nextLineAdd": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Add next line. Repeats while held.
"nextLineRemove": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": {
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"buttonIndex": 5 // Raw button index.
} // Optional input held while pressing; null for none.
}, // Review dialog: Remove next line. Repeats while held.
"useOriginal": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Use original timing (after cancel).
"keepEditing": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Keep editing (after cancel).
"skipMedia": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Review dialog: Without media (after cancel).
"discard": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
} // Review dialog: Discard card (after cancel).
}, // Controller layout for the media timing review dialog, one entry per action. Set it with the Review media timing group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.
"fieldGroupingBindings": {
"selectOriginal": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Original card.
"selectNew": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: New card.
"toggleDeleteDuplicate": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Delete duplicate on/off.
"continue": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Continue.
"cancel": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Cancel.
"confirmMerge": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Confirm merge (merge preview).
"back": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Back (merge preview).
"cancelMerge": {
"kind": "none" // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
}, // Duplicate card dialog: Cancel (merge preview).
"toggleView": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Compact / Full (merge preview).
"scrollUp": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
}, // Duplicate card dialog: Scroll up (merge preview). Repeats while held.
"scrollDown": {
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
"press": {
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
}, // Input that triggers the action.
"hold": null // Optional input held while pressing; null for none.
} // Duplicate card dialog: Scroll down (merge preview). Repeats while held.
}, // Controller layout for the duplicate card (field grouping) dialog, one entry per action. Set it with the Duplicate card (field grouping) group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
@@ -211,6 +520,14 @@
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior.
// ==========================================
// Plugins
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
// Restart SubMiner after changing plugins.
// ==========================================
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
@@ -455,6 +772,7 @@
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"autoPauseVideoOnKeyboardMode": false, // Pause mpv when keyboard-only mode turns on (from the keyboard or a controller) and resume when it turns off. Leaving the mode resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, is left as it is. Values: true | false
"primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false
"nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
@@ -534,6 +852,22 @@
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// ==========================================
// Subtitle Filters
// Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
// Applies to the overlay, sidebar, mined sentences and stats.
// Hot-reload: filter changes apply to the current subtitle file immediately.
// ==========================================
"subtitleFilter": {
"presets": {
"parentheses": false, // Remove text in () and (), such as speaker names, readings and notes. A line wrapped entirely in parentheses, usually inner thoughts, is kept. Values: true | false
"brackets": false, // Remove text in [], [], 【】 and 〔〕, such as sound effects and music cues. Values: true | false
"speakerPrefix": false, // Remove a short name followed by a colon at the start of a line, as in 山田:元気? Values: true | false
"musicSymbols": false // Remove ♪, ♫ and ♬. A line left with only symbols such as ~ is removed. Values: true | false
}, // Built-in filters. Each removes its matches and is off by default.
"customRules": [] // Your own regex rules, applied in order after the presets. Each rule is { name, pattern, replacement, enabled }; patterns use the gmu flags, so ^ and $ match per line. Leave replacement empty to remove the match.
}, // Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
// ==========================================
// Shared AI Provider
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
@@ -571,7 +905,7 @@
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
"fields": {
"word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
"audio": "SentenceAudio", // Card field that receives generated sentence audio.
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
"image": "Picture", // Card field that receives the captured screenshot or animated image.
"sentence": "Sentence", // Card field that receives the source sentence text.
@@ -656,7 +990,8 @@
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
"maxEntryResults": 10 // Maximum Jimaku search results returned.
"maxEntryResults": 10, // Maximum Jimaku search results returned.
"autoOpenWhenPrimarySubtitleMissing": false // Pause playback and open the Jimaku modal when a local video has no subtitle track in youtube.primarySubLanguages (Japanese by default). Values: true | false
}, // Jimaku API configuration and defaults.
// ==========================================
@@ -734,10 +1069,10 @@
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
// Set mpv.profile to pass an mpv profile to managed mpv launches; leave it blank to pass none.
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
// Leave mpv.executablePath blank to auto-discover mpv from SUBMINER_MPV_PATH, PATH, or (macOS) Homebrew and /Applications/mpv.app.
// ==========================================
"mpv": {
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"executablePath": "", // Optional absolute path to the mpv binary SubMiner starts itself (Windows launches, Jellyfin casting, timing preview). Leave empty to auto-discover from SUBMINER_MPV_PATH, PATH, and on macOS the Homebrew and /Applications/mpv.app locations.
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
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

+19 -16
View File
@@ -6,12 +6,12 @@ Shortcuts work when the overlay has focus. With the [mpv plugin](/mpv-plugin), `
## Global
| Shortcut | Action | Config key |
| ------------- | ---------------------- | -------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` |
| `Alt+Shift+Y` | Open Yomitan settings | Fixed |
| Shortcut | Action | Config key |
| ------------- | ------------------------------- | -------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` |
| `Alt+Shift+Y` | Open active dictionary settings | Fixed |
`Alt+Shift+Y` is registered with the OS and works from any app. If another app already uses it, SubMiner cannot take it and you cannot rebind it.
`Alt+Shift+Y` opens Yomitan or Hachidori settings, whichever backend is running. It is registered with the OS and works from any app. If another app already uses it, SubMiner cannot take it and you cannot rebind it.
## Mining
@@ -62,6 +62,7 @@ Built into the overlay, not configurable:
| Input | Action |
| ------------------------- | -------------------------------------------------- |
| `V` | Cycle primary subtitle bar: hidden, visible, hover |
| `C` (keyboard-only mode) | Move by word or by character |
| Right-click | Pause or resume (outside the subtitle area) |
| Right-click + drag | Move the subtitles |
| Drop files on the overlay | Replace the mpv playlist |
@@ -98,17 +99,17 @@ The subtitle picker (`g` then `s`) is off until you turn it on in **Settings, Be
Press `y`, then the second key.
| Keys | Action |
| ----- | -------------------------- |
| `y-y` | Open the SubMiner menu |
| `y-s` | Start the overlay |
| `y-S` | Stop the overlay |
| `y-t` | Toggle the visible overlay |
| `y-o` | Open Yomitan settings |
| `y-r` | Restart the overlay |
| `y-c` | Show overlay status |
| `y-h` | Open session help |
| `v` | Cycle primary subtitle bar |
| Keys | Action |
| ----- | ------------------------------- |
| `y-y` | Open the SubMiner menu |
| `y-s` | Start the overlay |
| `y-S` | Stop the overlay |
| `y-t` | Toggle the visible overlay |
| `y-o` | Open active dictionary settings |
| `y-r` | Restart the overlay |
| `y-c` | Show overlay status |
| `y-h` | Open session help |
| `v` | Cycle primary subtitle bar |
The plugin's `v` replaces mpv's own subtitle visibility toggle. When the overlay has focus, `y` then `d` toggles DevTools.
@@ -138,6 +139,8 @@ The plugin's `v` replaces mpv's own subtitle visibility toggle. When the overlay
}
```
To run a [plugin](/plugins) action, use `["__plugin-action:<plugin id>/<action>"]` as the command.
Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. Scroll wheel names are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, and `WHEEL_RIGHT`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference.
## Automatic mpv bindings
+4 -2
View File
@@ -16,7 +16,7 @@ The modal's **Local tools** section lists anything missing. After you install a
## Generating from the overlay
1. Open a local video in mpv and select its Japanese audio track, or play a YouTube video.
1. Open a local video in mpv and select its Japanese audio track, or play a Jellyfin or YouTube video.
2. Press `Ctrl+Shift+G`. If the subtitle sidebar is empty, its **Generate Japanese subtitles** button opens the same modal.
3. Pick a model and download it if needed.
4. Optionally check **Focus on spoken dialogue** (see below).
@@ -26,6 +26,8 @@ The modal shows progress. **Cancel** stops the job. Closing the modal lets the j
SubMiner saves `<video>.ja.generated.srt` next to the video and adds a number if that name is taken. If the same file is still playing, it loads the subtitles and resets the subtitle delay.
For a Jellyfin video, SubMiner reads the original file from your server and saves the subtitles to `~/.cache/subminer/generated-subs/`, since a stream has no folder of its own. Each Jellyfin generation replaces the previous file there. Playback keeps going while it works. If Jellyfin is transcoding, SubMiner transcribes the audio track that playback started with.
For a YouTube video, SubMiner downloads its audio first and pauses the video until the subtitles load. The subtitles are temporary, and the audio is deleted when generation ends or you switch videos. See [YouTube integration](/youtube-integration#generate-subtitles-with-whisper).
Change the shortcut with `shortcuts.openSubtitleGeneration`.
@@ -46,7 +48,7 @@ subminer generate-subs episode.mkv --model-path /path/to/ggml-small.bin
| `--audio-stream <index>` | Pick an audio stream by its absolute FFmpeg index |
| `--output <path>` | Write to this SRT path. Existing files are never overwritten. |
With a file argument, SubMiner uses the audio stream tagged Japanese, or the first stream. `Ctrl+C` cancels.
With a file argument, SubMiner uses the audio stream tagged Japanese, or the first stream. `Ctrl+C` cancels. The launcher only works with local files. Use the overlay for Jellyfin and YouTube.
## Choosing a model
+10 -3
View File
@@ -39,11 +39,18 @@ SubMiner reads subtitles from mpv over an IPC socket (a named pipe on Windows).
SubMiner reconnects on its own if mpv restarts.
## Casting from Jellyfin does nothing
When `connect ENOENT /tmp/subminer-socket` follows a cast in the app log, SubMiner could not start mpv, so the socket never appeared. Look for `Failed handling Jellyfin remote Play event` or `Failed to launch mpv for Jellyfin remote playback` just before it.
- Check that `mpv --version` works in a terminal.
- If it does, SubMiner is not finding mpv where it is installed. Set `mpv.executablePath` to the output of `command -v mpv`. See [Casting from Jellyfin](/jellyfin-integration#casting-from-jellyfin) for where SubMiner looks.
## Overlay does not appear
- Confirm SubMiner is running (`SubMiner.AppImage --start`, or check for the process).
- Linux: Hyprland and Sway work natively. Any other compositor needs mpv and SubMiner under X11 or Xwayland, with `xdotool`, `xprop`, and `xwininfo` installed. See [KDE Plasma and other Wayland compositors](#kde-plasma-and-other-wayland-compositors).
- macOS: grant Accessibility permission in System Settings > Privacy & Security > Accessibility.
- macOS: grant Accessibility permission. See [macOS](#macos).
## Overlay is on the wrong monitor or position
@@ -79,7 +86,7 @@ SubMiner detected that a previously non-empty Yomitan profile suddenly appears e
## "Yomitan extension not found in any search path"
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `build/yomitan`.
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `dist/yomitan`.
## "MeCab not found on system"
@@ -255,5 +262,5 @@ mpv --gpu-context=x11vk,x11egl,x11 video.mkv
## macOS
- Accessibility permission is required for window tracking: System Settings > Privacy & Security > Accessibility.
- Grant Accessibility permission under System Settings > Privacy & Security > Accessibility (named **Device Control and Data Access** on macOS 27). Without it, SubMiner falls back to a basic window lookup that cannot tell when mpv is minimized.
- If Gatekeeper blocks the app, right-click it and choose Open, or run `xattr -d com.apple.quarantine /path/to/SubMiner.app`.
+82 -25
View File
@@ -16,10 +16,41 @@ Run `subminer` with no file to pick one from the current directory instead. See
### Yomitan setup
Lookups need at least one dictionary in SubMiner's bundled Yomitan. First-run setup asks you to import one. To add more later, open Yomitan settings with `Alt+Shift+Y` or `subminer app --yomitan`.
Lookups need at least one dictionary in SubMiner's bundled Yomitan (or in Hachidori, if you [switched backends](#hachidori-setup)). First-run setup asks you to import one. To add more later, open Yomitan settings with `Alt+Shift+Y` or `subminer app --yomitan`.
The bundled Yomitan is separate from any Yomitan in your browser. It has its own dictionaries and settings.
### Hachidori setup
[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.
Hachidori uses SubMiner's subtitle scanning, popup pause, controller commands, character dictionaries, and Anki media. Keep the [Anki proxy](/anki-integration#proxy-mode-setup-yomitan-texthooker) on for screenshots and sentence audio. Hachidori's own screen recorder and screenshot capture are off inside SubMiner. `startupWarmups.yomitanExtension` and `subtitleStyle.autoPauseVideoOnYomitanPopup` apply to whichever backend is selected.
Switching backends:
- First-run setup asks for dictionaries the first time you switch to a backend. Switching back to a backend that already finished setup skips it.
- Until you restart, SubMiner keeps running the backend it started with. The launcher waits for that backend before playback and logs a restart reminder.
- `--yomitan` and `--hachidori` both work whichever backend is selected. Opening settings does not switch backends.
- When `yomitan.externalProfilePath` is set, `--yomitan` is disabled to keep the external profile read-only. Hachidori settings still open.
#### External dictionary host
First-run setup can link a Hachidori host instead of using local dictionaries: **Dictionary source → Use an external dictionary host → Link host**. Turn on sharing in the other Hachidori app or browser, or start a compatible Docker host, then enter its sharing address, for example `127.0.0.1:8771` or `ws://host:8771/link`. Use the WebSocket sharing port, not the management page or HTTP API port.
| Host | Must be running |
| -------- | ------------------------------------------- |
| Browser | The browser, the Hachidori extension, relay |
| Electron | The host app and any relay it needs |
| Docker | The container only |
Setup checks the connection and the host's dictionaries before **Finish** unlocks, so import at least one dictionary on the host and refresh. The link survives restarts. **Unlink and use local dictionaries** goes back to local.
While linked, dictionaries and dictionary settings come from the host. Anki templates, pronunciation sources, custom buttons, and SubMiner's audio and image processing stay local. Frequency annotations use ranks returned with dictionary entries, and SubMiner asks the host for missing ones. Words with no matching definition entry may stay unranked even if a frequency dictionary lists them.
SubMiner sends [character dictionaries](/character-dictionary) to the linked host over the link and replaces the previous one once the import succeeds, retrying while the host is busy. The host needs Hachidori 0.2.3 or later, or a current Docker host. With an older host, sync reports where the merged ZIP is so you can import it from that host's Hachidori settings.
## Picking files
```bash
@@ -33,15 +64,19 @@ See [Launcher script](/launcher-script#video-picker) for picker and history deta
## Overlay basics
| Key | Action |
| ------------- | --------------------------------------------------------------------- |
| `Alt+Shift+O` | Show or hide the overlay (works while the overlay or mpv has focus) |
| `Alt+Shift+Y` | Open Yomitan settings (works from any window, not configurable) |
| `V` | Cycle the subtitle bar through hidden, visible, and hover-only |
| `Ctrl+Alt+P` | Open the playlist browser to queue, reorder, or jump between episodes |
| `Ctrl/Cmd+/` | Show every overlay and mpv keybinding for this session |
| Key | Action |
| ------------- | ---------------------------------------------------------------------------- |
| `Alt+Shift+O` | Show or hide the overlay (works while the overlay or mpv has focus) |
| `Alt+Shift+Y` | Open Yomitan or Hachidori settings (works from any window, not configurable) |
| `V` | Cycle the subtitle bar through hidden, visible, and hover-only |
| `Ctrl+Alt+P` | Open the playlist browser to queue, reorder, or jump between episodes |
| `Ctrl/Cmd+/` | Show every overlay and mpv keybinding for this session |
Hovering subtitle text pauses mpv, and moving away resumes it. An open Yomitan popup also keeps playback paused. Turn these off with `subtitleStyle.autoPauseVideoOnHover` and `subtitleStyle.autoPauseVideoOnYomitanPopup`.
Hovering subtitle text pauses mpv, and moving away resumes it. An open dictionary popup also keeps playback paused. Turn these off with `subtitleStyle.autoPauseVideoOnHover` and `subtitleStyle.autoPauseVideoOnYomitanPopup`.
**Pause Video In Keyboard-Only Mode** (`subtitleStyle.autoPauseVideoOnKeyboardMode`, off by default) pauses mpv when keyboard-only mode turns on, from the keyboard or a controller, and resumes it when the mode turns off. It only resumes a pause it made: if the video was already paused, or you played or paused it yourself in the meantime, leaving the mode leaves playback as it is.
Turning keyboard-only mode off also closes the dictionary popup, whether or not this setting is on.
You can drop files onto the overlay:
@@ -73,6 +108,7 @@ subminer doctor # check dependencies, config, and the mpv soc
subminer generate-subs video.mkv # make Japanese subtitles from the audio
subminer logs -e # export a log ZIP for bug reports
subminer app --setup # reopen first-run setup
subminer app --plugin-action <id>/<name> # run a plugin action
subminer -u # update SubMiner
```
@@ -124,26 +160,47 @@ On Wayland, the tray icon only appears if your panel provides a StatusNotifier (
You can drive the overlay with a gamepad.
1. Set `controller.enabled` to `true` in your config.
2. Connect a controller. SubMiner uses the first one it sees.
3. Press `Y` on the controller to turn on keyboard-only mode. The controller only works in this mode.
2. Connect a controller and press a button. SubMiner uses the first one it sees and announces it through [`ankiConnect.behavior.notificationType`](/configuration#ankiconnect).
3. Press `Y` on the controller to turn on keyboard-only mode. Most controller actions only work in this mode.
4. Move between words with the left stick, press `A` to look one up, and `X` to mine it.
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want. `Alt+Shift+C` shows raw input values for unusual pads.
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want, or hold one button and press another to make a combo such as `L1` + D-pad right. Set **Button Style** to Xbox, Nintendo or PlayStation so buttons are named as printed on your controller; it is saved per controller. Changes apply right away. When two actions share an input, both fire, so their rows show a ⚠ warning naming the other action; this includes a button on the D-pad while a **(D-pad)** row under **Navigation** is still on, and a stick direction used by a stick action. The window only sees a controller after you press one of its buttons; until then it shows the bindings of the controller in use as a preview, and editing stays locked. With several controllers connected, it selects the one you pressed last. `Alt+Shift+C` shows raw input values for unusual pads.
| Button | Action |
| --------------------- | ------------------------------------ |
| `A` (South) | Look up the selected word |
| `B` (East) | Close the lookup |
| `X` (West) | Mine a card |
| `Y` (North) | Toggle keyboard-only mode |
| `L1` | Play the current Yomitan audio |
| `R1` | Next Yomitan audio source |
| `L3` | Pause or resume mpv |
| `Select` / `Minus` | Quit mpv |
| Left stick | Move between words, scroll the popup |
| Right stick (up/down) | Jump through the popup |
A combo fires each time you press its second button while the first is down, so holding `L1` and pressing right twice runs it twice. While a combo's hold button is down, other buttons only run combos that use it, and its press no longer drives a **(D-pad)** or stick row. A hold button can keep its own action: that action then runs when you let go, unless you pressed another bound button during the hold. The exception is **Show Secondary Subtitle (Hold)**, which can't wait for release, so its row warns if it shares a hold button.
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`. Remap it with `Alt+C`. All options are in [Configuration](/configuration#controller-support).
| Button | Action |
| --------------------- | -------------------------------------- |
| `A` (South) | Look up the selected word |
| `B` (East) | Close the lookup |
| `X` (West) | Mine a card |
| `Y` (North) | Toggle keyboard-only mode |
| `L1` | Play the current Yomitan audio |
| `R1` | Next Yomitan audio source |
| `L3` | Pause or resume mpv (any mode) |
| `Select` / `Minus` | Quit mpv |
| Left stick | Move between words, scroll the popup |
| Right stick (up/down) | Jump through the popup |
| Unbound by default | Show the secondary subtitle while held |
| Unbound by default | Previous word entry in the popup |
| Unbound by default | Next word entry in the popup |
| Unbound by default | Previous / next subtitle line |
| Unbound by default | Replay the current line, then pause |
| Unbound by default | Play the next line, then pause |
| Unbound by default | Move by word or by character |
The secondary subtitle hold works with or without keyboard-only mode. Assign it with **Learn** on the **Show Secondary Subtitle (Hold)** row in `Alt+C`. While held, the secondary subtitle shows as if its mode were `visible`; releasing the button returns it to your chosen mode.
The **Subtitles** rows in `Alt+C` do the same as the keyboard shortcuts: **Previous Subtitle** and **Next Subtitle** jump a line like `Shift+H` / `Shift+L` (a paused video stays paused), **Replay Subtitle** is `Ctrl+Shift+H` and **Play Next Subtitle** is `Ctrl+Shift+L`. Like the secondary subtitle hold and pause, they work with or without keyboard-only mode. If you put them on the D-pad, click **Clear** on the matching **(D-pad)** row under **Navigation** too (both rows warn until you do), or in keyboard-only mode the D-pad will also move between words or scroll the popup.
**Previous Entry** and **Next Entry** move one word entry at a time in the open popup, and Mine Card then mines the selected entry. They send Yomitan's default `Alt+Up` / `Alt+Down` entry shortcuts, so they stop working if you rebind those in Yomitan's keyboard settings. If you assign them to the D-pad, click **Clear** on the **Popup Scroll (D-pad)** row too, or the D-pad will also scroll the popup.
When the subtitle is split in the wrong place, a word can start in the middle of a box (in もっとハ|キ|ハキしなくちゃ, ハキハキ starts at the last character of the first box) and moving by word never reaches it. In keyboard-only mode, press `C` on the keyboard, or the **Toggle Character Navigation** button (unbound by default, under **Navigation** in `Alt+C`), to move one character at a time: the box turns orange and covers one character, and a lookup searches from that character, so Yomitan finds the longest word starting there. Press it again to go back to moving by word. It stays on until you switch it back, even after leaving keyboard-only mode.
The media timing review and duplicate card (field grouping) dialogs have their own controller layouts, which you can change in the **Review media timing** and **Duplicate card (field grouping)** groups of `Alt+C`; see [Review media timing](/anki-integration#with-a-controller) and [Field grouping](/anki-integration#field-grouping-with-a-controller).
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`, and the default pause button on `Start` instead of `L3`. The default right-stick popup jump does nothing on these controllers; click **Learn** on **Popup Jump (Stick)** and move the right stick up or down to set it. Remap anything else with `Alt+C`, which names buttons according to the layout your controller reports. All options are in [Configuration](/configuration#controller-support).
On macOS, a controller only reaches the app in front. When mpv is in front, a controller button brings the overlay to the front and then runs that button's action. With more than one controller connected, that first press only brings the overlay to the front; press the button again to run its action. Stick movements only work once the overlay is in front. Buttons pressed while another app is in front are left alone.
## Development profiles
+2 -2
View File
@@ -206,7 +206,7 @@ The same pattern works for n8n or Zapier workflows, Discord notifiers, or append
## mpv script messages
SubMiner has no in-app plugin SDK. Besides the streams above, you can drive it from other mpv scripts and from the [launcher CLI](/launcher-script).
SubMiner also supports [JavaScript app plugins](/plugins) for playback events, mining events, actions, and custom UI. Besides the streams above, you can drive it from other mpv scripts and from the [launcher CLI](/launcher-script).
The mpv plugin accepts these script messages:
@@ -227,7 +227,7 @@ The mpv plugin accepts these script messages:
script-message subminer-start backend=hyprland socket=/custom/path texthooker=no log-level=debug
```
The plugin also registers `subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, and `subminer-reload-session-bindings`. The SubMiner app sends these to keep the plugin in sync, so do not send them from your own scripts.
The plugin also registers `subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, `subminer-overlay-loading-dismissed`, and `subminer-reload-session-bindings`. The SubMiner app sends these to keep the plugin in sync, so do not send them from your own scripts.
While the app is connected to mpv, it also handles two AniSkip messages over the mpv IPC socket: `subminer-skip-intro` skips the intro, and `subminer-aniskip-refresh` reloads intro data, for example after your script changes title or episode metadata.
+10 -10
View File
@@ -54,10 +54,10 @@ files before signing and generating updater hashes, never from a signed app.
4. Bump `package.json` to the release version.
5. Build release metadata before tagging (this calls `claude -p` locally):
`bun run changelog:build --version <version> --date <yyyy-mm-dd>`
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed
- The polished `CHANGELOG.md` and `packaging/release-notes/release-notes.md` are committed
before tagging. Release CI no longer auto-builds the changelog; it fails
fast if `changes/*.md` fragments are still present on a tag-based run.
6. Review `CHANGELOG.md` and `release/release-notes.md`. Edit by hand if Claude
6. Review `CHANGELOG.md` and `packaging/release-notes/release-notes.md`. Edit by hand if Claude
missed something — the committed Markdown is what ships.
7. Run release gate locally:
`bun run changelog:check --version <version>`
@@ -73,9 +73,9 @@ files before signing and generating updater hashes, never from a signed app.
`bun-v<version>-source.tar.gz` plus its `.sha256` file. The script
fails if Bun's CMake dependency pins, WebKit pin, source checksums, patch
inputs, or collected license files differ from
`build/bun-source-manifest.json`.
`packaging/bun/bun-source-manifest.json`.
When validating auto-update metadata, also run the relevant platform package
build and confirm `release/` contains the generated updater metadata
build and confirm `dist/release/` contains the generated updater metadata
(`latest*.yml`) and blockmaps (`*.blockmap`).
8. If `docs-site/` changed, also run:
`bun run docs:test`
@@ -103,9 +103,9 @@ files before signing and generating updater hashes, never from a signed app.
source archive. A missing source repository or license file fails the
release instead of publishing only the executable.
When validating packaged updater output, confirm the platform build writes
`latest*.yml` and `*.blockmap` files under `release/`.
`latest*.yml` and `*.blockmap` files under `dist/release/`.
5. Commit the prerelease prep (package.json version bump + the generated
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
`packaging/release-notes/prerelease-notes.md`). CI does not regenerate notes — it uses the
committed file — so review it before committing. Rerun
`bun run changelog:prerelease-notes --version <version>` for every later
beta/RC, even if no fragments changed: the notes carry a hidden
@@ -128,14 +128,14 @@ Notes:
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
- `changelog:check` now rejects tag/package version mismatches.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `packaging/release-notes/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
- From the second prerelease of a base version onward, the notes open with a `## Changes since <previous tag>` section above the cumulative `## Highlights`. The generator locates the newest preceding beta/RC tag for the same base version (semver order: all betas before all RCs), diffs `changes/*.md` between that tag and the working tree, and asks Claude to describe only the behavioral beta-to-beta differences — added fragments as new changes, modified fragments by their before/after difference (editorial-only edits are dropped), deleted fragments as removed/reverted changes. If no fragments changed (for example a packaging-only rebuild), the section states that explicitly without a Claude call. The delta section carries no separate contributor attribution; `## What's Changed` stays cumulative like `## Highlights`.
- `changelog:check-prerelease-notes --version <version>` verifies the committed notes' `prerelease-version` marker matches the version being tagged; the prerelease workflow runs it and fails the release on stale notes.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
- `changelog:build` generates `CHANGELOG.md` + `packaging/release-notes/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- `packaging/release-notes/release-notes.md` (and `packaging/release-notes/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
- Do not tag while `changes/*.md` fragments still exist.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
- CI, stable release, and prerelease workflows call the shared `.github/workflows/quality-gate.yml`; update that reusable workflow when changing common test coverage. Its environment suite includes the Lua mpv plugin tests.
@@ -1,298 +0,0 @@
# Renderer Performance Optimizations
**Date:** 2026-03-15
**Status:** Draft
## Goal
Minimize the time between a subtitle line appearing and annotations being displayed. Three optimizations target different pipeline stages to achieve this.
## Current Pipeline (Warm State)
```text
MPV subtitle change (0ms)
-> IPC to main (5ms)
-> Cache check (2ms)
-> [CACHE MISS] Yomitan parser (35-180ms)
-> Parallel: MeCab enrichment (20-80ms) + Frequency lookup (15-50ms)
-> Annotation stage: 4 sequential passes (25-70ms)
-> IPC to renderer (10ms)
-> DOM render: createElement per token (15-50ms)
─────────────────────────────────
Total: ~200-320ms (cache miss)
Total: ~72ms (cache hit)
```
## Target Pipeline
```text
MPV subtitle change (0ms)
-> IPC to main (5ms)
-> Cache check (2ms)
-> [CACHE HIT via prefetch] (0ms)
-> IPC to renderer (10ms)
-> DOM render: cloneNode from template (10-30ms)
─────────────────────────────────
Total: ~30-50ms (prefetch-warmed, normal playback)
[CACHE MISS, e.g. immediate seek]
-> Yomitan parser (35-180ms)
-> Parallel: MeCab enrichment + Frequency lookup
-> Annotation stage: 1 batched pass (10-25ms)
-> IPC to renderer (10ms)
-> DOM render: cloneNode from template (10-30ms)
─────────────────────────────────
Total: ~150-260ms (cache miss, still improved)
```
---
## Optimization 1: Subtitle Prefetching
### Summary
A new `SubtitlePrefetchService` parses external subtitle files and tokenizes upcoming lines in the background before they appear on screen. This converts most cache misses into cache hits during normal playback.
### Scope
External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out of scope since Japanese subtitles are virtually always external files.
### Architecture
#### Subtitle File Parsing
A cue parser extracts both timing and text content from subtitle files for prefetching.
**Parsed cue structure:**
```typescript
interface SubtitleCue {
startTime: number; // seconds
endTime: number; // seconds
text: string; // plain text, decoded from the source format
source?: 'canonical-ass'; // recovered authored text for generated ASS animation
animationStartTime?: number; // full generated-frame envelope; entrance/exit frames
animationEndTime?: number; // run past the authored timing, live matching uses this
}
```
**Supported formats:**
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
- ASS: Parse the `[Events]` section, read the field order from the `Format:` row, and extract timed `Dialogue:` lines. Timed `Comment:` lines are normally ignored, but can supply canonical authored text when they match a nearby generated animation from the same style and actor. Text can itself contain commas.
**ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key.
**Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`.
ASS scripts can also redraw one complete lyric for two or more long color/highlight phases. Those flush-timed phases collapse separately from short animation frames when they share text, style, actor, and layer and carry direct animation evidence, such as temporal tags or changing non-spatial overrides. Spatial command changes do not prove a phase, so separately positioned signs remain distinct.
**Canonical animation recovery.** Some ASS producers keep the readable lyric or sign as a timed `Comment:` and generate hundreds of `Dialogue:` frames containing repeated glyphs or changing clip regions. Others retain the complete line as brief `Dialogue:` events around the generated fragments. A complete event is promoted only when nearby dialogue from the same style and actor forms a proven animation cluster and reconstructs its entire text in source order. The generated frames are then replaced by one cue marked `source: 'canonical-ass'`. This source marker lets the live primary-subtitle path prefer the clean authored text and timing for display, sidebar history, immersion recording, and mining, while unmatched editor notes and alternative translations remain ignored. Secondary selection advances to an entering canonical cue at its generated animation start when the preceding authored cue ends before the new authored span. Unrelated simultaneous cues that continue through the new span remain visible.
**Font texture cleanup.** A clipped repeated-glyph run or frequent changes to secondary alpha marks a texture seed. Clipped runs do not need a font override because some signs build their masks from ordinary `l` glyphs. The parser removes short clipped pieces that share a no-font seed's style and timing, or pieces that share a font seed's style, timing, and font even when the actor changes. It also removes positioned text layers with at least `E0` global alpha when they overlap a seed in the same style. Opaque authored sign text stays publishable when the texture switches fonts or actors around it.
#### Prefetch Service Lifecycle
1. **Activation trigger:** When a subtitle track is activated (or changes), check if it's external via MPV's `track-list` property. If `external === true`, read the file via `external-filename` using the existing `loadSubtitleSourceText` infrastructure.
2. **Parse phase:** Parse all cues from the file content. Sort by start time. Store as an ordered array.
3. **Priority window:** Determine the current playback position. Identify the next 10 cues as the priority window.
4. **Priority tokenization:** Tokenize the priority window cues sequentially, storing results into the `SubtitleProcessingController`'s tokenization cache.
5. **Background tokenization:** After the priority window is done, tokenize remaining cues working forward from the current position, then wrapping around to cover earlier cues. The prefetcher stops once it has tokenized all cues or the cache is full (whichever comes first) to avoid wasteful eviction churn. For files with more cues than the cache limit, background tokenization focuses on cues ahead of the current position.
6. **Seek handling:** On seek, re-compute the priority window from the new position. A seek is detected by observing MPV's `time-pos` property and checking if the delta from the last observed position exceeds a threshold (e.g., > 3 seconds forward or any backward jump). The current in-flight tokenization finishes naturally, then the new priority window takes over.
7. **Teardown:** When the subtitle track changes or playback ends, stop all prefetch work and discard state.
#### Live Priority
The prefetcher and live subtitle handler share the Yomitan parser (single-threaded IPC). Live subtitle requests must always take priority. The prefetcher:
- Checks a `paused` flag before each cue tokenization. The live handler sets `paused = true` on subtitle change and clears it after emission.
- Yields between each background cue tokenization (via `setTimeout(0)` or equivalent) so the live handler can set the pause flag between cues.
- When paused, the prefetcher waits (polling the flag on a short interval or awaiting a resume signal) before continuing with the next cue.
#### Cache Integration
The prefetcher calls the same `tokenizeSubtitle` function used by live processing to produce `SubtitleData` results, then stores them into the existing `SubtitleProcessingController` tokenization cache via a new method:
```typescript
// New methods on SubtitleProcessingController
preCacheTokenization: (text: string, data: SubtitleData) => void;
isCacheFull: () => boolean;
```
`preCacheTokenization` uses the same `setCachedTokenization` logic internally (LRU eviction, Map-based storage). `isCacheFull` returns `true` when the cache has reached its limit, allowing the prefetcher to stop background tokenization and avoid wasteful eviction churn.
#### Cache Invalidation
When the user marks a word as known (or any event triggers `invalidateTokenizationCache()`), all cached results are cleared -- including prefetched ones, since they share the same cache. After invalidation, the prefetcher re-computes the priority window from the current playback position and re-tokenizes those cues to restore warm cache state.
#### Error Handling
If the subtitle file is malformed or partially parseable, the cue parser uses what it can extract. A file that yields zero cues disables prefetching silently (falls back to live-only processing). Encoding errors from `loadSubtitleSourceText` are caught and logged; prefetching is skipped for that track.
#### Integration Points
- **MPV property subscriptions:** Needs `track-list` (to detect external subtitle file path) and `time-pos` (to track playback position for window calculation and seek detection).
- **File loading:** Uses existing `loadSubtitleSourceText` dependency.
- **Tokenization:** Calls the same `tokenizeSubtitle` function used by live processing.
- **Cache:** Writes into `SubtitleProcessingController`'s cache.
- **Cache invalidation:** Listens for cache invalidation events to re-prefetch the priority window.
### Files Affected
- **New:** `src/core/services/subtitle-prefetch.ts` -- the prefetch service
- **New:** `src/core/services/subtitle-cue-parser.ts` -- SRT/VTT/ASS cue parser (text + timing)
- **Modified:** `src/core/services/subtitle-processing-controller.ts` -- expose `preCacheTokenization` method
- **Modified:** `src/main.ts` -- wire up the prefetch service, listen to track changes
---
## Optimization 2: Batched Annotation Pass
### Summary
Collapse the 4 sequential annotation passes (`applyKnownWordMarking` -> `applyFrequencyMarking` -> `applyJlptMarking` -> `markNPlusOneTargets`) into a single iteration over the token array, followed by N+1 marking.
**Important context:** Frequency rank _values_ (`token.frequencyRank`) are already assigned at the parser level by `applyFrequencyRanks()` in `tokenizer.ts`, before the annotation stage is called. The annotation stage's `applyFrequencyMarking` only performs POS-based _filtering_ -- clearing `frequencyRank` to `undefined` for tokens that should be excluded (particles, noise tokens, etc.) and normalizing valid ranks. This optimization does not change the parser-level frequency rank assignment; it only batches the annotation-level filtering.
### Current Flow (4 passes, 4 array copies)
```text
tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
-> applyKnownWordMarking() // .map() -> new array
-> applyFrequencyMarking() // .map() -> new array (POS-based filtering only)
-> applyJlptMarking() // .map() -> new array
-> markNPlusOneTargets() // .map() -> new array
```
### Dependency Analysis
All annotations either depend on MeCab POS data or benefit from running after it:
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
- **N+1 marking:** Uses POS exclusion sets to filter candidates. Depends on known word status + MeCab POS.
Since frequency filtering and JLPT marking both depend on POS data from MeCab enrichment, and MeCab enrichment already happens before the annotation stage, all four can run in a single pass after MeCab completes.
### New Flow (1 pass + N+1)
```typescript
function annotateTokens(tokens, deps, options): MergedToken[] {
const pos1Exclusions = resolvePos1Exclusions(options);
const pos2Exclusions = resolvePos2Exclusions(options);
// Single pass: known word + frequency filtering + JLPT computed together
const annotated = tokens.map((token) => {
const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false;
// Filter frequency rank using POS exclusions (rank values already set at parser level)
const frequencyRank = frequencyEnabled
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
: undefined;
const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined;
return { ...token, isKnown, frequencyRank, jlptLevel };
});
// N+1 must run after known word status is set for all tokens
if (nPlusOneEnabled) {
return markNPlusOneTargets(annotated, minSentenceWords, pos1Exclusions, pos2Exclusions);
}
return annotated;
}
```
### What Changes
- The individual `applyKnownWordMarking`, `applyFrequencyMarking`, `applyJlptMarking` functions are refactored into per-token computation helpers (pure functions that compute a single field). The frequency helper is named `filterFrequencyRank` to clarify it performs POS-based exclusion, not rank computation.
- The `annotateTokens` orchestrator runs one `.map()` call that invokes all three helpers per token.
- `markNPlusOneTargets` remains a separate pass because it needs the full array with `isKnown` set (it examines sentence-level context).
- The parser-level `applyFrequencyRanks()` call in `tokenizer.ts` is unchanged -- it remains a separate step outside the annotation stage.
- Net: 4 array copies + 4 iterations become 1 array copy + 1 iteration + N+1 pass.
### Expected Savings
~15-45ms saved (3 fewer array allocations + 3 fewer full iterations). Annotation drops from ~25-70ms to ~10-25ms.
### Files Affected
- **Modified:** `src/core/services/tokenizer/annotation-stage.ts` -- refactor into batched single-pass
---
## Optimization 3: DOM Template Pooling
### Summary
Replace `document.createElement('span')` calls in the renderer with `templateSpan.cloneNode(false)` from a pre-created template element.
### Current Behavior
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
1. Clears DOM with `innerHTML = ''`
2. Creates a `DocumentFragment`
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
4. Sets `className`, `textContent`, `dataset.*` individually
5. Appends fragment to root
### New Behavior
1. At renderer initialization (`createSubtitleRenderer`), create a single template:
```typescript
const templateSpan = document.createElement('span');
```
2. In `renderWithTokens`, replace every `document.createElement('span')` with:
```typescript
const span = templateSpan.cloneNode(false) as HTMLSpanElement;
```
3. Replace all `innerHTML = ''` calls with `root.replaceChildren()` to avoid the HTML parser invocation on clear. This applies to `renderSubtitle` (primary subtitle root), `renderSecondarySub` (secondary subtitle root), and `renderCharacterLevel` if applicable.
4. Everything else stays the same (setting className, textContent, dataset, appending to fragment).
### Why cloneNode Over Full Node Recycling
Full recycling (collecting old nodes, clearing attributes, reusing them) requires carefully resetting every `dataset.*` property that might have been set on a previous render. This is error-prone -- a stale `data-frequency-rank` from a previous subtitle appearing on a new token would cause incorrect styling. `cloneNode(false)` on a bare template is nearly as fast and produces a clean node every time.
### Expected Savings
`cloneNode(false)` is ~2-3x faster than `createElement` in most browser engines. For 10-15 tokens per subtitle: ~3-8ms saved per render cycle.
### Files Affected
- **Modified:** `src/renderer/subtitle-render.ts` -- template creation + cloneNode usage
---
## Combined Impact Summary
| Scenario | Before | After | Improvement |
| --------------------------------- | ---------- | ---------- | ----------- |
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
---
## Files Summary
### New Files
- `src/core/services/subtitle-prefetch.ts`
- `src/core/services/subtitle-cue-parser.ts`
### Modified Files
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
- `src/renderer/subtitle-render.ts` (template cloneNode)
- `src/main.ts` (wire up prefetch service)
### Test Files
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
- Updated tests for annotation stage (same behavior, new implementation)
- Updated tests for subtitle render (template cloning)
+11 -3
View File
@@ -3,7 +3,7 @@
# Architecture Map
Status: active
Last verified: 2026-05-23
Last verified: 2026-11-01
Owner: Kyle Yasuda
Read when: runtime ownership, composition boundaries, or layering questions
@@ -30,22 +30,30 @@ Update checks and startup launcher migration share a serialized update-state sto
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; `main.ts` supplies window creation and subtitle refresh hooks.
- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; the overlay assembly supplies window creation and subtitle refresh hooks.
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same media. For Jellyfin playback it reads the item's original-file stream (`toJellyfinOriginalStreamUrl`) into one reused output folder, where each run best-effort deletes earlier files and writes a uniquely named SRT; transcoded playback uses Jellyfin's audio index and only external references, since mpv's track indexes describe the transcode. Subprocess errors redact URL tokens; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `src/main/assemblies/plugins.ts` builds the host. The playback assembly attaches each new mpv client so plugin listeners survive client replacement, the startup assembly starts plugins from the app-ready path after logging is configured, and the `will-quit` cleanup unloads them first, before any teardown. Plugins run in the main process with no sandbox. `src/plugin-api.ts` is the standalone public v1 type contract; internal mpv event and session-action types derive from it. Manifests may declare `apiVersion`, which is checked before loading code. Async activation is bounded and actions wait only for their own plugin. The host owns cached media snapshots and playback ids, including repeated loads of the same path. `ctx.signal` aborts as unloading starts; `ctx.onDispose` manages outside resources and late registrations are disposed immediately. Awaitable `ctx.mpv.request` uses the existing request/response transport. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` as `ctx.dataDir`. `ctx.anki` carries the `card-mined` event with unique kept `noteIds` and per-original-note records. Sentence-card creation snapshots media and subtitle context before asynchronous work; the proxy snapshots before forwarding an upstream request. `AnkiIntegration` keeps provenance under original ids through merges and queued YouTube jobs; polling uses null provenance. The event is fed by `AnkiIntegration.setCardsReadyCallback`: polling, the AnkiConnect proxy, and sentence-card creation report a note once its enrichment finishes (or when they see it, if auto-update is off); `AnkiIntegration` holds a sentence card's report until field grouping finishes, and the pending YouTube media queue reports notes whose media it is still waiting on (directly or through the note field grouping merged them into), which is later than `recordCardsMined` (stats counts at add time). Stats-dashboard mining does not emit it. `ctx.anki.invoke` sends one action to the effective `ankiConnect.url` with no retries. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
- `src/renderer/` owns overlay rendering and input behavior.
- `src/config/` owns config definitions, defaults, loading, and resolution.
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
- `src/main/runtime/composers/` owns larger domain compositions.
- `src/main/assemblies/` owns per-domain assemblies that `src/main.ts` builds once (see [Layering](./layering.md)).
- `src/main.ts` call sites invoke configured runtime handlers and runtime-object methods directly;
do not add local pass-through wrappers around them.
## Architecture Intent
The dictionary backend is selected once at startup by `dictionaryBackend`. Yomitan keeps its existing session and external-profile policy. Hachidori uses `persist:hachidori`; overlay windows select that session before extension loading, including deferred startup. Named settings flags can open either backend without injecting a second reader into the active overlay. The detached stats word helper reads the same config key so dashboard mining uses the active backend.
`setup-state.json` records one backend's status at a time plus `completedDictionaryBackends`, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart.
`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, keeps the lookup highlight on in the overlay first-install options (SubMiner captures media from mpv, not the overlay viewport), removes the unsupported `userScripts` permission, and sets a fixed manifest `key` so the extension ID (and the storage origin holding its dictionaries and settings) does not depend on the userData path, only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Dictionary imports go through Hachidori's chunked upload requests (`hachidori-linked-import`): an unlinked install imports the ZIP itself and a linked one forwards it to its host. Linked hosts that do not advertise `linked-import-v1` (Hachidori before 0.2.3) cannot receive uploads, and the import reports where to import the ZIP by hand. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. Attention also covers a left press anywhere on the overlay that may start a selection, and the host element only exists after the first lookup, so once a Hachidori event has been seen, popup auto-pause, popup key routing, controller lookup state, and the gate on imported mpv keys and mouse buttons require an unhidden popup pane in the host's shadow root. Auto-pause rechecks after each successful lookup. Hachidori's popup lives in the overlay document rather than an iframe, so wheel events over its host scroll the popup instead of forwarding to mpv. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.
- Small units, explicit boundaries
- Composition over monoliths
- Pure helpers where possible
+40 -6
View File
@@ -3,7 +3,7 @@
# Domain Ownership
Status: active
Last verified: 2026-07-15
Last verified: 2026-11-01
Owner: Kyle Yasuda
Read when: you need to find the owner module for a behavior or test surface
@@ -19,21 +19,51 @@ Read when: you need to find the owner module for a behavior or test surface
- Config system: `src/config/`; Anki resolution is composed by
`src/config/resolve/anki-connect.ts` from focused resolvers in
`src/config/resolve/anki-connect/`
Config hot-reload and the settings window: `src/main/assemblies/settings.ts`
- App startup: app-ready sequence, headless known-word refresh, overlay startup prerequisites and
overlay runtime bootstrap: `src/main/assemblies/startup.ts`
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
Overlay windows, visibility, geometry and pointer interaction, Linux fullscreen handling, mpv
subtitle suppression while the overlay shows, and the overlay-window IPC handlers (modal
open/close, pointer interaction, content measurement): `src/main/assemblies/overlay.ts`
Overlay-hosted modal openers: `src/main/assemblies/overlay-modals.ts`
- MPV runtime and protocol: `src/core/services/mpv*.ts`
mpv client creation, mpv event wiring, AniSkip, the autoplay-ready gate that releases mpv's
startup pause, managed local subtitle selection, subtitle prefetch, and subtitle sidebar state:
`src/main/assemblies/playback.ts`
- Keyboard input outside the renderer (overlay, global and numeric shortcuts, session bindings):
`src/main/assemblies/shortcuts.ts`
- Session actions (the dispatcher behind shortcuts, controller buttons, CLI commands and plugin
actions), the secondary-subtitle mode cycle, and the overlay renderer's mpv commands:
`src/main/assemblies/session-actions.ts`
- System tray: `src/main/assemblies/tray.ts`
- Yomitan/Hachidori extension loading, parser runtime deps, and dictionary settings windows:
`src/main/assemblies/yomitan.ts`
Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows.
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
JLPT and frequency annotation lookups: `src/main/jlpt-runtime.ts`, `src/main/frequency-dictionary-runtime.ts`, assembled by `src/main/assemblies/dictionaries.ts`
Annotation toggles (a cycled runtime option over its config value): `src/main/runtime/annotation-options.ts`
Whisper subtitle generation runtime, modal and IPC: `src/main/assemblies/subtitle-generation.ts`
Subtitle text filters (`subtitleFilter`): `src/shared/subtitle-text-filter.ts`. Primary text is filtered at its two entry points, live `sub-text` in `mpv-protocol.ts` and parsed cues in `playback.ts`, so every consumer sees the same text and live/cue matching still works. A config change re-reads `sub-text` and re-parses the active track (the prefetch controller's parse key).
- First-run setup: `src/main/runtime/first-run-setup-*.ts`, assembled by `src/main/assemblies/first-run-setup.ts`
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
Card mining handlers, the pending lookup context, field grouping, and media timing review wiring:
`src/main/assemblies/mining.ts`
- Media timing review: `src/main/runtime/media-timing-review.ts` owns the review and hidden `MediaTimingPreviewSession`. The session waits for mpv readiness and acknowledges playback commands; its `time-pos` observations reach the modal through `media-timing-review:preview-position`. The renderer places the cursor from those timestamps and uses an inactivity timeout instead of timing the clip itself.
- Immersion tracking: `src/core/services/immersion-tracker/`
- Immersion tracking: `src/core/services/immersion-tracker/`; tracker startup, the stats server,
the stats overlay window options, and the `--stats` command are assembled by `src/main/assemblies/stats.ts`
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions.
`delete-maintenance-scheduler.ts` coalesces and serializes stats deletes; the expensive work runs in `delete-maintenance-worker-thread.ts` while the tracker queues playback writes. Each batch uses one transaction, lexical update, rollup refresh, and incremental lifetime subtraction (`planLifetimeRemovals`/`applyLifetimeRemovals` in `lifetime.ts`). Merges, moves, AniList reassignments, and `stats cleanup -l` use `repairLifetimeSummariesFromMedia` (recompute from the per-video media ledger). The full lifetime rebuild survives only as the empty-table bootstrap; anywhere else it would collapse lifetime totals to the session retention window.
- Immersion sync: `src/core/services/stats-sync/`, bound by `src/main/sync-cli.ts`.
- Immersion sync: `src/core/services/stats-sync/`, bound by `src/main/sync-cli.ts`. The sync window and auto-sync scheduler live in `src/main/assemblies/sync.ts`.
`snapshot-transfer.ts` selects compressed rsync or scp. `transfer-cache.ts` atomically retains the last successfully received snapshot per hashed peer/database identity under the config directory's `sync-transfer-cache/`. Cache copies seed isolated transfer directories; rsync verifies reconstructed files before the existing merge engine runs. The `--make-temp` / `--remove-temp` helpers accept an internal `--transfer-cache` key, with a fallback for older peers that do not recognize it.
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*` (called by `src/main/assemblies/anilist.ts`), `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`, assembled by `src/main/assemblies/character-dictionary.ts`
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`,
assembled by `src/main/assemblies/jellyfin.ts`
- YouTube playback, subtitles, media cache, and in-app browser: `src/core/services/youtube/`,
`src/main/runtime/youtube-*`, assembled by `src/main/assemblies/youtube.ts`
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/main/runtime/plugin-window-runtime.ts` (plugin windows), assembled by `src/main/assemblies/plugins.ts`; `src/renderer/modals/plugin-panel*.ts`; examples in `assets/plugins/`
- Window trackers: `src/window-trackers/`
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
in `src/core/services/stats-server/`
@@ -63,7 +93,11 @@ the plugin artifact, persistent config, or session help.
The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
identifies the media and subtitle source so renderer selections are invalidated on source changes,
including changes whose cue text and timings are identical. Native selection and clean clipboard
including changes whose cue text and timings are identical. While open, the sidebar polls the
cheap `SubtitleSidebarPlaybackState` (cached time, current line, `sourceKey`, `cuesRevision`; no
mpv round-trips) every 80 ms for active-cue tracking. It fetches the full snapshot, which resolves
the source over mpv IPC and carries every cue plus config, only on open, config reload, a
`sourceKey`/`cuesRevision` change, and a ~2 s fallback. Native selection and clean clipboard
serialization live in `src/renderer/modals/subtitle-sidebar-selection.ts`. Electron lets standard
Copy input reach the renderer, where sidebar selection takes priority over the live-subtitle binding.
The preload bridge writes selections through Electron's clipboard API so copying does not depend
+18 -1
View File
@@ -3,7 +3,7 @@
# Layering Rules
Status: active
Last verified: 2026-05-23
Last verified: 2026-10-31
Owner: Kyle Yasuda
Read when: deciding whether a dependency direction is acceptable
@@ -19,6 +19,23 @@ Renderer, launcher, plugin, and stats each keep their own local layering and sho
## Rules
- Keep `src/main.ts` thin; wire, do not implement.
- Pass deps straight to a handler factory (`createXHandler({...})`). Do not add
deps builders (`createBuild*MainDepsHandler`, `*-main-deps.ts`) that re-wrap deps.
Adaptation logic (defaults, appState getters/setters, shared state) belongs in the
handler or in a named adapter that returns the deps directly.
- Group a domain's runtimes in one assembly under `src/main/assemblies/` (for example
`createYoutubeAssembly(deps)`). It owns the domain's state, takes an explicit deps
object, and returns its runtimes; `main.ts` builds it once and calls through the result.
Siblings built later in `main.ts` are passed as getters or callbacks, and construction must
not call them.
- An assembly that answers renderer IPC or CLI commands returns them as typed slices
(`IpcSlice<...>` / `CliSlice<...>` from `src/main/assemblies/slices.ts`). `main.ts`
spreads the slices into the registration and checks the result with
`satisfies Required<...>`, so a missing handler fails typecheck. Only app-level handlers
with no owning assembly stay inline in `main.ts`.
- Electron modules that value-import named Electron APIs (`dialog`, `screen`, `shell`,
`clipboard`, `globalShortcut`, `Tray`, ...) cannot load under `bun test`. Pass those APIs
into an assembly as deps instead of importing them there.
- Prefer injecting dependencies from `src/main/` instead of reaching outward from core services.
- Keep side effects explicit and close to composition boundaries.
- Put reusable business logic in focused services, not in top-level lifecycle files.
+32 -10
View File
@@ -12,20 +12,42 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
- collapse multiple network round-trips into one request
- keep heavy chart shaping close to tracker/query logic
## Date Ranges And Buckets
- A range resolves to calendar days (`resolveTrendWindow`). Rollups are selected by day span, never by "last N rows", so sparse activity cannot pull older days into a range. Custom ranges are `{ startDay, endDay }` from `range=custom&fromDay&toDay`.
- `all` spans the recorded data, not today.
- Day and week buckets read daily rollups. Month buckets read monthly rollups for months wholly in range, because the `minimal` and `deep-history` retention presets prune daily rollups first, and daily rollups for a partly covered month. Weeks start on Monday.
- `buckets` is the one axis for `activity`, `progress`, and `ratios`, index for index. Charts use it to map a clicked bar back to its days.
- Each `progress` point is the total at the end of its period, and the chart adds the zero starting point.
## Data Sources
- rollup-backed:
- activity charts
- cumulative watch/cards/tokens/sessions trends
- per-anime watch/cards/tokens/episodes series
- activity charts and running totals
- `titleTimeline`: sparse per-title minutes for active days only, because a dense title x day payload grew to several MB
- session-metric-backed:
- lookup trends
- lookup rate trends
- watch-time by day-of-week/hour
- lookups and lookup rate
- `watchTimeByDayHour`, attributed to the hour each session started
- rollup word counts and session metrics share one session query per request (`getTrendSessionRows`), so the per-session display-word count runs once instead of once per rollup query
- `imm_videos.watched`-backed:
- finished episodes count each watched video once, on the last day it has a rollup, since the flag has no timestamp
- vocabulary-backed:
- new-words trend reads permanent daily lexical rollups
- rollup rows count only vocabulary-visible tokens and normalize mixed legacy timestamp units
- a persisted rollup version invalidates stale materializations and triggers an atomic background rebuild
- `getNewWordFirstSeenRows` folds `imm_words` rows into dictionary words first, because the table is unique on (headword, word, reading) and stores each conjugation separately
- the aggregate scans every word, so it is cached per connection until `total_changes()` or `PRAGMA data_version` moves
- Trends new-word metrics exclude names; the Vocabulary tab offers both
- `imm_lexical_daily_rollups` is no longer read for new-word counts (it counts surface forms). Its version state still gates the `vocabulary_visible` backfill.
## Vocabulary Word List
`/api/stats/vocabulary/words` (`query-vocabulary-words.ts`) folds `imm_words` into dictionary words on the server and filters, sorts, and pages there, because a client-side list can only hold a slice of the table. The fold is cached per connection until a write, the same way as the new-word aggregate. `titles` is computed for the returned rows; sorting or filtering by it needs every word's count (about 900ms), so that map tolerates five minutes of staleness. Names are detected with `isProperNounPos2`, since merged tokens store tags like `固有名詞|一般`.
## Rates
Rates are `null` when the underlying data is under 30 active minutes or 2,000 words, and charts draw that as a gap. Daily views rate a trailing 7 calendar days, volume-weighted, which is why the query loads days before the range. Week and month views rate each bucket.
## Title Filter
`title` narrows totals, charts, and patterns. `librarySummary` and `titleTimeline` stay complete so the page can switch titles. New words have no per-title record, so `learning` and `summary.current.newWords` are `null` while filtered.
## Metric Semantics
@@ -39,6 +61,6 @@ Stats data crosses the process boundary over HTTP only. Shared JSON models live
The stats preload bridge remains limited to native window behavior such as confirmation-dialog layering. Do not add stats data request channels back to Electron IPC.
The stats UI should treat the trends payload as chart-ready data. Presentation-only work in the client is fine, but rebuilding the main trend datasets from raw sessions should stay out of the render path.
The stats UI should treat the trends payload as chart-ready data. Presentation-only work in the client is fine (including the title timeline layout), but rebuilding the main trend datasets from raw sessions should stay out of the render path.
For session detail timelines, omitting `limit` now means "return the full retained session telemetry/history". Explicit `limit` remains available for bounded callers, but the default stats UI path should not trim long sessions to the newest 200 samples.
+10 -9
View File
@@ -3,7 +3,7 @@
# Subtitle Overlay Priming
Status: active
Last verified: 2026-08-19
Last verified: 2026-11-01
Owner: Kyle Yasuda
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
@@ -19,13 +19,14 @@ subtitles do not draw.
## Entry Points
- `src/main.ts` calls `primeCurrentSubtitleForVisibleOverlay()` when manual visible-overlay show
paths run.
- `src/main.ts` calls `restoreVisibleOverlayWindowShapeForShow()` before visible-overlay show
actions on Linux, and `resetVisibleOverlayInputState()` restores a full shape instead of applying
an empty shape.
- `src/main.ts` also restores the Linux/X11 shape after applying mpv overlay bounds, so a newly
created 800x600 hidden Electron window cannot keep clipping after it is resized to mpv geometry.
- The overlay assembly (`src/main/assemblies/overlay.ts`) calls
`primeCurrentSubtitleForVisibleOverlay()` when manual visible-overlay show paths run.
- It calls `restoreVisibleOverlayWindowShapeForShow()` before visible-overlay show actions on
Linux, and `resetVisibleOverlayInputState()` restores a full shape instead of applying an empty
shape (both in `src/main/runtime/visible-overlay-interaction-runtime.ts`).
- `src/main/runtime/overlay-geometry-runtime.ts` also restores the Linux/X11 shape after applying
mpv overlay bounds, so a newly created 800x600 hidden Electron window cannot keep clipping after
it is resized to mpv geometry.
- `primeCurrentSubtitleForVisibleOverlay()` delegates to
`primeVisibleOverlaySubtitleFromMpv()` in `src/main/runtime/current-subtitle-snapshot.ts`.
- `restoreVisibleOverlayWindowShapeForShow()` delegates to `restoreLinuxOverlayWindowShape()` in
@@ -54,7 +55,7 @@ Both `onSubtitleChange` and `refreshCurrentSubtitle` pause `subtitlePrefetchServ
the matching `subtitleProcessingController` method, giving the visible overlay priority over
background prefetch work. Prefetch is not re-centered here: restarting the run per line
(`onSeek`) discarded the in-flight tokenization every time the subtitle changed, so only real
seeks restart it (see `onTimePosUpdate` in `src/main.ts`).
seeks restart it (see the time-pos handling in `src/main/assemblies/playback.ts`).
On an uncached autoplay prime the raw payload is emitted here and reported to the controller with
`notePlainSubtitleEmitted`, so the controller skips its own plain emit for that line and the
+1
View File
@@ -21,6 +21,7 @@ Read when: finding internal docs or checking verification status
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-23 | repo-local workflow skill ownership |
| Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes |
| Testing guidelines | `docs/workflow/testing.md` | active | 2026-10-04 | test style and anti-patterns |
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
## Update Rules
+1
View File
@@ -13,6 +13,7 @@ This section is the internal workflow map for contributors and agents.
- [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan
- [Verification](./verification.md) - maintained test/build lanes and handoff gate
- [Testing](./testing.md) - what a good test looks like and what not to write
- [Agent Skills](./agent-skills.md) - repo-local workflow skill ownership
- [Release Guide](../RELEASING.md) - tagged release workflow
+48
View File
@@ -0,0 +1,48 @@
<!-- read_when: writing, reviewing, or deleting tests -->
# Testing Guidelines
Status: active
Last verified: 2026-10-04
Owner: Kyle Yasuda
Read when: adding or changing tests
Which lanes to run lives in [Verification](./verification.md). This page covers what a
good test looks like here.
## Test Behavior, Not Wiring
- Exercise a module through its public interface and assert outcomes: return values,
resulting state, files written, rows in sqlite, messages sent to a fake process.
- Prefer real collaborators that are cheap: tmp dirs, in-memory or tmp sqlite, fake
executables, pure parsers. Fake only the edges (Electron, mpv socket, network).
- Assert call order only when order is the behavior (startup sequencing, ABA races),
and then assert relative order, not the whole call log.
## Do Not Write
- Pass-through tests: a handler or adapter that only forwards deps
(`*-runtime-handlers.ts`) needs no test. TypeScript already checks the shape.
- Source-text tests: reading `.ts`, `.css`, `.html`, `.lua`, `.swift`, or workflow YAML
and asserting with regexes. If the invariant matters, extract the logic and run it
(see `src/preload-ipc-listeners.ts`). Policy lints over workflow YAML are the
exception and belong in one parsed-YAML check.
- "Stays deleted" tests for removed modules, flags, or selectors. `tsc` and review
catch reintroduction.
- Tests that restate constants, defaults, labels, or window options.
- The same rule at several layers. Test a rule once where it lives; keep one
end-to-end case (for the tokenizer, the golden corpus) for integration.
## Keep Tests Small
- Shared setup goes in a `makeDeps(overrides)` helper or a `*-test-harness.ts` file, not
pasted into every test.
- Near-identical cases become a table: `for (const c of cases) test(c.name, ...)`.
- Inject timers, clocks, and loggers instead of patching globals or private members.
- A bug fix gets a regression test for the behavior that broke, named after the
behavior, not the ticket.
## Known Debt
- `src/renderer/renderer-init-order.test.ts` still regexes `renderer.ts` to guard startup
ordering. Replace it with a behavioral test as that logic moves out of `renderer.ts`.
+59 -5
View File
@@ -3,7 +3,7 @@
# Verification
Status: active
Last verified: 2026-08-13
Last verified: 2026-10-05
Owner: Kyle Yasuda
Read when: selecting the right verification lane for a change
@@ -14,8 +14,9 @@ Read when: selecting the right verification lane for a change
files in `package.json`.
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
(per-file isolation with a wall timeout) so a hanging test or leaked global in
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
`--single-process` restores the shared-process mode for debugging.
one file cannot cascade into the rest of the lane. Files run in parallel, one
worker per CPU by default; `--jobs 1` runs serially and `--single-process`
restores the shared-process mode for debugging.
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
unit, and `scripts/**`.
- `.github/workflows/quality-gate.yml` is the reusable `workflow_call` gate for
@@ -57,12 +58,15 @@ bun run docs:build
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / compiled behavior after `bun run build`: `bun run test:runtime:compat`
- Stats dashboard UI: `bun run test:stats`
- Overlay, renderer, mpv, or mining behavior that unit tests cannot show: `bun run build`,
then `bun run test:e2e` (see [Headless End-to-End Harness](#headless-end-to-end-harness))
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
On headless Linux: `xvfb-run -a bun run test:package release/linux-unpacked/resources`.
On headless Linux: `xvfb-run -a bun run test:package dist/release/linux-unpacked/resources`.
Content checks run inside the electron-builder afterPack hook. See the
[release guide](../RELEASING.md#package-contents-checks) for the
installed-app verification checklist.
- Dictionary backend windows: after a full build, run `xvfb-run -a bun run test:dictionary:electron` on headless Linux (CI runs it in the Linux E2E job). It uses temporary profiles to check both named settings commands, backend session isolation, the overlay's external-link bridge, and Hachidori's native dictionary parser.
- Coverage for the maintained source lane: `bun run test:coverage:src`
- Deep/local full gate: default handoff gate above
@@ -70,7 +74,7 @@ bun run docs:build
- `bun run test:coverage:src` runs the same discovered `bun-src-full` membership as
`test:src` through a sharded coverage runner: one Bun coverage process per test
file, then merged LCOV output.
file (parallel, one per CPU unless `--jobs N`), then merged LCOV output.
- A failing coverage shard stops the runner with a nonzero status. Coverage is a
source test gate, not a report-only step.
- Machine-readable output lands at `coverage/test-src/lcov.info`.
@@ -92,6 +96,56 @@ bun run docs:build
- This is not a full Electron UI startup check. It does not require a display and
makes no claims about renderer, window, tray, or mpv behavior.
## Headless End-to-End Harness
`bun run test:e2e` boots the built app against a real mpv and asserts on what the
overlay renders and what reaches AnkiConnect. Use it instead of a live desktop test
whenever the behavior does not depend on a specific compositor.
- Needs an existing `bun run build`, plus `mpv` and `ffmpeg` on `PATH` (and `Xvfb` on
Linux). It is not part of `test:fast` or the handoff gate. The `e2e` job in
`.github/workflows/ci.yml` runs it on Linux, macOS, and Windows for pull requests and
`main`, outside the reusable quality gate, and uploads the session logs when it fails.
- Every run is sandboxed in a temp dir: its own config and Electron profile, mpv IPC
socket, and a fake AnkiConnect on a free port. It never touches the user's running
SubMiner, Anki, or stats database. It is not offline: the app still asks AniList for
cover art matching the clip's title.
- Media is synthetic: an ffmpeg test-pattern clip, a three-cue Japanese `.srt`, and a
tiny Yomitan dictionary covering those cues (`e2e/harness/fixtures.ts`). The harness
imports the dictionary on a first boot, then restarts the app for the scenario.
- Display: Linux runs everything inside a private Xvfb server, so no window reaches the
desktop. macOS and Windows have no offscreen display server, so windows open in the
current session there; `SUBMINER_E2E_DISPLAY=host` forces the same on Linux. The
CI job runs all three. On macOS and Windows the app pre-creates a hidden modal window
that loads the same renderer document, so window matching must include the
`layer=visible` query (`OVERLAY_PAGE`).
- The app is driven over the Chrome DevTools Protocol (DOM queries, input events,
per-window screenshots), mpv over its JSON IPC socket, and app commands through the
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through
`startE2eSession()`. Pass `config` to override settings for a scenario file, and `files`
to write files such as plugins into the isolated config dir before launch.
- On a failed startup the temp dir is kept and its path is in the error. Set
`SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`,
`mpv-stdio.log`).
For exploratory checks, keep one session up and poke at it:
```bash
bun run e2e start & # prints the session JSON, stays up until stopped
bun run e2e mpv seek 2 absolute+exact
bun run e2e eval overlay 'document.getElementById("subtitleRoot").textContent'
bun run e2e shot overlay /tmp/overlay.png
bun run e2e app --mine-sentence
bun run e2e anki # AnkiConnect requests received so far
bun run e2e stop
```
Out of scope: compositor-specific behavior (Hyprland placement, stacking, fullscreen,
click-through), real GPU video output, and global shortcuts. Xvfb runs without a window
manager or compositor, so the transparent overlay is not composited over the video;
check overlay visuals with per-window screenshots, and use the live desktop for the rest.
## Dependency Audit Policy
- `bun audit --audit-level high` blocks the reusable quality gate.
+160
View File
@@ -0,0 +1,160 @@
// Keeps one isolated e2e session running so a change can be poked at by hand
// (or by an agent) without touching the real desktop:
//
// bun run e2e start boot and stay up until `stop` or Ctrl-C
// bun run e2e eval <page> <expression> evaluate JS in a window (or "main" for the main process)
// bun run e2e shot <page> <file.png> save a screenshot of a window
// bun run e2e mpv <command> [args...] send an mpv IPC command (args parsed as JSON)
// bun run e2e app <args...> run a SubMiner CLI command against the instance
// bun run e2e anki print the AnkiConnect requests received so far
// bun run e2e stop
//
// <page> is a URL fragment: "overlay" is shorthand for the overlay window.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { connectCdp, connectCdpPage, listCdpTargets } from './harness/cdp';
import { connectMpv } from './harness/mpv';
import { OVERLAY_PAGE, runAppCommand, startE2eSession, type AppTarget } from './harness/session';
import { waitUntil } from './harness/wait';
type SessionState = {
pid: number;
root: string;
display: string;
cdpPort: number;
inspectorUrl: string;
mpvSocketPath: string;
ankiUrl: string;
target: AppTarget;
};
const stateFile = path.join(os.tmpdir(), 'subminer-e2e-session.json');
function readState(): SessionState {
if (!fs.existsSync(stateFile)) {
throw new Error('No e2e session is running. Start one with `bun run e2e start`.');
}
return JSON.parse(fs.readFileSync(stateFile, 'utf8')) as SessionState;
}
function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function parseJsonOrString(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
async function connectNamedPage(state: SessionState, pageName: string) {
if (pageName === 'main') return connectCdp(state.inspectorUrl, 'main process');
const urlPart = pageName === 'overlay' ? OVERLAY_PAGE : pageName;
const targets = await listCdpTargets(state.cdpPort);
const target = targets.find((candidate) => candidate.url.includes(urlPart));
if (!target) {
const open = targets.map((candidate) => ` ${candidate.type} ${candidate.url.slice(0, 100)}`);
throw new Error(`No window matches "${pageName}". Open targets:\n${open.join('\n')}`);
}
return connectCdpPage(target);
}
async function withPage<T>(
state: SessionState,
pageName: string,
use: (page: Awaited<ReturnType<typeof connectCdpPage>>) => Promise<T>,
): Promise<T> {
const page = await connectNamedPage(state, pageName);
try {
return await use(page);
} finally {
page.close();
}
}
async function start(): Promise<void> {
if (fs.existsSync(stateFile) && isAlive(readState().pid)) {
throw new Error('An e2e session is already running. Stop it with `bun run e2e stop`.');
}
const session = await startE2eSession();
const state: SessionState = {
pid: process.pid,
root: session.root,
display: session.display,
cdpPort: session.cdpPort,
inspectorUrl: session.inspectorUrl,
mpvSocketPath: session.mpvSocketPath,
ankiUrl: session.anki.url,
target: session.target,
};
// The state file grants main-process code evaluation via inspectorUrl, so keep it
// owner-only. 'wx' creates it exclusively: it fails rather than follow a symlink
// planted in the shared temp dir after the stale file is removed.
fs.rmSync(stateFile, { force: true });
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), { mode: 0o600, flag: 'wx' });
console.log(JSON.stringify(state, null, 2));
await new Promise<void>((resolve) => {
process.once('SIGINT', resolve);
process.once('SIGTERM', resolve);
});
fs.rmSync(stateFile, { force: true });
await session.dispose();
}
async function main(): Promise<void> {
const [command, ...args] = process.argv.slice(2);
if (command === 'start') return start();
const state = readState();
switch (command) {
case 'eval': {
const [pageName, expression] = args;
if (!pageName || !expression) throw new Error('Usage: eval <page> <expression>');
const value = await withPage(state, pageName, (page) => page.evaluate<unknown>(expression));
console.log(JSON.stringify(value, null, 2));
return;
}
case 'shot': {
const [pageName, outputPath] = args;
if (!pageName || !outputPath) throw new Error('Usage: shot <page> <file.png>');
fs.writeFileSync(outputPath, await withPage(state, pageName, (page) => page.screenshot()));
return;
}
case 'mpv': {
if (args.length === 0) throw new Error('Usage: mpv <command> [args...]');
const mpv = await connectMpv(state.mpvSocketPath);
try {
console.log(JSON.stringify((await mpv.command(...args.map(parseJsonOrString))) ?? null));
} finally {
mpv.close();
}
return;
}
case 'app':
await runAppCommand(state.target, args);
return;
case 'anki':
console.log(JSON.stringify(await (await fetch(state.ankiUrl)).json(), null, 2));
return;
case 'stop':
process.kill(state.pid, 'SIGTERM');
await waitUntil(() => !isAlive(state.pid), { description: 'e2e session to exit' });
return;
default:
throw new Error(`Unknown command "${command ?? ''}". See the header of e2e/cli.ts.`);
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
+133
View File
@@ -0,0 +1,133 @@
import { waitUntil, type Truthy, type WaitOptions } from './wait';
export type CdpTarget = {
type: string;
title: string;
url: string;
webSocketDebuggerUrl: string;
};
export type CdpPage = {
/** Sends a raw DevTools protocol command, for anything the helpers below do not cover. */
send: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
/** Evaluates an expression in the page, awaiting promises, and returns its JSON value. */
evaluate: <T>(expression: string) => Promise<T>;
/** Polls an expression until it is truthy and returns that value. */
waitFor: <T>(expression: string, options: WaitOptions) => Promise<Truthy<T>>;
/** Console output and uncaught exceptions seen since connecting, oldest first. */
console: string[];
/** PNG of the page as rendered by Chromium; needs no OS screen-capture permission. */
screenshot: () => Promise<Buffer>;
close: () => void;
};
type CdpReply = {
id?: number;
method?: string;
params?: Record<string, unknown>;
error?: { message: string };
result?: Record<string, unknown>;
};
type RemoteObject = { value?: unknown; description?: string };
// Renders a console/exception event as one log line.
function formatConsoleEvent(method: string, params: Record<string, unknown>): string | null {
if (method === 'Runtime.consoleAPICalled') {
const args = Array.isArray(params.args) ? (params.args as RemoteObject[]) : [];
const text = args.map((arg) => arg.description ?? JSON.stringify(arg.value)).join(' ');
return `console.${String(params.type)}: ${text}`;
}
if (method === 'Runtime.exceptionThrown') {
const details = params.exceptionDetails as
| { text?: string; exception?: RemoteObject }
| undefined;
return `exception: ${details?.exception?.description ?? details?.text ?? 'unknown'}`;
}
if (method === 'Log.entryAdded') {
const entry = params.entry as { level?: string; source?: string; text?: string } | undefined;
return `${String(entry?.source)}.${String(entry?.level)}: ${String(entry?.text)}`;
}
return null;
}
type EvaluateResult = {
result: { value?: unknown };
exceptionDetails?: { text: string; exception?: { description?: string } };
};
export async function listCdpTargets(port: number): Promise<CdpTarget[]> {
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
return (await response.json()) as CdpTarget[];
}
export function connectCdpPage(target: CdpTarget): Promise<CdpPage> {
return connectCdp(target.webSocketDebuggerUrl, target.url);
}
/** Connects to any DevTools endpoint: a renderer page or Electron's main-process inspector. */
export async function connectCdp(webSocketDebuggerUrl: string, label: string): Promise<CdpPage> {
const socket = new WebSocket(webSocketDebuggerUrl);
await new Promise<void>((resolve, reject) => {
socket.addEventListener('open', () => resolve(), { once: true });
socket.addEventListener('error', () => reject(new Error(`CDP connection to ${label} failed`)), {
once: true,
});
});
const pending = new Map<number, (reply: CdpReply) => void>();
const consoleLines: string[] = [];
let nextId = 1;
socket.addEventListener('message', (event) => {
const reply: CdpReply = JSON.parse(String(event.data));
if (reply.id === undefined) {
if (reply.method) {
const line = formatConsoleEvent(reply.method, reply.params ?? {});
if (line) consoleLines.push(line);
}
return;
}
pending.get(reply.id)?.(reply);
pending.delete(reply.id);
});
const send = (method: string, params: Record<string, unknown> = {}) =>
new Promise<Record<string, unknown>>((resolve, reject) => {
const id = nextId++;
pending.set(id, (reply) => {
if (reply.error) reject(new Error(`CDP ${method} failed: ${reply.error.message}`));
else resolve(reply.result ?? {});
});
socket.send(JSON.stringify({ id, method, params }));
});
const evaluate = async <T>(expression: string): Promise<T> => {
const reply = (await send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
})) as EvaluateResult;
if (reply.exceptionDetails) {
const { text, exception } = reply.exceptionDetails;
throw new Error(`Page evaluation threw: ${exception?.description ?? text}`);
}
return reply.result.value as T;
};
await send('Runtime.enable');
// The Node inspector behind Electron's main process has no Log domain.
await send('Log.enable').catch(() => undefined);
return {
send,
evaluate,
console: consoleLines,
waitFor: <T>(expression: string, options: WaitOptions) =>
waitUntil(() => evaluate<T>(expression), options),
screenshot: async () => {
const reply = await send('Page.captureScreenshot', { format: 'png' });
return Buffer.from(String(reply.data), 'base64');
},
close: () => socket.close(),
};
}
+75
View File
@@ -0,0 +1,75 @@
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { stopProcess, type EnvDelta } from './process';
export const E2E_SCREEN = { width: 1280, height: 720 } as const;
export type E2eDisplay = {
kind: 'xvfb' | 'host';
env: EnvDelta;
stop: () => Promise<void>;
};
// Linux gets a private Xvfb server so no window ever reaches the real desktop.
// macOS and Windows have no offscreen display server, so windows open in the
// current GUI session there. SUBMINER_E2E_DISPLAY=host forces that on Linux too.
export async function startDisplay(): Promise<E2eDisplay> {
const requested = process.env.SUBMINER_E2E_DISPLAY?.trim().toLowerCase();
if (requested && requested !== 'host' && requested !== 'xvfb') {
throw new Error(`SUBMINER_E2E_DISPLAY must be "host" or "xvfb", got "${requested}"`);
}
const kind = requested ?? (process.platform === 'linux' ? 'xvfb' : 'host');
if (kind === 'host') {
return { kind, env: { set: {}, unset: [] }, stop: async () => {} };
}
return startXvfb();
}
async function startXvfb(): Promise<E2eDisplay> {
// -displayfd makes Xvfb pick a free display number and write it to fd 3 once
// it accepts connections, so there is no port guessing and no startup sleep.
const child = spawn(
'Xvfb',
[
'-displayfd',
'3',
'-screen',
'0',
`${E2E_SCREEN.width}x${E2E_SCREEN.height}x24`,
'-nolisten',
'tcp',
],
{ stdio: ['ignore', 'ignore', 'ignore', 'pipe'] },
);
const displayFd = child.stdio[3];
if (!displayFd) throw new Error('Xvfb did not expose its display fd');
const displayNumber = await Promise.race([
once(displayFd, 'data').then(([chunk]) => String(chunk).trim()),
once(child, 'error').then(([error]) => {
throw new Error(
`Could not start Xvfb (${String(error)}). Install it or set SUBMINER_E2E_DISPLAY=host.`,
);
}),
once(child, 'exit').then(([code]) => {
throw new Error(`Xvfb exited during startup with code ${String(code)}`);
}),
]);
return {
kind: 'xvfb',
env: {
set: { DISPLAY: `:${displayNumber}`, XDG_SESSION_TYPE: 'x11' },
// Without these the app and mpv would pick the real Wayland session, or
// take the compositor-specific code paths of the desktop running the tests.
unset: [
'WAYLAND_DISPLAY',
'HYPRLAND_INSTANCE_SIGNATURE',
'SWAYSOCK',
'XDG_CURRENT_DESKTOP',
'XDG_SESSION_DESKTOP',
],
},
stop: () => stopProcess(child),
};
}
+136
View File
@@ -0,0 +1,136 @@
import { once } from 'node:events';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
export type AnkiRequest = { action: string; params: Record<string, unknown> };
export type FakeAnki = {
url: string;
/** Every AnkiConnect action received so far, with `multi` batches flattened. */
requests: AnkiRequest[];
stop: () => Promise<void>;
};
export const FAKE_ANKI_DECK = 'E2E';
export const FAKE_ANKI_MODEL = 'Lapis';
const MODEL_FIELDS = [
'Expression',
'ExpressionFurigana',
'ExpressionReading',
'ExpressionAudio',
'SelectionText',
'MainDefinition',
'Sentence',
'SentenceFurigana',
'SentenceAudio',
'Picture',
'Glossary',
'IsWordAndSentenceCard',
'IsClickCard',
'IsSentenceCard',
'IsAudioCard',
'MiscInfo',
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
// A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type.
// Added notes (with later `updateNoteFields` writes) are echoed back by `notesInfo`
// so the app can resolve their fields; scenarios still assert on `requests`.
export async function startFakeAnki(): Promise<FakeAnki> {
const requests: AnkiRequest[] = [];
const addedNotes = new Map<number, Record<string, unknown>>();
let nextNoteId = 1000;
const handle = (body: unknown): unknown => {
if (!isRecord(body) || typeof body.action !== 'string') return null;
const params = isRecord(body.params) ? body.params : {};
if (body.action === 'multi') {
return Array.isArray(params.actions) ? params.actions.map(handle) : [];
}
requests.push({ action: body.action, params });
switch (body.action) {
case 'version':
return 6;
case 'deckNames':
return [FAKE_ANKI_DECK];
case 'modelNames':
return [FAKE_ANKI_MODEL];
case 'modelFieldNames':
return MODEL_FIELDS;
case 'addNote': {
const noteId = nextNoteId++;
const note = isRecord(params.note) ? params.note : {};
addedNotes.set(noteId, isRecord(note.fields) ? { ...note.fields } : {});
return noteId;
}
case 'updateNoteFields': {
const note = isRecord(params.note) ? params.note : {};
const fields = typeof note.id === 'number' ? addedNotes.get(note.id) : undefined;
if (fields && isRecord(note.fields)) Object.assign(fields, note.fields);
return null;
}
case 'notesInfo': {
// Like AnkiConnect: one entry per requested id, `{}` for unknown notes.
const ids = Array.isArray(params.notes) ? params.notes : [];
return ids.map((id) => {
const fields = typeof id === 'number' ? addedNotes.get(id) : undefined;
if (!fields) return {};
return {
noteId: id,
modelName: FAKE_ANKI_MODEL,
tags: [],
fields: Object.fromEntries(
MODEL_FIELDS.map((name, order) => [
name,
{ value: String(fields[name] ?? ''), order },
]),
),
};
});
}
case 'findNotes':
return [];
case 'storeMediaFile':
return params.filename ?? null;
default:
return null;
}
};
const server = http.createServer((request, response) => {
response.setHeader('content-type', 'application/json');
// GET exposes the request log so the session CLI can read it from outside.
if (request.method === 'GET') {
response.end(JSON.stringify(requests));
return;
}
const chunks: Buffer[] = [];
request.on('data', (chunk: Buffer) => chunks.push(chunk));
request.on('end', () => {
let body: unknown = null;
try {
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
// Malformed bodies get the same null result AnkiConnect gives unknown actions.
}
response.end(JSON.stringify({ result: handle(body), error: null }));
});
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const { port } = server.address() as AddressInfo;
return {
url: `http://127.0.0.1:${port}`,
requests,
stop: async () => {
server.closeAllConnections();
server.close();
await once(server, 'close');
},
};
}
+92
View File
@@ -0,0 +1,92 @@
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import { promisify } from 'node:util';
import { writeStoredZipAsync } from '../../src/shared/stored-zip';
const execFileAsync = promisify(execFile);
export const FIXTURE_DICTIONARY_TITLE = 'SubMiner E2E Fixture';
export const FIXTURE_CLIP_SECONDS = 15;
/** Cues of the synthetic clip. Seek to `at` to land inside a cue. */
export const FIXTURE_CUES = [
{ start: 1, end: 4, at: 2, text: '今日はいい天気ですね。' },
{ start: 5, end: 8, at: 6, text: '猫が好きです。' },
{ start: 9, end: 12, at: 10, text: '明日は学校に行きます。' },
] as const;
// [term, reading, part of speech, gloss] for every content word in FIXTURE_CUES.
const FIXTURE_TERMS = [
['今日', 'きょう', 'n', 'today'],
['いい', 'いい', 'adj-i', 'good'],
['天気', 'てんき', 'n', 'weather'],
['猫', 'ねこ', 'n', 'cat'],
['好き', 'すき', 'adj-na', 'liked'],
['明日', 'あした', 'n', 'tomorrow'],
['学校', 'がっこう', 'n', 'school'],
['行く', 'いく', 'v5', 'to go'],
] as const;
function srtTimestamp(seconds: number): string {
return `00:00:${String(seconds).padStart(2, '0')},000`;
}
export function writeFixtureSubtitles(outputPath: string): void {
const blocks = FIXTURE_CUES.map(
(cue, index) =>
`${index + 1}\n${srtTimestamp(cue.start)} --> ${srtTimestamp(cue.end)}\n${cue.text}\n`,
);
fs.writeFileSync(outputPath, blocks.join('\n'), 'utf8');
}
/** Renders a deterministic test-pattern clip with a sine tone, so no real media is needed. */
export async function generateFixtureClip(outputPath: string): Promise<void> {
await execFileAsync('ffmpeg', [
'-nostdin',
'-v',
'error',
'-y',
'-f',
'lavfi',
'-i',
`testsrc2=size=640x360:rate=24:duration=${FIXTURE_CLIP_SECONDS}`,
'-f',
'lavfi',
'-i',
`sine=frequency=440:duration=${FIXTURE_CLIP_SECONDS}`,
'-c:v',
'libx264',
'-preset',
'ultrafast',
'-pix_fmt',
'yuv420p',
'-c:a',
'aac',
outputPath,
]);
}
/** Writes a minimal Yomitan term dictionary so the tokenizer has real entries to match. */
export async function buildFixtureDictionary(outputPath: string): Promise<void> {
const index = {
title: FIXTURE_DICTIONARY_TITLE,
revision: '1',
format: 3,
author: 'SubMiner',
description: 'Synthetic dictionary for the e2e harness.',
};
const termBank = FIXTURE_TERMS.map(([term, reading, partOfSpeech, gloss], sequence) => [
term,
reading,
'',
partOfSpeech,
0,
[gloss],
sequence + 1,
'',
]);
await writeStoredZipAsync(outputPath, [
{ name: 'index.json', data: Buffer.from(JSON.stringify(index), 'utf8') },
{ name: 'term_bank_1.json', data: Buffer.from(JSON.stringify(termBank), 'utf8') },
]);
}
+133
View File
@@ -0,0 +1,133 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs';
import net from 'node:net';
import type { E2eDisplay } from './display';
import { E2E_SCREEN } from './display';
import { applyEnvDelta } from './process';
import { waitUntil } from './wait';
export type MpvClient = {
/** Sends one mpv JSON IPC command and resolves with its `data` field. */
command: (...args: unknown[]) => Promise<unknown>;
close: () => void;
};
type MpvReply = { request_id?: number; error?: string; data?: unknown };
function videoOutputArgs(display: E2eDisplay): string[] {
// Xvfb has no GPU; the software X11 output always works there.
if (display.kind === 'xvfb') return ['--vo=x11'];
// gpu-next's Vulkan path (libplacebo over MoltenVK) segfaults on the first frame
// on GitHub's paravirtual macOS GPU with libplacebo 7.387. `--vo=libmpv` is mpv's
// cocoa-cb OpenGL output, which avoids it and still opens the window the overlay tracks.
// Each new decoder asks that render thread for a direct-rendering image and waits with no
// timeout. cocoa-cb only answers from its main queue, which can stall on this runner, so a
// `loadfile` could freeze mpv's core and its IPC for good. `--vd-lavc-dr=no` skips the request.
if (process.platform === 'darwin') return ['--vo=libmpv', '--vd-lavc-dr=no'];
return [];
}
export async function startMpv(options: {
socketPath: string;
mediaPath: string;
subtitlePath: string;
/** mpv's own verbose log (`--log-file`); `--no-terminal` silences its console output. */
logPath: string;
/** Process stdout/stderr, which still catches crash output from mpv's libraries. */
stdioLogPath: string;
display: E2eDisplay;
}): Promise<ChildProcess> {
const log = fs.openSync(options.stdioLogPath, 'w');
const child = spawn(
'mpv',
[
'--no-config',
'--no-terminal',
`--log-file=${options.logPath}`,
'--pause',
'--keep-open=yes',
'--ao=null',
`--geometry=${E2E_SCREEN.width}x${E2E_SCREEN.height}+0+0`,
...videoOutputArgs(options.display),
`--input-ipc-server=${options.socketPath}`,
`--sub-file=${options.subtitlePath}`,
options.mediaPath,
],
{ env: applyEnvDelta(process.env, options.display.env), stdio: ['ignore', log, log] },
);
fs.closeSync(log);
await Promise.race([
once(child, 'spawn'),
once(child, 'error').then(([error]) => {
throw new Error(`Could not start mpv (${String(error)}). Is it on PATH?`);
}),
]);
return child;
}
function tryConnect(socketPath: string): Promise<net.Socket | null> {
return new Promise((resolve) => {
const socket = net.connect(socketPath);
socket.once('connect', () => resolve(socket));
socket.once('error', () => resolve(null));
});
}
/**
* Connects to mpv's JSON IPC socket. Once the socket closes (mpv exited or
* crashed), pending and later commands reject instead of hanging until the test
* timeout; `describeClose` adds context such as mpv's exit status.
*/
export async function connectMpv(
socketPath: string,
describeClose: () => string = () => 'mpv IPC connection closed',
): Promise<MpvClient> {
const socket = await waitUntil(() => tryConnect(socketPath), {
description: `mpv IPC socket ${socketPath}`,
});
const pending = new Map<number, (reply: MpvReply) => void>();
let nextRequestId = 1;
let buffer = '';
let closedError: Error | null = null;
const failPending = () => {
closedError ??= new Error(describeClose());
for (const settle of pending.values()) settle({ error: closedError.message });
pending.clear();
};
socket.on('close', failPending);
// 'close' always follows 'error'; listening here keeps the error from being thrown.
socket.on('error', () => {});
socket.setEncoding('utf8');
socket.on('data', (chunk: string) => {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
const reply: MpvReply = JSON.parse(line);
if (reply.request_id === undefined) continue;
pending.get(reply.request_id)?.(reply);
pending.delete(reply.request_id);
}
});
return {
command: (...args) =>
new Promise((resolve, reject) => {
if (closedError) {
reject(new Error(`mpv ${JSON.stringify(args)} failed: ${closedError.message}`));
return;
}
const requestId = nextRequestId++;
pending.set(requestId, (reply) => {
if (reply.error === 'success') resolve(reply.data);
else reject(new Error(`mpv ${JSON.stringify(args)} failed: ${reply.error}`));
});
socket.write(`${JSON.stringify({ command: args, request_id: requestId })}\n`);
}),
close: () => socket.destroy(),
};
}
+24
View File
@@ -0,0 +1,24 @@
import type { ChildProcess } from 'node:child_process';
import { once } from 'node:events';
export type EnvDelta = { set: Record<string, string>; unset: string[] };
export function applyEnvDelta(base: NodeJS.ProcessEnv, ...deltas: EnvDelta[]): NodeJS.ProcessEnv {
const env = { ...base };
for (const delta of deltas) {
for (const name of delta.unset) delete env[name];
Object.assign(env, delta.set);
}
return env;
}
/** Terminates a child and waits for it to exit, escalating to SIGKILL after `graceMs`. */
export async function stopProcess(child: ChildProcess, graceMs = 5_000): Promise<void> {
// A child that never spawned (missing binary) has no pid and never emits exit.
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
const exited = once(child, 'exit');
child.kill();
const timer = setTimeout(() => child.kill('SIGKILL'), graceMs);
await exited;
clearTimeout(timer);
}
+360
View File
@@ -0,0 +1,360 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { connectCdp, connectCdpPage, listCdpTargets, type CdpPage } from './cdp';
import { startDisplay, type E2eDisplay } from './display';
import { FAKE_ANKI_DECK, FAKE_ANKI_MODEL, startFakeAnki, type FakeAnki } from './fake-anki';
import { buildFixtureDictionary, generateFixtureClip, writeFixtureSubtitles } from './fixtures';
import { connectMpv, startMpv, type MpvClient } from './mpv';
import { applyEnvDelta, stopProcess, type EnvDelta } from './process';
import { waitUntil } from './wait';
import {
shouldForceX11ElectronBackend,
X11_ELECTRON_BOOTSTRAP_ENV,
} from '../../src/core/utils/electron-backend';
const repoRoot = path.resolve(__dirname, '..', '..');
/** URL fragments identifying the app's windows among the CDP targets. The modal
* window loads the same document with `layer=modal`, so the layer is required. */
export const OVERLAY_PAGE = 'renderer/index.html?layer=visible';
const YOMITAN_SETTINGS_PAGE = 'settings.html';
type JsonObject = { [key: string]: JsonValue };
type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
export type E2eSessionOptions = {
/** Deep-merged over the harness config, for scenarios that need other settings. */
config?: JsonObject;
/** Files written before launch, keyed by path relative to the config dir (e.g. plugins). */
files?: Record<string, string>;
};
export type AppTarget = {
/** Environment changes that point a process at the isolated instance. */
env: EnvDelta;
/** Electron/Chromium switches every launch needs. */
electronArgs: string[];
/** Receives stdout and stderr of every launch. */
logPath: string;
};
export type E2eSession = {
/** Temp dir holding the isolated config, fixtures, sockets, and logs. */
root: string;
display: E2eDisplay['kind'];
cdpPort: number;
/** DevTools endpoint of the Electron main process (Node inspector). */
inspectorUrl: string;
mpvSocketPath: string;
/** How to start a process that talks to this isolated instance. */
target: AppTarget;
mpv: MpvClient;
anki: FakeAnki;
/** Connects to the first window or frame whose URL contains `urlPart`, waiting for it to exist. */
openPage: (urlPart: string) => Promise<CdpPage>;
/** Evaluates in the main process; reach modules via `process.mainModule.require`. */
mainProcess: () => Promise<CdpPage>;
/** Runs a CLI command (e.g. `--mine-sentence`) against the running instance. */
runAppCommand: (...args: string[]) => Promise<void>;
/** After loading a file: waits for the app's autoplay resume, then leaves mpv paused. */
settleAfterResume: () => Promise<void>;
dispose: () => Promise<void>;
};
function isJsonObject(value: JsonValue | undefined): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function mergeConfig(base: JsonObject, overrides: JsonObject): JsonObject {
const merged = { ...base };
for (const [key, value] of Object.entries(overrides)) {
const existing = merged[key];
merged[key] =
isJsonObject(existing) && isJsonObject(value) ? mergeConfig(existing, value) : value;
}
return merged;
}
// Everything that could reach outside the sandbox (network services, the real
// Anki, ports the user's own SubMiner holds) is off or pointed at a fake.
function buildConfig(mpvSocketPath: string, ankiUrl: string): JsonObject {
return {
mpv: { socketPath: mpvSocketPath, aniskipEnabled: false },
ankiConnect: {
url: ankiUrl,
deck: FAKE_ANKI_DECK,
proxy: { enabled: false },
isLapis: { enabled: true, sentenceCardModel: FAKE_ANKI_MODEL },
},
stats: { autoStartServer: false },
updates: { enabled: false },
discordPresence: { enabled: false },
websocket: { enabled: false },
annotationWebsocket: { enabled: false },
texthooker: { launchAtStartup: false },
};
}
// Outside Electron the package's entry point is the path of its binary, and
// resolving it downloads the binary when an install skipped that step (CI).
function resolveElectronBinary(): string {
const binary: unknown = require('electron');
if (typeof binary !== 'string') throw new Error('Could not resolve the Electron binary path');
return binary;
}
// Electron announces its main-process inspector on stderr; `offset` skips earlier launches.
function readInspectorUrl(logPath: string, byteOffset: number): string | null {
const tail = fs.readFileSync(logPath).subarray(byteOffset).toString('utf8');
return /Debugger listening on (ws:\/\/\S+)/.exec(tail)?.[1] ?? null;
}
function readDevToolsPort(portFile: string): number | null {
try {
const port = Number(fs.readFileSync(portFile, 'utf8').split('\n')[0]);
return port > 0 ? port : null;
} catch {
return null;
}
}
function spawnApp(target: AppTarget, electronArgs: string[], args: string[]): ChildProcess {
const log = fs.openSync(target.logPath, 'a');
const child = spawn(
resolveElectronBinary(),
[...target.electronArgs, ...electronArgs, repoRoot, ...args],
{ cwd: repoRoot, env: applyEnvDelta(process.env, target.env), stdio: ['ignore', log, log] },
);
fs.closeSync(log);
return child;
}
/** Runs a CLI command against the running instance and waits for it to finish. */
export async function runAppCommand(target: AppTarget, args: string[]): Promise<void> {
const child = spawnApp(target, [], args);
const timer = setTimeout(() => child.kill('SIGKILL'), 30_000);
const [code] = await once(child, 'exit');
clearTimeout(timer);
if (code !== 0) {
throw new Error(
`SubMiner ${args.join(' ')} exited with ${String(code)}; see ${target.logPath}`,
);
}
}
async function openPage(cdpPort: number, urlPart: string): Promise<CdpPage> {
const target = await waitUntil(
async () =>
(await listCdpTargets(cdpPort)).find((candidate) => candidate.url.includes(urlPart)),
{ description: `app window matching "${urlPart}"`, timeoutMs: 30_000 },
);
const page = await connectCdpPage(target);
// A target is listed before its document exists; callers expect a usable DOM.
await page.waitFor(`document.readyState === "complete"`, {
description: `"${urlPart}" document to load`,
});
return page;
}
// mpv starts paused and the app resumes each newly loaded file once the overlay
// and tokenizer are ready, so that resume marks the file as ready. Scenarios
// expect a paused player, and the app retries its resume for a moment, so keep
// pausing until the pause has held for longer than that retry window.
async function settleAfterResume(mpv: MpvClient): Promise<void> {
await waitUntil(async () => (await mpv.command('get_property', 'pause')) === false, {
description: 'SubMiner to resume playback once the file is ready',
timeoutMs: 30_000,
});
let pausedSince: number | null = null;
await waitUntil(
async () => {
if ((await mpv.command('get_property', 'pause')) !== true) {
pausedSince = null;
await mpv.command('set_property', 'pause', true);
return false;
}
pausedSince ??= Date.now();
return Date.now() - pausedSince >= 1_500;
},
{ description: 'playback to stay paused after the resume', timeoutMs: 40_000 },
);
}
export async function startE2eSession(options: E2eSessionOptions = {}): Promise<E2eSession> {
if (!fs.existsSync(path.join(repoRoot, 'dist', 'main-entry.js'))) {
throw new Error('dist/ is missing. Run `bun run build` before the e2e harness.');
}
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-e2e-'));
const configHome = path.join(root, 'config');
const userDataDir = path.join(configHome, 'SubMiner');
const mediaPath = path.join(root, 'clip.mkv');
const subtitlePath = path.join(root, 'clip.srt');
const dictionaryPath = path.join(root, 'fixture-dictionary.zip');
const mpvSocketPath =
process.platform === 'win32'
? `\\\\.\\pipe\\${path.basename(root)}-mpv`
: path.join(root, 'mpv.sock');
// Teardown steps, run in reverse. Also used to unwind a failed startup.
const teardown: Array<() => Promise<void> | void> = [];
const dispose = async (): Promise<void> => {
for (const step of teardown.reverse()) await step();
teardown.length = 0;
if (process.env.SUBMINER_E2E_KEEP !== '1') {
fs.rmSync(root, { recursive: true, force: true });
}
};
try {
fs.mkdirSync(userDataDir, { recursive: true });
writeFixtureSubtitles(subtitlePath);
await Promise.all([generateFixtureClip(mediaPath), buildFixtureDictionary(dictionaryPath)]);
const display = await startDisplay();
teardown.push(display.stop);
const anki = await startFakeAnki();
teardown.push(anki.stop);
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
for (const [relativePath, content] of Object.entries(options.files ?? {})) {
const filePath = path.resolve(userDataDir, relativePath);
const inside = path.relative(userDataDir, filePath);
if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) {
throw new Error(`E2E file path must stay inside the config dir: ${relativePath}`);
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
const env: EnvDelta = {
set: {
...display.env.set,
// The app derives its config dir, Electron profile, and control socket from these.
...(process.platform === 'win32'
? { APPDATA: configHome }
: { XDG_CONFIG_HOME: configHome, XDG_DATA_HOME: path.join(root, 'data') }),
SUBMINER_APP_LOG: path.join(root, 'app.log'),
// Stay attached instead of re-spawning detached, so the harness owns the process.
SUBMINER_BACKGROUND_CHILD: '1',
},
unset: [...display.env.unset, 'ELECTRON_RUN_AS_NODE'],
};
// --enable-logging routes renderer console output into the app log too.
const electronArgs = ['--password-store=basic', '--enable-logging=stderr'];
// Outside Hyprland/Sway the app re-spawns itself detached onto the X11
// backend. Start it there directly so the process we spawn is the app.
if (shouldForceX11ElectronBackend(applyEnvDelta(process.env, env))) {
env.set[X11_ELECTRON_BOOTSTRAP_ENV] = '1';
electronArgs.push('--ozone-platform=x11');
}
// CI containers and runners lack the user namespaces Chromium's sandbox needs.
if (process.platform === 'linux' && process.env.CI) electronArgs.push('--no-sandbox');
const target: AppTarget = { env, electronArgs, logPath: path.join(root, 'app-stdio.log') };
const launchApp = async (
args: string[],
): Promise<{ child: ChildProcess; cdpPort: number; inspectorUrl: string }> => {
// With port 0 Chromium and Node each pick a free port; Chromium records
// its port in the profile dir, Node prints its endpoint to stderr.
const portFile = path.join(userDataDir, 'DevToolsActivePort');
fs.rmSync(portFile, { force: true });
const logOffset = fs.existsSync(target.logPath) ? fs.statSync(target.logPath).size : 0;
const child = spawnApp(
target,
['--remote-debugging-port=0', '--inspect=0'],
[...args, '--log-level', 'debug'],
);
const stop = () => stopProcess(child);
teardown.push(stop);
const cdpPort = await waitUntil(
() => {
if (child.exitCode !== null) {
throw new Error(`SubMiner exited with code ${child.exitCode} during startup`);
}
return readDevToolsPort(portFile);
},
{ description: 'SubMiner DevTools port', timeoutMs: 30_000 },
);
const inspectorUrl = await waitUntil(() => readInspectorUrl(target.logPath, logOffset), {
description: 'SubMiner main-process inspector endpoint',
});
return { child, cdpPort, inspectorUrl };
};
// First boot: import the fixture dictionary through Yomitan's settings page.
// Without one the tokenizer finds nothing and first-run setup takes over.
const seeder = await launchApp(['--yomitan']);
const settings = await openPage(seeder.cdpPort, YOMITAN_SETTINGS_PAGE);
await settings.waitFor('globalThis.__subminerYomitanSettingsAutomation?.ready === true', {
description: 'Yomitan settings automation bridge',
});
const archive = fs.readFileSync(dictionaryPath).toString('base64');
await settings.evaluate(
`globalThis.__subminerYomitanSettingsAutomation
.importDictionaryArchiveBase64(${JSON.stringify(archive)}, "fixture-dictionary.zip")
.then(() => true)`,
);
settings.close();
await stopProcess(seeder.child);
const mpvLogPath = path.join(root, 'mpv.log');
const mpvStdioLogPath = path.join(root, 'mpv-stdio.log');
const mpvProcess = await startMpv({
socketPath: mpvSocketPath,
mediaPath,
subtitlePath,
logPath: mpvLogPath,
stdioLogPath: mpvStdioLogPath,
display,
});
teardown.push(() => stopProcess(mpvProcess));
const mpv = await connectMpv(mpvSocketPath, () => {
const { exitCode, signalCode } = mpvProcess;
const status =
exitCode === null && signalCode === null
? 'mpv IPC connection closed'
: `mpv exited (code ${String(exitCode)}, signal ${String(signalCode)})`;
return `${status}; see ${mpvLogPath} and ${mpvStdioLogPath}`;
});
teardown.push(mpv.close);
const app = await launchApp(['--start']);
await settleAfterResume(mpv);
const pages: CdpPage[] = [];
teardown.push(() => pages.forEach((page) => page.close()));
return {
root,
display: display.kind,
cdpPort: app.cdpPort,
inspectorUrl: app.inspectorUrl,
mpvSocketPath,
target,
mpv,
anki,
openPage: async (urlPart) => {
const page = await openPage(app.cdpPort, urlPart);
pages.push(page);
return page;
},
mainProcess: async () => {
const page = await connectCdp(app.inspectorUrl, 'main process');
pages.push(page);
return page;
},
runAppCommand: (...args) => runAppCommand(target, args),
settleAfterResume: () => settleAfterResume(mpv),
dispose,
};
} catch (error) {
// Keep the logs: a failed startup is exactly when they are needed.
process.env.SUBMINER_E2E_KEEP = '1';
await dispose();
throw new Error(`E2E session failed to start (logs kept in ${root})`, { cause: error });
}
}
+23
View File
@@ -0,0 +1,23 @@
export type WaitOptions = {
description: string;
timeoutMs?: number;
intervalMs?: number;
};
export type Truthy<T> = Exclude<T, false | 0 | '' | null | undefined>;
/** Polls `probe` until it returns a truthy value, then returns that value. */
export async function waitUntil<T>(
probe: () => T | Promise<T>,
{ description, timeoutMs = 15_000, intervalMs = 100 }: WaitOptions,
): Promise<Truthy<T>> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const value = await probe();
if (value) return value as Truthy<T>;
if (Date.now() >= deadline) {
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
+421
View File
@@ -0,0 +1,421 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import { connectCdpPage, listCdpTargets, type CdpPage } from './harness/cdp';
import { FAKE_ANKI_DECK, FAKE_ANKI_MODEL } from './harness/fake-anki';
import { FIXTURE_CUES } from './harness/fixtures';
import { OVERLAY_PAGE, startE2eSession, type E2eSession } from './harness/session';
import { waitUntil } from './harness/wait';
type Started = { session: E2eSession; overlay: CdpPage };
// 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;
let started: Promise<Started> | undefined;
function useSession(): Promise<Started> {
started ??= (async () => {
const session = await startE2eSession({ config: { controller: { enabled: true } } });
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 RenderedToken = { surface: string; headword: string | null };
// Parks playback inside a cue and waits for the overlay to show that line.
async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToken[]> {
const { session, overlay } = await useSession();
await session.mpv.command('seek', cue.at, 'absolute+exact');
// One expression reads the line and its tokens together, so a re-render
// between two round trips cannot hand back a half-updated subtitle.
return overlay
.waitFor<RenderedToken[] | false>(
`(() => {
const root = document.getElementById('subtitleRoot');
const words = Array.from(root.querySelectorAll('.word'), (word) => ({
surface: word.textContent,
headword: word.getAttribute('data-headword'),
}));
return root.textContent === ${JSON.stringify(cue.text)} && words.length > 0 && words;
})()`,
{ description: `overlay to render "${cue.text}" tokenized` },
)
.catch(async (error: unknown) => {
// Say what the overlay was showing, what main thinks the line is, and
// what the renderer logged, so a dropped update can be placed.
const state = await overlay.evaluate<string>(
`(async () => JSON.stringify({
visibility: document.visibilityState,
text: document.getElementById('subtitleRoot').textContent,
html: document.getElementById('subtitleRoot').innerHTML.slice(0, 200),
mainLine: (await window.electronAPI.getCurrentSubtitle())?.text,
errorToast: document.getElementById('overlayErrorToast')?.textContent,
}))()`,
);
// Diagnostics must not replace the cue failure if the inspector is gone too.
const windows = await session
.mainProcess()
.then((main) =>
main.evaluate<string>(
`JSON.stringify(process.mainModule.require('electron').BrowserWindow.getAllWindows().map((w) => ({
id: w.id,
title: w.getTitle(),
url: w.webContents.getURL().split('/').pop(),
visible: w.isVisible(),
loading: w.webContents.isLoading(),
documentLoaded: w.__subminerOverlayDocumentLoaded,
contentReady: w.__subminerOverlayContentReady,
})))`,
),
)
.catch((inspectorError: unknown) => `unavailable (${String(inspectorError)})`);
const targets = (await listCdpTargets(session.cdpPort))
.filter((t) => t.type === 'page')
.map((t) => t.url.split('/').pop());
const log = overlay.console.slice(-20).join('\n');
throw new Error(
`Overlay state: ${state}\nMain windows: ${windows}\nPage targets: ${JSON.stringify(targets)}\nRenderer console:\n${log}`,
{ cause: error },
);
});
}
// Card-kind flags are written before media, so wait for the update carrying audio.
function findMediaUpdate(session: E2eSession) {
return session.anki.requests.find(
(request) =>
request.action === 'updateNoteFields' && JSON.stringify(request.params).includes('[sound:'),
);
}
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
return tokens.some((token) => token.surface === surface && token.headword === headword);
}
test(
'overlay renders the current mpv subtitle line as dictionary tokens',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const tokens = await showCue(FIXTURE_CUES[0]);
assert.ok(hasToken(tokens, '今日', '今日'), JSON.stringify(tokens));
assert.ok(hasToken(tokens, '天気', '天気'), JSON.stringify(tokens));
},
);
test(
'seeking to another cue replaces the line and deinflects verbs to their headword',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const tokens = await showCue(FIXTURE_CUES[2]);
assert.ok(hasToken(tokens, '行きます', '行く'), JSON.stringify(tokens));
},
);
test(
'mining a sentence card sends the line with generated audio and image to Anki',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session } = await useSession();
const cue = FIXTURE_CUES[1];
await showCue(cue);
session.anki.requests.length = 0;
await session.runAppCommand('--mine-sentence');
const update = await waitUntil(() => findMediaUpdate(session), {
description: 'media fields to be written to the mined note',
timeoutMs: 30_000,
});
const added = session.anki.requests.filter((request) => request.action === 'addNote');
assert.equal(added.length, 1);
assert.partialDeepStrictEqual(added[0]?.params.note, {
deckName: FAKE_ANKI_DECK,
modelName: FAKE_ANKI_MODEL,
fields: { Sentence: cue.text },
});
const stored = session.anki.requests
.filter((request) => request.action === 'storeMediaFile')
.map((request) => String(request.params.filename));
assert.ok(
stored.some((name) => name.endsWith('.mp3')),
`no audio in ${stored.join(', ')}`,
);
assert.ok(
stored.some((name) => name.endsWith('.jpg')),
`no image in ${stored.join(', ')}`,
);
const updatedFields = JSON.stringify(update.params);
for (const filename of stored) assert.ok(updatedFields.includes(filename), filename);
},
);
test(
'mining a line retimed with sub-delay clips it where mpv plays it',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
const cue = FIXTURE_CUES[1];
const delay = 2;
await session.mpv.command('set_property', 'sub-delay', delay);
try {
// The previous test leaves this cue on screen, so blank the line first; otherwise the wait
// below passes before the seek lands and mining can catch mpv's empty mid-seek sub-text.
await session.mpv.command('seek', FIXTURE_CUES[0].start / 2, 'absolute+exact');
await overlay.waitFor(`document.getElementById('subtitleRoot').textContent === ''`, {
description: 'overlay to clear the line before the first delayed cue',
});
// Past the cue's own end, so the line is only on screen because of the delay.
await session.mpv.command('seek', cue.end + delay / 2, 'absolute+exact');
await overlay.waitFor(
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
{ description: 'overlay to show the delayed line' },
);
session.anki.requests.length = 0;
await session.runAppCommand('--mine-sentence');
await waitUntil(() => findMediaUpdate(session), {
description: 'media fields to be written to the mined note',
timeoutMs: 30_000,
});
// MiscInfo stamps the clip start: the cue's start on the video clock.
const written = JSON.stringify(session.anki.requests);
assert.ok(written.includes(`(00:00:0${cue.start + delay})`), written);
} finally {
await session.mpv.command('set_property', 'sub-delay', 0);
}
},
);
test(
'open subtitle sidebar lists the parsed cues and follows playback',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
await showCue(FIXTURE_CUES[0]);
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item').length === ${FIXTURE_CUES.length}`,
{ description: 'sidebar to list every fixture cue' },
);
for (const [index, cue] of FIXTURE_CUES.entries()) {
await session.mpv.command('seek', cue.at, 'absolute+exact');
await overlay.waitFor(
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '${index}'`,
{ description: `sidebar to highlight cue ${index}` },
);
}
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
{ description: 'sidebar to close' },
);
},
);
test(
'subtitle sidebar follows and seeks a sub-delay retimed track',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
const delay = 2;
await session.mpv.command('set_property', 'sub-delay', delay);
try {
// At 9s cue 2 starts on the file's clock, but mpv is still showing cue 1.
await session.mpv.command('seek', FIXTURE_CUES[2].start, 'absolute+exact');
await overlay.waitFor(
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(FIXTURE_CUES[1].text)}`,
{ description: 'overlay to show the delayed line' },
);
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '1'`,
{ description: 'sidebar to highlight the delayed line' },
);
await overlay.evaluate(
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item[data-index="2"]').click()`,
);
const target = FIXTURE_CUES[2].start + delay;
await waitUntil(
async () => {
const time = Number(await session.mpv.command('get_property', 'time-pos'));
return time >= target && time < target + 0.5;
},
{ description: `click to seek to cue 2 at ${target}s on the video clock` },
);
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
{ description: 'sidebar to close' },
);
} finally {
await session.mpv.command('set_property', 'sub-delay', 0);
}
},
);
// macOS and Windows keep a hidden modal renderer alive, and hidden windows keep polling because
// background throttling is off. A fake pad goes to every renderer, as a real pad would.
// Linux has no such window, and under Xvfb without a window manager the overlay reports hidden,
// so requestAnimationFrame and with it the controller poll never run. Bun's node:test ignores the
// `skip` option, so this picks `test.skip` instead.
const testOutsideLinux = process.platform === 'linux' ? test.skip : test;
testOutsideLinux(
'one controller press toggles mpv pause once across every window',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session } = await useSession();
const START_BUTTON = 9;
const targets = (await listCdpTargets(session.cdpPort)).filter((target) =>
target.url.includes('renderer/index.html'),
);
const renderers = await Promise.all(targets.map(connectCdpPage));
const everyRenderer = (expression: string) =>
Promise.all(renderers.map((renderer) => renderer.evaluate(expression)));
const nextPolls = `new Promise((done) =>
requestAnimationFrame(() => requestAnimationFrame(() => done(true))))`;
try {
await everyRenderer(`(() => {
window.__fakePadPressed = false;
Object.defineProperty(navigator, 'getGamepads', {
configurable: true,
value: () => [{
id: 'E2E Pad (STANDARD GAMEPAD)',
index: 0,
connected: true,
mapping: 'standard',
axes: [0, 0, 0, 0],
buttons: Array.from({ length: 17 }, (_, index) => {
const pressed = window.__fakePadPressed && index === ${START_BUTTON};
return { value: pressed ? 1 : 0, pressed, touched: pressed };
}),
}],
});
return true;
})()`);
await everyRenderer(nextPolls);
await session.mpv.command('set_property', 'pause', true);
await everyRenderer('window.__fakePadPressed = true');
await everyRenderer(nextPolls);
await everyRenderer('window.__fakePadPressed = false');
await everyRenderer(nextPolls);
// Long enough for a second `cycle pause` to reach mpv and cancel the first.
await new Promise((done) => setTimeout(done, 300));
assert.equal(await session.mpv.command('get_property', 'pause'), false);
} finally {
await everyRenderer('delete navigator.getGamepads');
renderers.forEach((renderer) => renderer.close());
await session.mpv.command('set_property', 'pause', true);
}
},
);
test(
'controller combos survive the real save path and can be rebound to one button',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { overlay } = await useSession();
const L1 = { kind: 'button', buttonIndex: 4 };
const DPAD_LEFT = { kind: 'button', buttonIndex: 14 };
const DPAD_RIGHT = { kind: 'button', buttonIndex: 15 };
const save = (bindings: Record<string, unknown>) =>
overlay.evaluate<Record<string, unknown>>(
`(async () => {
await window.electronAPI.saveControllerConfig(${JSON.stringify({ bindings })});
return (await window.electronAPI.getControllerConfig()).bindings;
})()`,
);
await save({ previousEntry: { ...DPAD_LEFT, hold: L1 } });
const bothCombos = await save({ nextEntry: { ...DPAD_RIGHT, hold: L1 } });
assert.deepEqual(bothCombos.previousEntry, { ...DPAD_LEFT, hold: L1 });
assert.deepEqual(bothCombos.nextEntry, { ...DPAD_RIGHT, hold: L1 });
const rebound = await save({ previousEntry: DPAD_LEFT, nextEntry: { kind: 'none' } });
assert.deepEqual(rebound.previousEntry, DPAD_LEFT);
assert.deepEqual(rebound.nextEntry, { kind: 'none' });
await save({ previousEntry: { kind: 'none' } });
},
);
// Runs last: it leaves the lookup popup open over the overlay.
test(
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
await showCue(FIXTURE_CUES[0]);
const word = await overlay.evaluate<{ x: number; y: number }>(
`(() => {
const words = Array.from(document.querySelectorAll('#subtitleRoot .word'));
const box = words.find((word) => word.textContent === '天気').getBoundingClientRect();
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
})()`,
);
// Yomitan's default scan modifier is Shift, and it scans on mouse movement,
// so approach the word, hold Shift, then move across it.
const shift = { key: 'Shift', code: 'ShiftLeft', windowsVirtualKeyCode: 16 };
const move = (x: number, modifiers = 0) =>
overlay.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y: word.y, modifiers });
await move(word.x - 30);
await overlay.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...shift, modifiers: 8 });
await move(word.x - 2, 8);
await move(word.x, 8);
await overlay.waitFor(
`document.querySelector('[data-subminer-yomitan-popup-visible="true"]') !== null`,
{ description: 'overlay to mark the Yomitan popup visible' },
);
// Yomitan keeps more than one popup frame around, so read whichever
// one holds the entry instead of trusting target order.
let seen: string[] = [];
const entry = await waitUntil(
async () => {
const targets = (await listCdpTargets(session.cdpPort)).filter((target) =>
target.url.includes('popup.html'),
);
seen = [];
for (const target of targets) {
// A frame can go away between listing and connecting; skip it.
const frame = await connectCdpPage(target).catch(() => undefined);
if (!frame) continue;
// textContent, not innerText: the entry counts once it is in the DOM,
// even where a GPU-less runner never lays the frame out.
const text = await frame.evaluate<string>('document.body?.textContent ?? ""');
frame.close();
seen.push(text);
}
return seen.find((text) => text.includes('weather'));
},
{ description: 'a popup frame to show the fixture dictionary entry' },
).catch((error: unknown) => {
const summary = seen.map((text) => text.replace(/\s+/g, ' ').trim().slice(0, 200));
throw new Error(`Popup frames held: ${JSON.stringify(summary)}`, { cause: error });
});
await overlay.send('Input.dispatchKeyEvent', { type: 'keyUp', ...shift });
assert.match(entry, /天気/);
},
);
+346
View File
@@ -0,0 +1,346 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test, { after } from 'node:test';
import type { CdpPage } from './harness/cdp';
import { FIXTURE_CUES } from './harness/fixtures';
import { OVERLAY_PAGE, startE2eSession, type E2eSession } from './harness/session';
import { waitUntil } from './harness/wait';
// Bun caps node:test hooks at 5s whatever their timeout option says, so the
// session boots inside whichever test runs first and every test budgets for it.
const TEST_TIMEOUT_MS = 120_000;
// Opens a panel describing the subtitle context and a child process result, and
// logs when the panel's signal aborts so the test can see the close reach main.
const PROBE_PLUGIN = `
const { ipcMain } = require('electron');
exports.activate = (ctx) => {
ipcMain.handle('e2e-probe:info', () => ({ dataDir: ctx.dataDir, greeting: ctx.settings.greeting }));
ctx.onDispose(() => ipcMain.removeHandler('e2e-probe:info'));
ctx.media.on('change', (media) => ctx.log.info('probe media ' + JSON.stringify(media)));
ctx.anki.on('card-mined', ({ notes }) => ctx.log.info('probe mined ' + JSON.stringify(notes)));
ctx.actions.register('subtitle', () => ctx.log.info('probe subtitle ' + JSON.stringify(ctx.subtitles.current())));
ctx.actions.register('request', async () => {
const title = await ctx.mpv.request(['get_property', 'media-title']);
ctx.log.info('probe request ' + JSON.stringify(title));
try { await ctx.mpv.request(['subminer-no-such-command']); }
catch { ctx.log.info('probe request rejected'); }
});
ctx.actions.register('window', () => {
ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', title: 'E2E Probe Window' });
});
ctx.mpv.on('end-file', ({ reason }) => ctx.log.info('probe end-file ' + reason));
ctx.actions.register('show', () => {
const { before, current, after } = ctx.subtitles.context({ before: 1, after: 1 });
const panel = ctx.ui.panel({ title: 'Probe', markdown: '_working_' });
panel.signal.addEventListener('abort', () => ctx.log.info('probe panel aborted'));
ctx
.exec(process.execPath, ['-e', 'process.stdout.write("child-ok")'], {
env: { ELECTRON_RUN_AS_NODE: '1' },
signal: panel.signal,
})
.then((result) =>
panel.update({
markdown: [
'## ' + ctx.settings.greeting,
'',
'- before: ' + before.map((line) => line.text).join('|'),
'- current: **' + (current ? current.text : '') + '**',
'- after: ' + after.map((line) => line.text).join('|'),
'- exec: ' + result.stdout,
].join('\\n'),
}),
)
.catch((error) => panel.update({ markdown: 'exec failed: ' + error.message }));
});
};
`;
const PROBE_PRELOAD = `
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('probe', { info: () => ipcRenderer.invoke('e2e-probe:info') });
`;
const PROBE_WINDOW_HTML = `<!doctype html>
<html>
<head><title>E2E Probe Window</title></head>
<body>
<pre id="out">loading</pre>
<script>
window.probe.info().then((info) => {
document.getElementById('out').textContent = JSON.stringify(info);
});
</script>
</body>
</html>
`;
type Started = { session: E2eSession; overlay: CdpPage };
let started: Promise<Started> | undefined;
function useSession(): Promise<Started> {
started ??= (async () => {
const session = await startE2eSession({
config: { plugins: { 'e2e-probe': { enabled: true, settings: { greeting: 'hello' } } } },
files: {
'plugins/e2e-probe/plugin.json': JSON.stringify({ name: 'E2E Probe', main: 'main.cjs' }),
'plugins/e2e-probe/main.cjs': PROBE_PLUGIN,
'plugins/e2e-probe/preload.js': PROBE_PRELOAD,
'plugins/e2e-probe/ui/index.html': PROBE_WINDOW_HTML,
},
});
try {
return { session, overlay: await session.openPage(OVERLAY_PAGE) };
} catch (error) {
await session.dispose();
throw error;
}
})();
return started;
}
after(async () => {
const running = await started?.catch(() => undefined);
await running?.session.dispose();
});
type PanelState = {
title: string;
heading: string;
current: string;
items: string[];
};
test(
'a plugin action opens the plugin panel with subtitle context and child process output',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
const [previous, cue, next] = FIXTURE_CUES;
await session.mpv.command('seek', cue.at, 'absolute+exact');
await overlay.waitFor(
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
{ description: `overlay to show "${cue.text}"` },
);
await session.runAppCommand(
'--session-action',
JSON.stringify({
actionId: 'runPluginAction',
payload: { pluginActionId: 'e2e-probe/show' },
}),
);
const modal = await session.openPage('renderer/index.html?layer=modal');
const panel = await modal
.waitFor<PanelState | false>(
`(() => {
const root = document.getElementById('pluginPanelModal');
const body = document.getElementById('pluginPanelBody');
if (root.classList.contains('hidden') || !/exec[: ]/.test(body.textContent)) return false;
return {
title: document.getElementById('pluginPanelTitle').textContent,
heading: body.querySelector('h4')?.textContent ?? '',
current: body.querySelector('strong')?.textContent ?? '',
items: Array.from(body.querySelectorAll('li'), (item) => item.textContent),
};
})()`,
{ description: 'plugin panel to show the finished probe result' },
)
.catch(async (error: unknown) => {
const body = await modal.evaluate<string>(
`document.getElementById('pluginPanelBody').textContent`,
);
throw new Error(`Plugin panel body: ${JSON.stringify(body)}`, { cause: error });
});
assert.deepEqual(panel, {
title: 'Probe',
heading: 'hello',
current: cue.text,
items: [
`before: ${previous.text}`,
`current: ${cue.text}`,
`after: ${next.text}`,
'exec: child-ok',
],
});
// Closing hides the modal window (Linux destroys it), often before Chromium
// acknowledges the key event, so the send is not awaited. The plugin's log line
// proves the close reached main and aborted the handle.
void modal
.send('Input.dispatchKeyEvent', {
type: 'rawKeyDown',
key: 'Escape',
code: 'Escape',
windowsVirtualKeyCode: 27,
})
.catch(() => {});
await waitUntil(
() =>
fs.readFileSync(path.join(session.root, 'app.log'), 'utf8').includes('probe panel aborted'),
{ description: 'closing the panel to abort the plugin handle in main' },
);
},
);
test(
'a CLI plugin action opens a plugin window whose preload talks to the plugin',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session } = await useSession();
await session.runAppCommand('--plugin-action', 'e2e-probe/window');
const page = await session.openPage('plugins/e2e-probe/ui/index.html');
const info = await page.waitFor<{ dataDir: string; greeting: string } | false>(
`(() => {
const text = document.getElementById('out')?.textContent;
return text && text !== 'loading' ? JSON.parse(text) : false;
})()`,
{ description: 'plugin window to read plugin info over IPC' },
);
assert.equal(info.greeting, 'hello');
assert.equal(path.basename(info.dataDir), 'e2e-probe');
assert.equal(path.basename(path.dirname(info.dataDir)), 'plugin-data');
assert.equal(fs.statSync(info.dataDir).isDirectory(), true);
},
);
test('plugins receive mpv end-file events', { timeout: TEST_TIMEOUT_MS }, async () => {
const { session } = await useSession();
const mediaPath = await session.mpv.command('get_property', 'path');
// Replacing the playing file ends it with reason "stop".
await session.mpv.command('loadfile', String(mediaPath), 'replace');
await waitUntil(
() =>
fs.readFileSync(path.join(session.root, 'app.log'), 'utf8').includes('probe end-file stop'),
{ description: 'the plugin to log the end-file reason' },
);
// Later tests need the replacement loaded and paused: a sub-add during the reload lands on
// the outgoing file, and the app's autoplay resume would keep cues moving under them.
await session.settleAfterResume();
});
test(
'plugin subtitle lookup uses the subtitle clock for repeated retimed lines',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
// Unlike any fixture cue, so the overlay showing it proves main received this line rather
// than still displaying one from an earlier test.
const text = '今日は猫が好きです。';
const subtitlePath = path.join(session.root, 'repeated.srt');
fs.writeFileSync(
subtitlePath,
`1\n00:00:01,000 --> 00:00:03,000\n${text}\n\n2\n00:00:06,000 --> 00:00:08,000\n${text}\n`,
);
const originalSid = await session.mpv.command('get_property', 'sid');
// Runs the probe action and returns the cue it logged on its nth run.
const reportSubtitle = async (run: number): Promise<unknown> => {
await session.runAppCommand('--plugin-action', 'e2e-probe/subtitle');
const line = await waitUntil(
() =>
fs
.readFileSync(path.join(session.root, 'app.log'), 'utf8')
.split('\n')
.filter((entry) => entry.includes('probe subtitle '))[run - 1],
{ description: `the plugin to report its current subtitle (run ${run})` },
);
assert.ok(line);
return JSON.parse(line.slice(line.indexOf('probe subtitle ') + 'probe subtitle '.length));
};
const seekToCue = async (time: number, cueIndex: number): Promise<void> => {
await session.mpv.command('seek', time, 'absolute+exact');
// time-pos can activate the sidebar while mpv is still rendering the seek with
// sub-text cleared. Wait for the seek, then for main to receive the live text.
await waitUntil(
async () => (await session.mpv.command('get_property', 'seeking')) === false,
{
description: `the seek to ${time}s to finish`,
},
);
await overlay.waitFor(
`(async () => {
const text = await window.electronAPI.getCurrentSubtitleRaw();
return text === ${JSON.stringify(text)} &&
document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item')[${cueIndex}]?.classList.contains('active') === true;
})()`,
{ description: `delayed cue ${cueIndex + 1} to be active with live subtitle text` },
);
};
try {
await session.mpv.command('seek', 0, 'absolute+exact');
await session.mpv.command('sub-add', subtitlePath, 'select');
await session.mpv.command('set_property', 'sub-delay', 5);
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item').length === 2`,
{ description: 'the repeated subtitle cues to be parsed' },
);
await seekToCue(7, 0);
assert.deepEqual(await reportSubtitle(1), { text, startTime: 1, endTime: 3 });
// Same text again at 6-8 on the subtitle clock, so only the timings tell the cues apart.
await seekToCue(12, 1);
assert.deepEqual(await reportSubtitle(2), { text, startTime: 6, endTime: 8 });
} finally {
await session.mpv.command('set_property', 'sub-delay', 0);
await session.mpv.command('set_property', 'sid', originalSid);
await session.runAppCommand('--toggle-subtitle-sidebar');
}
},
);
test(
'plugin requests report command failures and mined notes carry the captured source',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
const cue = FIXTURE_CUES[1];
await session.mpv.command('seek', cue.at, 'absolute+exact');
await overlay.waitFor(
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
{ description: 'the mined subtitle to be visible' },
);
await session.runAppCommand('--plugin-action', 'e2e-probe/request');
await waitUntil(
() =>
fs
.readFileSync(path.join(session.root, 'app.log'), 'utf8')
.includes('probe request rejected'),
{ description: 'mpv to reject an invalid plugin request' },
);
await session.runAppCommand('--mine-sentence');
const log = await waitUntil(
() => {
const text = fs.readFileSync(path.join(session.root, 'app.log'), 'utf8');
return text.includes('probe mined ') ? text : null;
},
{ description: 'the mining event to reach the plugin' },
);
assert.ok(log);
const line = log.split('\n').find((entry) => entry.includes('probe mined '));
assert.ok(line);
const notes: unknown = JSON.parse(
line.slice(line.indexOf('probe mined ') + 'probe mined '.length),
);
assert.ok(Array.isArray(notes));
assert.equal(notes.length, 1);
const note: unknown = notes[0];
assert.partialDeepStrictEqual(note, {
provenance: {
subtitle: { text: cue.text },
media: { path: await session.mpv.command('get_property', 'path') },
},
});
assert.ok(note && typeof note === 'object' && 'originalNoteId' in note && 'noteId' in note);
assert.equal(note.originalNoteId, note.noteId);
},
);
+35 -218
View File
@@ -6,11 +6,9 @@ import type { LauncherCommandContext } from './context.js';
import { runConfigCommand } from './config-command.js';
import { runDictionaryCommand } from './dictionary-command.js';
import { runDoctorCommand } from './doctor-command.js';
import { runLogsCommand } from './logs-command.js';
import { runMpvPostAppCommand, runMpvPreAppCommand } from './mpv-command.js';
import { runAppPassthroughCommand } from './app-command.js';
import { runStatsCommand } from './stats-command.js';
import { runUpdateCommand } from './update-command.js';
class ExitSignal extends Error {
code: number;
@@ -150,74 +148,7 @@ test('doctor command exits non-zero for missing hard dependencies', () => {
);
});
test('doctor command forwards refresh-known-words to app binary', () => {
const context = createContext();
context.args.doctor = true;
context.args.doctorRefreshKnownWords = true;
const forwarded: string[][] = [];
const handled = runDoctorCommand(context, {
commandExists: () => false,
configExists: () => true,
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [['--refresh-known-words']]);
});
test('logs command exports logs and writes archive path', () => {
const writes: string[] = [];
const context = createContext();
context.args.logsExport = true;
context.processAdapter = {
...context.processAdapter,
writeStdout: (text) => writes.push(text),
};
const handled = runLogsCommand(context, {
exportLogsArchive: () => ({
zipPath: '/tmp/subminer-logs.zip',
exportedFiles: ['/tmp/app.log'],
mode: 'current-day',
}),
});
assert.equal(handled, true);
assert.deepEqual(writes, ['/tmp/subminer-logs.zip\n']);
});
test('logs command ignores unrelated launcher commands', () => {
const context = createContext();
assert.equal(runLogsCommand(context), false);
});
test('app command starts default macOS background app detached from launcher', () => {
const context = createContext();
context.args.appPassthrough = true;
context.args.appArgs = [];
const calls: string[] = [];
const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: () => {
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
launchYoutubeBrowserDetached: () => calls.push('youtube'),
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']);
});
test('app command starts default Linux background app detached from launcher', () => {
test('app command starts default background app detached from launcher app detached from launcher', () => {
const context = createContext();
context.args.appPassthrough = true;
context.args.appArgs = [];
@@ -389,49 +320,6 @@ test('dictionary command forwards candidate and selection modes to app binary',
]);
});
test('dictionary command returns after app handoff starts', () => {
const context = createContext();
context.args.dictionary = true;
const handled = runDictionaryCommand(context, {
runAppCommandWithInherit: () => undefined,
});
assert.equal(handled, true);
});
test('update command runs direct Linux release update without launching Electron', async () => {
const context = createContext();
context.args.update = true;
const calls: string[] = [];
const handled = await runUpdateCommand(context, {
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
},
runDirectReleaseUpdate: async (request) => {
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
return {
appImage: { status: 'not-found' },
launcher: { status: 'updated' },
supportAssets: [{ status: 'skipped' }],
};
},
readMainConfig: () => null,
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, [
'direct:/tmp/subminer.app:/tmp/subminer:stable',
'info:AppImage update: not-found',
'info:Launcher update: updated',
'info:Support assets update: skipped',
]);
});
test('stats command launches attached app command with response path', async () => {
const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' });
const handled = await runStatsCommand(harness.context, harness.commandDeps);
@@ -449,16 +337,41 @@ test('stats command launches attached app command with response path', async ()
assert.equal(harness.removedPaths.length, 1);
});
test('stats background command launches attached daemon control command with response path', async () => {
const harness = createStatsTestHarness({ stats: true, statsBackground: true });
const handled = await runStatsCommand(harness.context, harness.commandDeps);
const STATS_RESPONSE_ARGS = ['--stats-response-path', '/tmp/subminer-stats-test/response.json'];
assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [
['--stats-daemon-start', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
]);
assert.equal(harness.removedPaths.length, 1);
});
const statsForwardingCases = [
{
name: 'stats background command launches the daemon start command',
overrides: { statsBackground: true },
expected: ['--stats-daemon-start', ...STATS_RESPONSE_ARGS],
},
{
name: 'stats stop command forwards the daemon stop flag',
overrides: { statsStop: true },
expected: ['--stats-daemon-stop', ...STATS_RESPONSE_ARGS],
},
{
name: 'stats cleanup command forwards cleanup vocab flags',
overrides: { statsCleanup: true, statsCleanupVocab: true },
expected: ['--stats', ...STATS_RESPONSE_ARGS, '--stats-cleanup', '--stats-cleanup-vocab'],
},
{
name: 'stats cleanup command forwards the lifetime rebuild flag',
overrides: { statsCleanup: true, statsCleanupLifetime: true },
expected: ['--stats', ...STATS_RESPONSE_ARGS, '--stats-cleanup', '--stats-cleanup-lifetime'],
},
];
for (const c of statsForwardingCases) {
test(`${c.name} to the app`, async () => {
const harness = createStatsTestHarness({ stats: true, ...c.overrides });
const handled = await runStatsCommand(harness.context, harness.commandDeps);
assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [c.expected]);
assert.equal(harness.removedPaths.length, 1);
});
}
test('stats command waits for attached app exit after startup response', async () => {
const harness = createStatsTestHarness({ stats: true });
@@ -503,45 +416,6 @@ test('stats command throws when attached app exits non-zero after startup respon
assert.equal(harness.removedPaths.length, 1);
});
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
const harness = createStatsTestHarness({
stats: true,
statsCleanup: true,
statsCleanupVocab: true,
});
const handled = await runStatsCommand(harness.context, {
...harness.commandDeps,
waitForStatsResponse: async () => ({ ok: true }),
});
assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [
[
'--stats',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
'--stats-cleanup',
'--stats-cleanup-vocab',
],
]);
assert.equal(harness.removedPaths.length, 1);
});
test('stats stop command forwards stop flag to the app', async () => {
const harness = createStatsTestHarness({ stats: true, statsStop: true });
const handled = await runStatsCommand(harness.context, {
...harness.commandDeps,
waitForStatsResponse: async () => ({ ok: true }),
});
assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [
['--stats-daemon-stop', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
]);
assert.equal(harness.removedPaths.length, 1);
});
test('stats stop command exits on process exit without waiting for startup response', async () => {
const harness = createStatsTestHarness({ stats: true, statsStop: true });
let waitedForResponse = false;
@@ -563,30 +437,6 @@ test('stats stop command exits on process exit without waiting for startup respo
assert.equal(harness.removedPaths.length, 1);
});
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
const harness = createStatsTestHarness({
stats: true,
statsCleanup: true,
statsCleanupLifetime: true,
});
const handled = await runStatsCommand(harness.context, {
...harness.commandDeps,
waitForStatsResponse: async () => ({ ok: true }),
});
assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [
[
'--stats',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
'--stats-cleanup',
'--stats-cleanup-lifetime',
],
]);
assert.equal(harness.removedPaths.length, 1);
});
test('stats command throws when stats response reports an error', async () => {
const harness = createStatsTestHarness({ stats: true });
@@ -692,36 +542,3 @@ test('stats command aborts pending response wait when attached app fails to spaw
assert.equal(aborted, true);
assert.equal(harness.removedPaths.length, 1);
});
test('stats cleanup command aborts pending response wait when app exits before startup response', async () => {
const harness = createStatsTestHarness({
stats: true,
statsCleanup: true,
statsCleanupVocab: true,
});
let aborted = false;
await assert.rejects(async () => {
await runStatsCommand(harness.context, {
...harness.commandDeps,
runAppCommandAttached: async (...args) => {
await harness.runAppCommandAttachedStub(...args);
return 2;
},
waitForStatsResponse: async (_responsePath, signal) =>
await new Promise((resolve) => {
signal?.addEventListener(
'abort',
() => {
aborted = true;
resolve({ ok: false, error: 'aborted' });
},
{ once: true },
);
}),
});
}, /Stats app exited before startup response \(status 2\)\./);
assert.equal(aborted, true);
assert.equal(harness.removedPaths.length, 1);
});
+1 -1
View File
@@ -65,7 +65,7 @@ export async function runMpvPostAppCommand(
context: LauncherCommandContext,
deps: MpvCommandDeps = defaultDeps,
): Promise<boolean> {
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig } = context;
const { args, appPath, mpvSocketPath, pluginRuntimeConfig } = context;
if (!args.mpvIdle) {
return false;
}
+182 -401
View File
@@ -4,81 +4,53 @@ import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
import { state } from '../mpv.js';
import { state, type startMpv } from '../mpv.js';
import { makeLauncherArgs } from '../test-support/args.js';
import { withEnv } from '../test-support/env.js';
function createContext(): LauncherCommandContext {
type PlaybackDeps = Parameters<typeof runPlaybackCommandWithDeps>[1];
type StartMpvOptions = NonNullable<Parameters<typeof startMpv>[6]>;
const YOUTUBE_URL = 'https://www.youtube.com/watch?v=65Ovd7t8sNw';
function makePluginConfig(
overrides: Partial<LauncherCommandContext['pluginRuntimeConfig']> = {},
): LauncherCommandContext['pluginRuntimeConfig'] {
return {
args: {
backend: 'auto',
directory: '.',
recursive: false,
profile: '',
startOverlay: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
sync: false,
syncCliTokens: [],
syncUi: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
...overrides,
};
}
/** A context for a YouTube URL target with logging at info and CLI texthooker off. */
function createContext(
options: {
args?: Partial<Args>;
plugin?: Partial<LauncherCommandContext['pluginRuntimeConfig']>;
} = {},
): LauncherCommandContext {
return {
args: makeLauncherArgs({
target: YOUTUBE_URL,
targetKind: 'url',
jimakuApiKey: '',
jimakuApiKeyCommand: '',
jimakuApiBaseUrl: '',
jimakuLanguagePreference: 'ja',
jimakuMaxEntryResults: 20,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
logsExport: false,
version: false,
settings: false,
youtubeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
mpvArgs: '',
appPassthrough: false,
appArgs: [],
jellyfinServer: '',
jellyfinUsername: '',
jellyfinPassword: '',
launchMode: 'normal',
},
logLevel: 'info',
useTexthooker: false,
...options.args,
}),
scriptPath: '/tmp/subminer',
scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
},
pluginRuntimeConfig: makePluginConfig(options.plugin),
appPath: '/tmp/SubMiner.AppImage',
launcherJellyfinConfig: {},
processAdapter: {
@@ -93,8 +65,70 @@ function createContext(): LauncherCommandContext {
};
}
const FILE_TARGET = { target: '/tmp/movie.mkv', targetKind: 'file' } as const;
/**
* Playback harness. Deps are inert by default and record what the tests assert on: the
* options mpv was started with, the overlay launches, and an `events` log for the few cases
* where relative order is the behavior.
*/
function makeHarness(
options: {
context?: LauncherCommandContext;
deps?: Partial<PlaybackDeps>;
events?: string[];
} = {},
) {
const context = options.context ?? createContext();
const events = options.events ?? [];
const startMpvOptions: StartMpvOptions[] = [];
const overlayCalls: Array<{ extraAppArgs: string[]; configDir?: string }> = [];
const deps: PlaybackDeps = {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {
events.push('plugin');
},
chooseTarget: async () => ({
target: context.args.target,
kind: context.args.targetKind === 'file' ? 'file' : 'url',
}),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async (_target, _kind, _args, _socket, _appPath, _subtitles, startOptions) => {
events.push('startMpv');
if (startOptions) startMpvOptions.push(startOptions);
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socket, extraAppArgs = [], configDir) => {
events.push('startOverlay');
overlayCalls.push({ extraAppArgs, configDir });
},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
...options.deps,
};
return {
context,
events,
startMpvOptions,
overlayCalls,
run: () => runPlaybackCommandWithDeps(context, deps),
};
}
function assertRanInOrder(events: string[], ...expected: string[]): void {
const positions = expected.map((event) => events.indexOf(event));
assert.ok(
positions.every(
(position, index) => position >= 0 && (index === 0 || position > positions[index - 1]!),
),
`expected ${expected.join(' -> ')} in order, got ${events.join(', ')}`,
);
}
test('playback cleanup signal handlers are registered once across repeated sessions', () => {
assert.equal(typeof registerCleanup, 'function', 'cleanup registration is not exported');
const context = createContext();
const registeredSignals: NodeJS.Signals[] = [];
context.processAdapter.onSignal = (signal) => {
@@ -108,121 +142,41 @@ test('playback cleanup signal handlers are registered once across repeated sessi
});
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
const calls: string[] = [];
const context = createContext();
context.pluginRuntimeConfig = {
...context.pluginRuntimeConfig,
autoStart: false,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
};
const receivedStartMpvOptions: Record<string, unknown>[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
if (options) {
receivedStartMpvOptions.push(options as Record<string, unknown>);
}
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
},
launchAppCommandDetached: (_appPath: string, appArgs: string[]) => {
calls.push(`launch:${appArgs.join(' ')}`);
},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
const harness = makeHarness({
context: createContext({
plugin: {
autoStart: false,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
},
}),
});
assert.deepEqual(calls, [
'startMpv',
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw',
]);
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
await harness.run();
assertRanInOrder(harness.events, 'startMpv', 'startOverlay');
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--youtube-play', YOUTUBE_URL]);
assert.equal(harness.startMpvOptions[0]?.startPaused, true);
assert.equal(harness.startMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
});
test('youtube app-owned playback disables mpv plugin auto-start', async () => {
const context = createContext();
context.pluginRuntimeConfig = {
...context.pluginRuntimeConfig,
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
};
const receivedStartMpvOptions: Record<string, unknown>[] = [];
const harness = makeHarness();
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
if (options) {
receivedStartMpvOptions.push(options as Record<string, unknown>);
}
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
});
await harness.run();
const runtimeConfig = receivedStartMpvOptions[0]?.runtimePluginConfig as
| { autoStart?: boolean; autoStartVisibleOverlay?: boolean; autoStartPauseUntilReady?: boolean }
| undefined;
const runtimeConfig = harness.startMpvOptions[0]?.runtimePluginConfig;
assert.equal(runtimeConfig?.autoStart, false);
assert.equal(runtimeConfig?.autoStartVisibleOverlay, false);
assert.equal(runtimeConfig?.autoStartPauseUntilReady, false);
});
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
useTexthooker: true,
};
context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
osdMessages: false,
texthookerEnabled: false,
};
const appPath = context.appPath ?? '';
state.appPath = appPath;
const context = createContext({
args: { ...FILE_TARGET, useTexthooker: true },
plugin: { autoStartVisibleOverlay: false, autoStartPauseUntilReady: false },
});
state.appPath = context.appPath ?? '';
state.overlayManagedByLauncher = false;
const mpvProc = new EventEmitter() as EventEmitter & {
exitCode: number | null;
@@ -233,31 +187,27 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
mpvProc.killed = false;
mpvProc.kill = () => true;
let cleanupSawManagedOverlay = true;
try {
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
const harness = makeHarness({
context,
deps: {
startMpv: async () => {
setTimeout(() => {
mpvProc.exitCode = 0;
mpvProc.emit('exit', 0);
}, 5);
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {
throw new Error('startOverlay should not run when plugin auto-start is used');
},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {
cleanupSawManagedOverlay = state.overlayManagedByLauncher;
},
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
});
},
});
try {
await harness.run();
assert.equal(cleanupSawManagedOverlay, false);
} finally {
@@ -267,258 +217,89 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
});
test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
useTexthooker: true,
};
context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true,
};
const calls: string[] = [];
const receivedStartMpvOptions: Record<string, unknown>[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
calls.push('startMpv');
if (options) {
receivedStartMpvOptions.push(options as Record<string, unknown>);
}
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
isAppControlServerAvailable: async () => true,
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
isAppControlServerAvailable: () => Promise<boolean>;
const harness = makeHarness({
context: createContext({
args: { ...FILE_TARGET, useTexthooker: true },
plugin: { texthookerEnabled: true },
}),
deps: { isAppControlServerAvailable: async () => true },
});
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']);
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
assert.equal(
(receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined)
?.autoStart,
false,
);
await harness.run();
assertRanInOrder(harness.events, 'startMpv', 'startOverlay');
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, [
'--show-visible-overlay',
'--texthooker',
]);
assert.equal(harness.startMpvOptions[0]?.startPaused, true);
assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.autoStart, false);
});
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
const context = createContext();
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
const originalAppData = process.env.APPDATA;
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
fs.mkdirSync(expectedConfigDir, { recursive: true });
fs.writeFileSync(path.join(expectedConfigDir, 'config.jsonc'), '{}');
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
useTexthooker: true,
};
context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true,
};
let availabilityConfigDir: string | undefined;
let overlayConfigDir: string | undefined;
let overlayLoadingOsd: boolean | undefined;
try {
process.env.XDG_CONFIG_HOME = xdgConfigHome;
process.env.APPDATA = xdgConfigHome;
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
overlayLoadingOsd = (
options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined
)?.overlayLoadingOsd;
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
overlayConfigDir = configDir;
},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
const harness = makeHarness({
context: createContext({
args: { ...FILE_TARGET, useTexthooker: true },
plugin: { texthookerEnabled: true },
}),
deps: {
isAppControlServerAvailable: async (_logLevel, configDir) => {
availabilityConfigDir = configDir;
return true;
},
});
},
});
try {
await withEnv({ XDG_CONFIG_HOME: xdgConfigHome, APPDATA: xdgConfigHome }, () => harness.run());
assert.equal(availabilityConfigDir, expectedConfigDir);
assert.equal(overlayConfigDir, expectedConfigDir);
assert.equal(overlayLoadingOsd, true);
assert.equal(harness.overlayCalls[0]?.configDir, expectedConfigDir);
assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.overlayLoadingOsd, true);
} finally {
if (originalXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
}
if (originalAppData === undefined) {
delete process.env.APPDATA;
} else {
process.env.APPDATA = originalAppData;
}
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
}
});
test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
};
context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true,
};
const calls: string[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
isAppControlServerAvailable: async () => true,
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
isAppControlServerAvailable: () => Promise<boolean>;
const harness = makeHarness({
context: createContext({ args: FILE_TARGET, plugin: { texthookerEnabled: true } }),
deps: { isAppControlServerAvailable: async () => true },
});
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
await harness.run();
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--show-visible-overlay']);
});
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
};
const calls: string[] = [];
const harness = makeHarness({ context: createContext({ args: FILE_TARGET }) });
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
});
await harness.run();
assert.deepEqual(calls, ['plugin', 'startMpv']);
assertRanInOrder(harness.events, 'plugin', 'startMpv');
});
test('rofi playback repairs support assets before opening the picker', async () => {
const context = createContext();
context.args = {
...context.args,
target: '',
targetKind: '',
useRofi: true,
};
const calls: string[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {
calls.push('assets');
const events: string[] = [];
const harness = makeHarness({
events,
context: createContext({ args: { target: '', targetKind: '', useRofi: true } }),
deps: {
chooseTarget: async () => {
events.push('picker');
return { target: '/tmp/movie.mkv', kind: 'file' };
},
checkPickerDependencies: () => {},
},
chooseTarget: async () => {
calls.push('picker');
return { target: '/tmp/movie.mkv', kind: 'file' };
},
checkPickerDependencies: () => {},
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
});
assert.deepEqual(calls, ['assets', 'picker', 'startMpv']);
await harness.run();
assertRanInOrder(events, 'plugin', 'picker', 'startMpv');
});
+7 -1
View File
@@ -26,7 +26,10 @@ import {
readSetupState,
} from '../../src/shared/setup-state.js';
import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js';
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
import {
hasLauncherExternalYomitanProfileConfig,
loadLauncherDictionaryBackend,
} from '../config.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const SETUP_POLL_INTERVAL_MS = 500;
@@ -115,6 +118,9 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
const configDir = getLauncherConfigDir();
const statePath = getSetupStatePath(configDir);
const ready = await ensureLauncherSetupReady({
dictionaryBackend: loadLauncherDictionaryBackend(),
isAppRunning: () => isRunningAppControlServerAvailable(args.logLevel, configDir),
warn: (message) => log('warn', args.logLevel, message),
readSetupState: () => readSetupState(statePath),
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
hasLegacyMpvPlugin: () =>
-43
View File
@@ -48,49 +48,6 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async ()
assert.equal(spawned.length, 1);
});
test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
const spawned: string[][] = [];
const deps: Partial<SyncCommandDeps> = {
runAppCommand: (_appPath, appArgs) => {
spawned.push(appArgs);
},
};
await runSyncCommand(
makeContext({
syncCliTokens: [
'media-box',
'--pull',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--json',
],
logLevel: 'debug',
}),
deps,
);
assert.deepEqual(spawned, [
[
'--sync-cli',
'sync',
'media-box',
'--pull',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--json',
'--log-level',
'debug',
],
]);
});
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
const deps: Partial<SyncCommandDeps> = {
runAppCommand: () => {
-14
View File
@@ -8,7 +8,6 @@ import {
buildPluginRuntimeScriptOptParts,
parsePluginRuntimeConfigFromMainConfig,
} from './config/plugin-runtime-config.js';
import { getDefaultSocketPath } from './types.js';
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
const parsed = parseLauncherYoutubeSubgenConfig({
@@ -18,20 +17,11 @@ test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
secondarySub: {
secondarySubLanguages: ['eng', true, 'deu'],
},
jimaku: {
apiKey: 'abc',
apiKeyCommand: 'pass show key',
apiBaseUrl: 'https://jimaku.cc',
languagePreference: 'ja',
maxEntryResults: 8.7,
},
});
assert.equal('mode' in parsed, false);
assert.deepEqual(parsed.primarySubLanguages, ['ja', 'en']);
assert.deepEqual(parsed.secondarySubLanguages, ['eng', 'deu']);
assert.equal(parsed.jimakuLanguagePreference, 'ja');
assert.equal(parsed.jimakuMaxEntryResults, 8);
});
test('parseLauncherJellyfinConfig omits legacy token and user id fields', () => {
@@ -207,10 +197,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
);
});
test('getDefaultSocketPath returns Windows named pipe default', () => {
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
});
test('parseLauncherMpvConfig reads configured mpv profile', () => {
assert.deepEqual(
parseLauncherMpvConfig({
-20
View File
@@ -1,20 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
test('launcher root help lists subcommands', () => {
const output = execFileSync('bun', ['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'], {
encoding: 'utf8',
});
assert.match(output, /Commands:/);
assert.match(output, /jellyfin\|jf/);
assert.match(output, /doctor/);
assert.match(output, /config/);
assert.match(output, /mpv/);
assert.match(output, /logs/);
assert.match(output, /dictionary\|dict/);
assert.match(output, /texthooker/);
assert.match(output, /app\|bin/);
});
+7
View File
@@ -1,4 +1,5 @@
import { fail } from './log.js';
import type { DictionaryBackend } from '../src/types/config.js';
import type {
Args,
LauncherLoggingConfig,
@@ -100,6 +101,12 @@ export function loadLauncherLoggingConfig(): LauncherLoggingConfig {
};
}
export function loadLauncherDictionaryBackend(): DictionaryBackend {
return readLauncherMainConfigObject()?.dictionaryBackend === 'hachidori'
? 'hachidori'
: 'yomitan';
}
export function hasLauncherExternalYomitanProfileConfig(): boolean {
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
}
+83 -162
View File
@@ -9,6 +9,7 @@ import {
applyRootOptionsToArgs,
createDefaultArgs,
} from './args-normalizer.js';
import type { CliInvocations } from './cli-parser-builder.js';
function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-'));
@@ -19,6 +20,48 @@ function withTempDir<T>(fn: (dir: string) => T): T {
}
}
/** An invocations bag with nothing triggered; tests pass only the fields under test. */
function emptyInvocations(overrides: Partial<CliInvocations> = {}): CliInvocations {
return {
jellyfinInvocation: null,
configInvocation: null,
settingsInvocation: null,
youtubeBrowserInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false,
texthookerLogLevel: null,
texthookerOpenBrowser: false,
...overrides,
};
}
test('createDefaultArgs normalizes configured language codes', () => {
const parsed = createDefaultArgs({
primarySubLanguages: [' JA ', 'jpn', 'ja'],
@@ -92,53 +135,23 @@ test('applyRootOptionsToArgs rejects unsupported targets', () => {
test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: {
action: 'play',
play: true,
server: 'https://jf.example',
username: 'alice',
password: 'secret',
logLevel: 'debug',
},
configInvocation: {
action: 'show',
logLevel: 'warn',
},
settingsInvocation: null,
youtubeBrowserInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false,
texthookerLogLevel: null,
texthookerOpenBrowser: false,
});
applyInvocationsToArgs(
parsed,
emptyInvocations({
jellyfinInvocation: {
action: 'play',
play: true,
server: 'https://jf.example',
username: 'alice',
password: 'secret',
logLevel: 'debug',
},
configInvocation: {
action: 'show',
logLevel: 'warn',
},
}),
);
assert.equal(parsed.jellyfin, false);
assert.equal(parsed.jellyfinPlay, true);
@@ -155,45 +168,14 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
test('applyInvocationsToArgs maps settings invocation to settings window', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: null,
settingsInvocation: {
logLevel: undefined,
},
youtubeBrowserInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false,
texthookerLogLevel: null,
texthookerOpenBrowser: false,
});
applyInvocationsToArgs(
parsed,
emptyInvocations({
settingsInvocation: {
logLevel: undefined,
},
}),
);
assert.equal(parsed.settings, true);
assert.equal(parsed.configPath, false);
@@ -203,45 +185,14 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
const parsed = createDefaultArgs({});
const error = withProcessExitIntercept(() => {
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: {
action: undefined,
},
settingsInvocation: null,
youtubeBrowserInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false,
texthookerLogLevel: null,
texthookerOpenBrowser: false,
});
applyInvocationsToArgs(
parsed,
emptyInvocations({
configInvocation: {
action: undefined,
},
}),
);
});
assert.equal(error.code, 1);
@@ -251,43 +202,13 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: null,
settingsInvocation: null,
youtubeBrowserInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: true,
texthookerLogLevel: null,
texthookerOpenBrowser: true,
});
applyInvocationsToArgs(
parsed,
emptyInvocations({
texthookerTriggered: true,
texthookerOpenBrowser: true,
}),
);
assert.equal(parsed.texthookerOnly, true);
assert.equal(parsed.texthookerOpenBrowser, true);
+4 -15
View File
@@ -10,7 +10,6 @@ import type {
LogLevel,
} from '../types.js';
import {
DEFAULT_JIMAKU_API_BASE_URL,
DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS,
DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS,
} from '../types.js';
@@ -125,11 +124,6 @@ export function createDefaultArgs(
recursive: false,
profile: mpvConfig.profile ?? '',
startOverlay: false,
jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '',
jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '',
jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL,
jimakuLanguagePreference: launcherConfig.jimakuLanguagePreference || 'ja',
jimakuMaxEntryResults: launcherConfig.jimakuMaxEntryResults || 10,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
@@ -184,15 +178,6 @@ export function createDefaultArgs(
targetKind: '',
};
if (launcherConfig.jimakuApiKey) parsed.jimakuApiKey = launcherConfig.jimakuApiKey;
if (launcherConfig.jimakuApiKeyCommand)
parsed.jimakuApiKeyCommand = launcherConfig.jimakuApiKeyCommand;
if (launcherConfig.jimakuApiBaseUrl) parsed.jimakuApiBaseUrl = launcherConfig.jimakuApiBaseUrl;
if (launcherConfig.jimakuLanguagePreference)
parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference;
if (launcherConfig.jimakuMaxEntryResults !== undefined)
parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults;
return parsed;
}
@@ -214,6 +199,10 @@ export function applyRootOptionsToArgs(
if (options.update === true) parsed.update = true;
if (options.version === true) parsed.version = true;
if (options.settings === true) parsed.settings = true;
if (options.yomitan === true || options.hachidori === true) {
parsed.appPassthrough = true;
parsed.appArgs = [options.yomitan === true ? '--yomitan' : '--hachidori'];
}
if (options.startOverlay === true) parsed.autoStartOverlay = true;
if (options.texthooker === false) parsed.useTexthooker = false;
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
+2
View File
@@ -87,6 +87,8 @@ function applyRootOptions(program: Command): void {
.option('--log-level <level>', 'Log level')
.option('-v, --version', 'Show SubMiner version')
.option('--settings', 'Open settings window')
.option('--yomitan', 'Open Yomitan settings window')
.option('--hachidori', 'Open Hachidori settings window')
.option('-u, --update', 'Check for updates')
.option('-R, --rofi', 'Use rofi picker')
.option('-H, --history', 'Browse local watch history')
-22
View File
@@ -16,31 +16,9 @@ export function parseLauncherYoutubeSubgenConfig(
secondarySubRaw && typeof secondarySubRaw === 'object'
? (secondarySubRaw as Record<string, unknown>)
: null;
const jimakuRaw = root.jimaku;
const jimaku =
jimakuRaw && typeof jimakuRaw === 'object' ? (jimakuRaw as Record<string, unknown>) : null;
const jimakuLanguagePreference = jimaku?.languagePreference;
const jimakuMaxEntryResults = jimaku?.maxEntryResults;
return {
primarySubLanguages: asStringArray(youtube?.primarySubLanguages),
secondarySubLanguages: asStringArray(secondarySub?.secondarySubLanguages),
jimakuApiKey: typeof jimaku?.apiKey === 'string' ? jimaku.apiKey : undefined,
jimakuApiKeyCommand:
typeof jimaku?.apiKeyCommand === 'string' ? jimaku.apiKeyCommand : undefined,
jimakuApiBaseUrl: typeof jimaku?.apiBaseUrl === 'string' ? jimaku.apiBaseUrl : undefined,
jimakuLanguagePreference:
jimakuLanguagePreference === 'ja' ||
jimakuLanguagePreference === 'en' ||
jimakuLanguagePreference === 'none'
? jimakuLanguagePreference
: undefined,
jimakuMaxEntryResults:
typeof jimakuMaxEntryResults === 'number' &&
Number.isFinite(jimakuMaxEntryResults) &&
jimakuMaxEntryResults > 0
? Math.floor(jimakuMaxEntryResults)
: undefined,
};
}
+304 -120
View File
@@ -1,128 +1,312 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Args } from './types.js';
import { runJellyfinPlayMenuWithDeps } from './jellyfin.js';
import path from 'node:path';
import {
buildForwardedJellyfinAppArgs,
buildRootSearchGroups,
classifyJellyfinChildSelection,
deriveJellyfinTokenStorePath,
hasStoredJellyfinSession,
parseEpisodePathFromDisplay,
parseJellyfinErrorFromAppOutput,
parseJellyfinAppReply,
parseJellyfinItemsReply,
parseJellyfinLibrariesReply,
parseJellyfinPreviewAuthResponse,
runJellyfinPlayMenuWithDeps,
shouldRetryWithStartForNoRunningInstance,
} from './jellyfin.js';
import { makeLauncherArgs } from './test-support/args.js';
import { withEnv } from './test-support/env.js';
function createArgs(): Args {
return {
backend: 'auto',
directory: '.',
recursive: false,
profile: '',
startOverlay: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
history: false,
sync: false,
syncCliTokens: [],
syncUi: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
target: '',
targetKind: '',
jimakuApiKey: '',
jimakuApiKeyCommand: '',
jimakuApiBaseUrl: '',
jimakuLanguagePreference: 'ja',
jimakuMaxEntryResults: 20,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
logsExport: false,
version: false,
settings: false,
youtubeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
mpvArgs: '',
appPassthrough: false,
appArgs: [],
jellyfinServer: 'https://jellyfin.example.test',
jellyfinUsername: '',
jellyfinPassword: '',
launchMode: 'normal',
class HandoffComplete extends Error {}
type PlayMenuDeps = NonNullable<Parameters<typeof runJellyfinPlayMenuWithDeps>[4]>;
/**
* Runs the play menu with fake deps and a direct env session. The app handoff never returns in
* production, so the fake throws `HandoffComplete` after recording its args into `handoff`.
*/
async function runPlayMenu(options: {
socketReady?: boolean;
overrides?: Partial<PlayMenuDeps>;
args?: Parameters<typeof makeLauncherArgs>[0];
}) {
const launches: string[] = [];
const handoff: { appArgs: string[] | null } = { appArgs: null };
const deps: PlayMenuDeps = {
loadLauncherJellyfinConfig: () => ({}),
findRofiTheme: () => null,
resolveJellyfinSelection: async () => 'item-123',
resolveJellyfinSelectionViaApp: async () => {
throw new Error('unexpected app-based selection');
},
hasStoredJellyfinSession: () => true,
requestJellyfinPreviewAuthFromApp: async () => null,
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
pathExists: () => options.socketReady ?? false,
ensureRuntimePluginReady: async () => {
launches.push('plugin');
},
waitForUnixSocketReady: async () => true,
launchMpvIdleDetached: async () => {
launches.push('launch');
},
resolveLauncherRuntimePluginPath: () => '/tmp/plugin/main.lua',
runAppCommandWithInheritLogged: (_appPath, appArgs) => {
handoff.appArgs = appArgs;
throw new HandoffComplete();
},
isAppManagedByLauncher: () => false,
runAppCommandCaptureOutput: () => {
throw new Error('unexpected owned-app handoff');
},
waitForUnixSocketClosed: async () => {
throw new Error('unexpected wait for mpv to close');
},
exitProcess: () => {
throw new Error('unexpected exit');
},
log: () => {},
...options.overrides,
};
await withEnv(
{ SUBMINER_JELLYFIN_ACCESS_TOKEN: 'token', SUBMINER_JELLYFIN_USER_ID: 'user' },
() =>
assert.rejects(
() =>
runJellyfinPlayMenuWithDeps(
'/tmp/SubMiner.AppImage',
makeLauncherArgs({ jellyfinServer: 'https://jellyfin.example.test', ...options.args }),
'/tmp/subminer',
'/tmp/subminer.sock',
deps,
),
HandoffComplete,
),
);
return { launches, appArgs: handoff.appArgs };
}
test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => {
const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID;
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token';
process.env.SUBMINER_JELLYFIN_USER_ID = 'user';
const calls: string[] = [];
test('Jellyfin playback launches the idle mpv with the runtime plugin, then hands the item to the app', async () => {
const { launches, appArgs } = await runPlayMenu({});
try {
await assert.rejects(
() =>
runJellyfinPlayMenuWithDeps(
'/tmp/SubMiner.AppImage',
createArgs(),
'/tmp/subminer',
'/tmp/subminer.sock',
{
loadLauncherJellyfinConfig: () => ({}),
findRofiTheme: () => null,
resolveJellyfinSelection: async () => 'item-123',
resolveJellyfinSelectionViaApp: async () => {
throw new Error('unexpected app-based selection');
},
hasStoredJellyfinSession: () => true,
requestJellyfinPreviewAuthFromApp: async () => null,
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
pathExists: () => false,
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
waitForUnixSocketReady: async () => {
calls.push('wait');
return true;
},
launchMpvIdleDetached: async () => {
calls.push('launch');
},
resolveLauncherRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
runAppCommandWithInheritLogged: () => {
calls.push('handoff');
throw new Error('stop after handoff');
},
log: () => {},
},
),
/stop after handoff/,
);
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']);
} finally {
if (originalAccessToken === undefined) {
delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
} else {
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken;
}
if (originalUserId === undefined) {
delete process.env.SUBMINER_JELLYFIN_USER_ID;
} else {
process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId;
}
}
assert.deepEqual(launches, ['plugin', 'launch']);
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
});
test('Jellyfin playback reuses a ready mpv socket instead of launching idle mpv', async () => {
const { launches, appArgs } = await runPlayMenu({ socketReady: true });
assert.deepEqual(launches, []);
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
});
test('Jellyfin playback in an app the picker started waits for mpv to close before exiting', async () => {
const events: string[] = [];
const { appArgs } = await runPlayMenu({
socketReady: true,
overrides: {
isAppManagedByLauncher: () => true,
runAppCommandCaptureOutput: (_appPath, appArgs) => {
events.push(`handoff ${appArgs.join(' ')}`);
return { status: 0, stdout: '', stderr: '' };
},
waitForUnixSocketClosed: async (socketPath) => {
events.push(`wait ${socketPath}`);
},
exitProcess: (code) => {
events.push(`exit ${code}`);
throw new HandoffComplete();
},
},
});
assert.equal(appArgs, null);
assert.deepEqual(events, [
'handoff --start --jellyfin-play --jellyfin-item-id=item-123',
'wait /tmp/subminer.sock',
'exit 0',
]);
});
test('parseJellyfinAppReply maps library and item replies to picker entries', () => {
const libraries = parseJellyfinAppReply(
JSON.stringify({
libraries: [
{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' },
{ id: 'lib1', name: 'Duplicate', collectionType: 'tvshows' },
{ name: 'No id' },
],
}),
);
assert.ok(libraries?.ok);
assert.deepEqual(parseJellyfinLibrariesReply(libraries.payload), [
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
]);
const items = parseJellyfinAppReply(
JSON.stringify({ items: [{ id: 'movie-1', title: 'Movie [Alt]', type: 'Movie' }] }),
);
assert.ok(items?.ok);
assert.deepEqual(parseJellyfinItemsReply(items.payload), [
{ id: 'movie-1', name: 'Movie [Alt]', type: 'Movie', display: 'Movie [Alt]' },
]);
assert.equal(parseJellyfinLibrariesReply(items.payload), null);
});
test('parseJellyfinAppReply surfaces app errors and rejects partial files', () => {
assert.deepEqual(
parseJellyfinAppReply(
JSON.stringify({ error: 'Missing Jellyfin session. Run --jellyfin-login first.' }),
),
{ ok: false, error: 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.' },
);
assert.equal(parseJellyfinAppReply(''), null);
assert.equal(parseJellyfinAppReply('{"libraries": ['), null);
});
test('buildForwardedJellyfinAppArgs appends server, password store, and log level', () => {
const forwarded = buildForwardedJellyfinAppArgs(
{
jellyfinServer: 'https://jf.example.test/',
passwordStore: 'gnome-libsecret',
logLevel: 'info',
} as never,
['--jellyfin-libraries'],
);
assert.deepEqual(forwarded, [
'--jellyfin-libraries',
'--jellyfin-server',
'https://jf.example.test',
'--password-store',
'gnome-libsecret',
'--log-level',
'info',
]);
});
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
const parsed = parseJellyfinErrorFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.
`);
assert.equal(
parsed,
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
);
});
test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => {
const parsed = parseJellyfinErrorFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}
`);
assert.equal(
parsed,
'[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}',
);
});
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
const parsed = parseJellyfinPreviewAuthResponse({
serverUrl: 'http://pve-main:8096/',
accessToken: 'token-123',
userId: 'user-1',
});
assert.deepEqual(parsed, {
serverUrl: 'http://pve-main:8096',
accessToken: 'token-123',
userId: 'user-1',
});
});
test('parseJellyfinPreviewAuthResponse returns null without a token', () => {
assert.equal(
parseJellyfinPreviewAuthResponse({
serverUrl: 'http://pve-main:8096',
accessToken: '',
userId: 'user-1',
}),
null,
);
});
test('hasStoredJellyfinSession checks token-store existence', () => {
const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc');
const tokenPath = deriveJellyfinTokenStorePath(configPath);
const exists = (candidate: string): boolean => candidate === tokenPath;
assert.equal(hasStoredJellyfinSession(configPath, exists), true);
assert.equal(
hasStoredJellyfinSession(path.join('/home/test', '.config', 'Other', 'alt.jsonc'), exists),
false,
);
});
test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => {
assert.equal(
shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'),
true,
);
assert.equal(
shouldRetryWithStartForNoRunningInstance(
'Missing Jellyfin session. Run --jellyfin-login first.',
),
false,
);
});
test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => {
assert.deepEqual(
parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'),
{
seriesName: 'KONOSUBA',
seasonNumber: 1,
},
);
assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), {
seriesName: 'Frieren',
seasonNumber: 2,
});
});
test('parseEpisodePathFromDisplay returns null for non-episode displays', () => {
assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null);
assert.equal(parseEpisodePathFromDisplay('Just A Name'), null);
});
test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => {
const groups = buildRootSearchGroups([
{ id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' },
{ id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' },
{ id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' },
]);
assert.deepEqual(groups, [
{
id: 'series-1',
name: 'The Eminence in Shadow',
type: 'Series',
display: 'The Eminence in Shadow (Series)',
},
{
id: 'movie-1',
name: 'Spirited Away',
type: 'Movie',
display: 'Spirited Away (Movie)',
},
]);
});
test('classifyJellyfinChildSelection keeps container drilldown state instead of flattening', () => {
const next = classifyJellyfinChildSelection({ id: 'season-2', type: 'Season' });
assert.deepEqual(next, {
kind: 'container',
id: 'season-2',
});
});
+195 -222
View File
@@ -10,7 +10,8 @@ import type {
JellyfinGroupEntry,
} from './types.js';
import { shouldForwardLogLevel } from './types.js';
import { log, fail, getMpvLogPath } from './log.js';
import type { JellyfinPreviewAuthPayload } from '../src/shared/jellyfin-cli-response.js';
import { log, fail } from './log.js';
import { nowMs } from './time.js';
import { commandExists, resolvePathMaybe, sleep } from './util.js';
import {
@@ -23,11 +24,14 @@ import {
import { loadLauncherJellyfinConfig } from './config.js';
import { resolveLauncherMainConfigPath } from './config/shared-config-reader.js';
import {
adoptLauncherStartedApp,
isAppManagedByLauncher,
runAppCommandWithInheritLogged,
runAppCommandCaptureOutput,
launchAppStartDetached,
launchAppBackgroundDetached,
launchMpvIdleDetached,
resolveLauncherRuntimePluginPath,
waitForUnixSocketClosed,
waitForUnixSocketReady,
} from './mpv.js';
import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js';
@@ -52,6 +56,10 @@ type JellyfinPlayMenuDeps = {
launchMpvIdleDetached: typeof launchMpvIdleDetached;
resolveLauncherRuntimePluginPath: typeof resolveLauncherRuntimePluginPath;
runAppCommandWithInheritLogged: typeof runAppCommandWithInheritLogged;
isAppManagedByLauncher: typeof isAppManagedByLauncher;
runAppCommandCaptureOutput: typeof runAppCommandCaptureOutput;
waitForUnixSocketClosed: typeof waitForUnixSocketClosed;
exitProcess: (code: number) => never;
log: typeof log;
};
@@ -75,6 +83,10 @@ const defaultJellyfinPlayMenuDeps: JellyfinPlayMenuDeps = {
launchMpvIdleDetached,
resolveLauncherRuntimePluginPath,
runAppCommandWithInheritLogged,
isAppManagedByLauncher,
runAppCommandCaptureOutput,
waitForUnixSocketClosed,
exitProcess: (code) => process.exit(code),
log,
};
@@ -171,76 +183,77 @@ function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, '');
}
function parseNamedJellyfinRecord(payload: string): {
name: string;
id: string;
type: string;
} | null {
const typeClose = payload.lastIndexOf(')');
if (typeClose !== payload.length - 1) return null;
// Reply the app writes to --jellyfin-response-path; see src/shared/jellyfin-cli-response.ts.
export type JellyfinAppReply =
| { ok: true; payload: Record<string, unknown> }
| { ok: false; error: string };
const typeOpen = payload.lastIndexOf(' (');
if (typeOpen <= 0 || typeOpen >= typeClose) return null;
const idClose = payload.lastIndexOf(']', typeOpen);
if (idClose <= 0) return null;
const idOpen = payload.lastIndexOf(' [', idClose);
if (idOpen <= 0 || idOpen >= idClose) return null;
const name = payload.slice(0, idOpen).trim();
const id = payload.slice(idOpen + 2, idClose).trim();
const type = payload.slice(typeOpen + 2, typeClose).trim();
if (!name || !id || !type) return null;
return { name, id, type };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function parseJellyfinLibrariesFromAppOutput(output: string): JellyfinLibraryEntry[] {
function readString(record: Record<string, unknown>, key: string): string {
const value = record[key];
return typeof value === 'string' ? value.trim() : '';
}
export function parseJellyfinAppReply(raw: string): JellyfinAppReply | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!isRecord(parsed)) return null;
const error = readString(parsed, 'error');
if (error) return { ok: false, error: normalizeJellyfinAppError(error) };
return { ok: true, payload: parsed };
}
export function parseJellyfinLibrariesReply(
payload: Record<string, unknown>,
): JellyfinLibraryEntry[] | null {
if (!Array.isArray(payload.libraries)) return null;
const libraries: JellyfinLibraryEntry[] = [];
const seenIds = new Set<string>();
for (const rawLine of output.split(/\r?\n/)) {
const line = stripAnsi(rawLine);
const markerIndex = line.indexOf('Jellyfin library:');
if (markerIndex < 0) continue;
const payload = line.slice(markerIndex + 'Jellyfin library:'.length).trim();
const parsed = parseNamedJellyfinRecord(payload);
if (!parsed || seenIds.has(parsed.id)) continue;
seenIds.add(parsed.id);
for (const entry of payload.libraries) {
if (!isRecord(entry)) continue;
const id = readString(entry, 'id');
if (!id || seenIds.has(id)) continue;
seenIds.add(id);
libraries.push({
id: parsed.id,
name: parsed.name,
kind: parsed.type,
id,
name: readString(entry, 'name') || 'Untitled',
kind: readString(entry, 'collectionType') || 'unknown',
});
}
return libraries;
}
export function parseJellyfinItemsFromAppOutput(output: string): JellyfinItemEntry[] {
export function parseJellyfinItemsReply(
payload: Record<string, unknown>,
): JellyfinItemEntry[] | null {
if (!Array.isArray(payload.items)) return null;
const items: JellyfinItemEntry[] = [];
const seenIds = new Set<string>();
for (const rawLine of output.split(/\r?\n/)) {
const line = stripAnsi(rawLine);
const markerIndex = line.indexOf('Jellyfin item:');
if (markerIndex < 0) continue;
const payload = line.slice(markerIndex + 'Jellyfin item:'.length).trim();
const parsed = parseNamedJellyfinRecord(payload);
if (!parsed || seenIds.has(parsed.id)) continue;
seenIds.add(parsed.id);
items.push({
id: parsed.id,
name: parsed.name,
type: parsed.type,
display: parsed.name,
});
for (const entry of payload.items) {
if (!isRecord(entry)) continue;
const id = readString(entry, 'id');
if (!id || seenIds.has(id)) continue;
seenIds.add(id);
const title = readString(entry, 'title') || 'Untitled';
items.push({ id, name: title, type: readString(entry, 'type'), display: title });
}
return items;
}
function normalizeJellyfinAppError(message: string): string {
if (message.includes('Missing Jellyfin session')) {
return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.';
}
return message;
}
export function parseJellyfinErrorFromAppOutput(output: string): string {
const lines = output.split(/\r?\n/).map((line) => stripAnsi(line).trim());
for (let i = lines.length - 1; i >= 0; i -= 1) {
@@ -260,41 +273,20 @@ export function parseJellyfinErrorFromAppOutput(output: string): string {
}
if (line.includes('Missing Jellyfin session')) {
return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.';
return normalizeJellyfinAppError(line);
}
}
return '';
}
type JellyfinPreviewAuthResponse = {
serverUrl: string;
accessToken: string;
userId: string;
};
export function parseJellyfinPreviewAuthResponse(raw: string): JellyfinPreviewAuthResponse | null {
if (!raw || raw.trim().length === 0) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
const candidate = parsed as Record<string, unknown>;
const serverUrl = sanitizeServerUrl(
typeof candidate.serverUrl === 'string' ? candidate.serverUrl : '',
);
const accessToken = typeof candidate.accessToken === 'string' ? candidate.accessToken.trim() : '';
const userId = typeof candidate.userId === 'string' ? candidate.userId.trim() : '';
export function parseJellyfinPreviewAuthResponse(
payload: Record<string, unknown>,
): JellyfinPreviewAuthPayload | null {
const serverUrl = sanitizeServerUrl(readString(payload, 'serverUrl'));
const accessToken = readString(payload, 'accessToken');
const userId = readString(payload, 'userId');
if (!serverUrl || !accessToken) return null;
return {
serverUrl,
accessToken,
userId,
};
return { serverUrl, accessToken, userId };
}
export function shouldRetryWithStartForNoRunningInstance(errorMessage: string): boolean {
@@ -312,19 +304,6 @@ export function hasStoredJellyfinSession(
return exists(deriveJellyfinTokenStorePath(configPath));
}
export function readUtf8FileAppendedSince(logPath: string, offsetBytes: number): string {
try {
const buffer = fs.readFileSync(logPath);
if (buffer.length === 0) return '';
const normalizedOffset =
Number.isFinite(offsetBytes) && offsetBytes >= 0 ? Math.floor(offsetBytes) : 0;
const startOffset = normalizedOffset > buffer.length ? 0 : normalizedOffset;
return buffer.subarray(startOffset).toString('utf8');
} catch {
return '';
}
}
export function parseEpisodePathFromDisplay(
display: string,
): { seriesName: string; seasonNumber: number } | null {
@@ -421,145 +400,79 @@ export function buildForwardedJellyfinAppArgs(args: Args, appArgs: string[]): st
return forwarded;
}
async function runAppJellyfinListCommand(
appPath: string,
args: Args,
appArgs: string[],
label: string,
): Promise<string> {
const attempt = await runAppJellyfinCommand(appPath, args, appArgs, label);
if (attempt.status !== 0) {
const message = attempt.output.trim();
fail(message || `${label} failed.`);
const JELLYFIN_APP_REPLY_TIMEOUT_MS = 60_000;
const JELLYFIN_PREVIEW_AUTH_TIMEOUT_MS = 15_000;
function readJellyfinAppReply(responsePath: string): JellyfinAppReply | null {
try {
return parseJellyfinAppReply(fs.readFileSync(responsePath, 'utf8'));
} catch {
return null;
}
if (attempt.error) {
fail(attempt.error);
}
return attempt.output;
}
async function runAppJellyfinCommand(
// Runs a Jellyfin command in the app and waits for the reply it writes to a temp file. The helper
// process usually hands the command to an already-running instance and exits at once with no
// output, so the reply file is the only result channel that works in both cases.
async function requestJellyfinReplyFromApp(
appPath: string,
args: Args,
appArgs: string[],
label: string,
): Promise<{ status: number; output: string; error: string; logOffset: number }> {
const forwardedBase = buildForwardedJellyfinAppArgs(args, appArgs);
timeoutMs: number,
): Promise<JellyfinAppReply> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jf-reply-'));
const responsePath = path.join(tmpDir, 'response.json');
const forwarded = buildForwardedJellyfinAppArgs(args, [
...appArgs,
`--jellyfin-response-path=${responsePath}`,
]);
const readLogAppendedSince = (offset: number): string => {
const logPath = getMpvLogPath();
return readUtf8FileAppendedSince(logPath, offset);
};
const hasCommandSignal = (output: string): boolean => {
if (label === 'jellyfin-libraries') {
return (
output.includes('Jellyfin library:') || output.includes('No Jellyfin libraries found.')
);
}
if (label === 'jellyfin-items') {
return (
output.includes('Jellyfin item:') ||
output.includes('No Jellyfin items found for the selected library/search.')
);
}
if (label === 'jellyfin-preview-auth') {
return output.includes('Jellyfin preview auth written.');
}
return output.trim().length > 0;
};
const runOnce = (): { status: number; output: string; error: string; logOffset: number } => {
const forwarded = [...forwardedBase];
const logPath = getMpvLogPath();
let logOffset = 0;
try {
if (fs.existsSync(logPath)) {
logOffset = fs.statSync(logPath).size;
}
} catch {
logOffset = 0;
}
const runOnce = (): { status: number; output: string; error: string } => {
log('debug', args.logLevel, `${label}: launching app with args: ${forwarded.join(' ')}`);
const result = runAppCommandCaptureOutput(appPath, forwarded);
log('debug', args.logLevel, `${label}: app command exited with status ${result.status}`);
let output = `${result.stdout || ''}\n${result.stderr || ''}\n${readLogAppendedSince(logOffset)}`;
let error = parseJellyfinErrorFromAppOutput(output);
return { status: result.status, output, error, logOffset };
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
return { status: result.status, output, error: parseJellyfinErrorFromAppOutput(output) };
};
let retriedAfterStart = false;
let attempt = runOnce();
if (shouldRetryWithStartForNoRunningInstance(attempt.error)) {
log('debug', args.logLevel, `${label}: starting app detached, then retrying command`);
launchAppStartDetached(appPath, args.logLevel);
await sleep(1000);
retriedAfterStart = true;
attempt = runOnce();
}
if (attempt.status === 0 && !attempt.error && !hasCommandSignal(attempt.output)) {
// When app is already running, command handling happens in the primary process and log
// lines can land slightly after the helper process exits.
const settleWindowMs = (() => {
if (label === 'jellyfin-items') {
return retriedAfterStart ? 45000 : 30000;
}
return retriedAfterStart ? 12000 : 4000;
})();
const settleDeadline = nowMs() + settleWindowMs;
const settleOffset = attempt.logOffset;
while (nowMs() < settleDeadline) {
await sleep(100);
const settledOutput = readLogAppendedSince(settleOffset);
if (!settledOutput.trim()) {
continue;
}
attempt.output = `${attempt.output}\n${settledOutput}`;
attempt.error = parseJellyfinErrorFromAppOutput(attempt.output);
if (attempt.error || hasCommandSignal(attempt.output)) {
break;
}
}
}
return attempt;
}
async function requestJellyfinPreviewAuthFromApp(
appPath: string,
args: Args,
): Promise<JellyfinPreviewAuthResponse | null> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jf-preview-auth-'));
const responsePath = path.join(tmpDir, 'response.json');
try {
const attempt = await runAppJellyfinCommand(
appPath,
args,
['--jellyfin-preview-auth', `--jellyfin-response-path=${responsePath}`],
'jellyfin-preview-auth',
);
if (attempt.status !== 0 || attempt.error) {
return null;
let attempt = runOnce();
if (shouldRetryWithStartForNoRunningInstance(attempt.error)) {
// Start it the way `subminer app` does: the app outlives this picker and the playback, so it
// needs its tray to be visible and quittable.
log(
'debug',
args.logLevel,
`${label}: starting app in the background, then retrying command`,
);
launchAppBackgroundDetached(appPath, args.logLevel);
adoptLauncherStartedApp(appPath, args);
// A cold start (an AppImage mount, first-run setup) can take a while to claim the instance
// lock, so keep retrying until the app takes the command or the request deadline passes.
const startDeadline = nowMs() + timeoutMs;
do {
await sleep(1000);
attempt = runOnce();
} while (shouldRetryWithStartForNoRunningInstance(attempt.error) && nowMs() < startDeadline);
}
// A written reply is authoritative; the exit status and log lines only explain a missing one.
const earlyReply = readJellyfinAppReply(responsePath);
if (earlyReply) return earlyReply;
if (attempt.status !== 0) {
return { ok: false, error: attempt.error || attempt.output.trim() || `${label} failed.` };
}
if (attempt.error) {
return { ok: false, error: attempt.error };
}
const deadline = nowMs() + 4000;
const deadline = nowMs() + timeoutMs;
while (nowMs() < deadline) {
try {
if (fs.existsSync(responsePath)) {
const raw = fs.readFileSync(responsePath, 'utf8');
const parsed = parseJellyfinPreviewAuthResponse(raw);
if (parsed) {
return parsed;
}
}
} catch {
// retry until timeout
}
const reply = readJellyfinAppReply(responsePath);
if (reply) return reply;
await sleep(100);
}
return null;
return { ok: false, error: `Timed out waiting for SubMiner to answer ${label}.` };
} finally {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
@@ -569,19 +482,57 @@ async function requestJellyfinPreviewAuthFromApp(
}
}
async function requestJellyfinPreviewAuthFromApp(
appPath: string,
args: Args,
): Promise<JellyfinPreviewAuthPayload | null> {
const reply = await requestJellyfinReplyFromApp(
appPath,
args,
['--jellyfin-preview-auth'],
'jellyfin-preview-auth',
JELLYFIN_PREVIEW_AUTH_TIMEOUT_MS,
);
if (!reply.ok) {
log('debug', args.logLevel, `jellyfin-preview-auth: ${reply.error}`);
return null;
}
return parseJellyfinPreviewAuthResponse(reply.payload);
}
async function requestJellyfinListFromApp<T>(
appPath: string,
args: Args,
appArgs: string[],
label: string,
parse: (payload: Record<string, unknown>) => T[] | null,
): Promise<T[]> {
const reply = await requestJellyfinReplyFromApp(
appPath,
args,
appArgs,
label,
JELLYFIN_APP_REPLY_TIMEOUT_MS,
);
if (!reply.ok) fail(reply.error);
const entries = parse(reply.payload);
if (!entries) fail(`Unexpected reply from SubMiner for ${label}.`);
return entries;
}
async function resolveJellyfinSelectionViaApp(
appPath: string,
args: Args,
session: JellyfinSessionConfig,
themePath: string | null = null,
): Promise<string> {
const listLibrariesOutput = await runAppJellyfinListCommand(
const libraries = await requestJellyfinListFromApp(
appPath,
args,
['--jellyfin-libraries'],
'jellyfin-libraries',
parseJellyfinLibrariesReply,
);
const libraries = parseJellyfinLibrariesFromAppOutput(listLibrariesOutput);
if (libraries.length === 0) {
fail('No Jellyfin libraries found.');
}
@@ -655,8 +606,13 @@ async function resolveJellyfinSelectionViaApp(
if (includeItemTypes) {
itemArgs.push(`--jellyfin-include-item-types=${includeItemTypes}`);
}
const output = await runAppJellyfinListCommand(appPath, args, itemArgs, 'jellyfin-items');
return parseJellyfinItemsFromAppOutput(output);
return requestJellyfinListFromApp(
appPath,
args,
itemArgs,
'jellyfin-items',
parseJellyfinItemsReply,
);
};
let rootItems =
@@ -1106,5 +1062,22 @@ export async function runJellyfinPlayMenuWithDeps(
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
if (!deps.isAppManagedByLauncher()) {
// A running app keeps running after playback; a cold `--start --jellyfin-play` makes the app
// quit with mpv on its own.
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
}
// The picker started the app, so this playback owns it, as with local playback: hand over the
// item, wait for mpv to close, then exit, which stops the app.
const handoff = deps.runAppCommandCaptureOutput(appPath, forwarded);
if (handoff.status !== 0) {
fail(handoff.error?.message || handoff.stderr.trim() || 'Failed to start Jellyfin playback.');
}
deps.log(
'debug',
args.logLevel,
'jellyfin-play: waiting for mpv to close before stopping the app',
);
await deps.waitForUnixSocketClosed(mpvSocketPath);
return deps.exitProcess(0);
}
Loaded 100 of 1010 files, more files were not shown because too many files have changed in this diff. Show more