Compare commits

..

22 Commits

Author SHA1 Message Date
sudacode 29cb8c7fb4 chore: release 0.15.0-beta.10 2026-05-27 01:54:52 -07:00
sudacode 1dcfed86ab fix: Kiku field grouping, frequency particles, sidebar media, Yomitan popup visibility (#91) 2026-05-27 01:40:48 -07:00
sudacode efe50ed1e4 docs: add startup flow diagram and document new config options (#90) 2026-05-26 23:51:36 -07:00
sudacode 5b44981688 docs: remove legacy option refs and modernize config docs (#89) 2026-05-26 18:15:23 -07:00
sudacode 2add95d541 fix(anilist): dedupe failures during retry cooldown and block dead-lette
- Ignore markFailure calls while an item is still within its retry backoff window
- Prevent enqueue from re-adding keys already in the dead-letter queue
2026-05-26 01:57:25 -07:00
sudacode f62fff2585 chore(release): 0.15.0-beta.9 2026-05-26 00:55:34 -07:00
sudacode 11c196821d Fix Windows mpv logging and add log export (#88) 2026-05-26 00:31:38 -07:00
sudacode 43ebc7d371 chore: prepare 0.15.0-beta.8 prerelease 2026-05-25 20:34:56 -07:00
sudacode 639e331f24 fix(character-dictionary): add surname honorifics for Japanese localized aliases (#87) 2026-05-25 20:12:27 -07:00
sudacode 78be72e32f chore: prepare 0.15.0-beta.7 prerelease 2026-05-25 18:37:03 -07:00
sudacode 3932e53ced feat(character-dictionary): add manager modal and scope name matching to current media (#86) 2026-05-25 18:29:20 -07:00
sudacode 097b619d71 fix: settings window z-order on Hyprland and Linux app detach (#85) 2026-05-25 13:21:38 -07:00
sudacode f7abcedd75 chore: prepare 0.15.0-beta.6 prerelease 2026-05-25 03:25:34 -07:00
sudacode 807c0ff3db Add inline character portraits and dictionary search workflow (#83) 2026-05-25 03:16:25 -07:00
sudacode 7e6f9672cf fix: suppress overlay subtitle immediately when character dictionary modal opens (#84) 2026-05-25 02:30:33 -07:00
sudacode 9fe13601fb Launch macOS app background-detached when no args passed
- Add `launchAppBackgroundDetached` that spawns with `--start --background` and `SUBMINER_BACKGROUND_CHILD=1`
- On darwin with empty appArgs, use detached background launch instead of inherited process
- Add `extraEnv` param to `launchAppCommandDetached` for env injection
- Inject deps into `runAppPassthroughCommand` for testability
- Bump vendor/subminer-yomitan submodule
2026-05-25 02:12:41 -07:00
sudacode 920cbab1bc Fix Windows mpv handoff and tray setup (#82) 2026-05-25 01:34:01 -07:00
sudacode 17d97f0b7e fix: rename Windows ZIPs and fix macOS manual update checks (#81) 2026-05-24 23:47:02 -07:00
sudacode 10463e7348 chore: prepare 0.15.0-beta.5 prerelease 2026-05-24 19:11:02 -07:00
sudacode e9abbd5f05 style: format youtube runtime files 2026-05-24 19:10:58 -07:00
sudacode d6ff50455a fix(changelog): summarize prerelease notes as net outcome 2026-05-24 19:10:53 -07:00
sudacode b1bdeabca8 fix(jellyfin): show overlay, inject plugin, and fix stats title on playback (#77)
* fix(jellyfin): show overlay, inject plugin, and fix stats title on playb

- Show visible overlay automatically during Jellyfin playback so subtitleStyle applies
- Inject bundled mpv plugin on auto-launch so keybindings work without overlay focus
- Group Jellyfin playback stats under item metadata (jellyfin://host/item/id) instead of stream URLs so episodes merge with matching local titles
- Mark ffsubsync unavailable in subsync modal for remote media paths
- Drain queued second-instance commands even when onReady throws

* fix(overlay): stabilize macOS focus handoff and sidebar Yomitan pause

- Keep overlay visible during macOS foreground probe after overlay blur
- Hold sidebar hover-pause while a Yomitan lookup popup remains open

* fix(jellyfin): fix discovery loop, device identity, tray state, and Disc

- Derive device identity from OS hostname; remove legacy configurable client/device fields
- Prevent discovery playback from reloading active item, misreporting pause state, and duplicate overlay restores
- Restart stale tray discovery sessions without re-login when server drops SubMiner cast target
- Sync tray discovery checkbox state on Linux after CLI/startup/remote-session changes
- Stop Discord presence falling back to stream URLs; prime title before tokenized stream loads
- Fix picker library discovery when log level is above info
- Fix config.example.jsonc trailing commas and array formatting

* docs(release): trim and consolidate prerelease notes for 0.15.0

- Remove breaking changes section and several redundant bullet points
- Consolidate per-platform updater notes into a single entry
- Normalize em-dash separators to hyphens in section headers

* fix(config): remove trailing commas from config.example.jsonc

- Strip trailing commas throughout both config.example.jsonc copies
- Reformat inline arrays to multi-line for JSON strictness
- Update Jellyfin subtitle preload and playback launch tests and impl

* fix(tokenizer): preserve known-word highlight when POS filters suppress

- Known-word cache matches now set isKnown=true even for tokens excluded by POS filters
- POS exclusion gate suppresses N+1, frequency, and JLPT only; known status is computed before the gate
- Jellyfin subtitle preload continues after cleanup failures instead of aborting
- Update config docs and option description to document the known-word bypass behavior

* fix(jellyfin): send explicit hide/show overlay instead of toggle

- Track overlay visibility in plugin state; y-t uses explicit hide/show commands when state is known
- Prevent paused Jellyfin playback from resuming on overlay hide
- Fix subtitle cache cleanup to only remove dirs after successful cleanup

* fix(jellyfin): fix remote progress sync, seek reporting, and startup sto

- arm active playback before loadfile with loadedMediaPath: null to suppress premature stop events
- force immediate progress report on seek-like position jumps at the mpv time-pos level
- send positionTicks and failed=false in reportStopped payload
- remove EventName from HTTP timeline payloads (websocket-only field)
- add startup grace window to drop stop events before media finishes loading

* fix(jellyfin): fix overlay toggle sync, redirect reload, and AppImage bi

- Sync visible-overlay state back to plugin via script messages to avoid toggle/hide drift
- Collapse duplicate toggle events within 250ms to prevent hide-then-show on single keypress
- Preserve manual hide across Jellyfin path-changing redirects even when media-title drops
- Rearm managed subtitle defaults on path-changing redirects
- Route toggleVisibleOverlay session binding through plugin toggle instead of app-side IPC
- Show Linux/Hyprland overlay passively (showInactive) to avoid stealing mpv keyboard focus
- Fix AppImage binary resolution to prefer $APPIMAGE env over mounted inner binary
- Add stats window layer management so delete/update dialogs appear above stats window
- Fix Jellyfin remote progress sync during Linux websocket reconnect windows

* Fix CodeRabbit review feedback

* fix(jellyfin): subtitle timing, resume progress, and overlay sync

- Add per-stream subtitle delay persistence and auto timeline-offset correction
- Strip server-selected subtitle stream from mpv load URL; suppress plugin subtitle rearm and auto-start during app-managed preload
- Fix resume position lost when mpv resets on stop; use last known position for final progress/stopped reports
- Keep Play vs Resume distinct to avoid early seek race on normal play
- Fix discovery resume when remote play sends StartPositionTicks=0 despite saved progress
- Deduplicate show/hide overlay commands using recorded visibility state
- Rewrite docs-site Jellyfin page around cast-to-device UX

* test: update lifecycle cleanup assertion

* fix: clear aborted playback state, fix overlay passthrough, and guard du

- Reset app_managed_playback_pending on lifecycle cleanup to prevent state leak into next item
- Record visible overlay action only after command succeeds, not before
- Non-native passive overlay now always click-through on re-show (fix isNonNativePassiveOverlay ordering)
- Defer activeParsedSubtitleMediaPath assignment until after prefetch completes
- Move autoplay gate release into the hide branch of toggleVisibleOverlay
- Clear active Jellyfin playback when stopping media that never loaded
- Reset managed subtitle delay and delay key when no external tracks are available
- Await async removeDir in subtitle cache cleanup
- Guard duplicate delete clicks in MediaDetailView and SessionsTab with refs
- Escape key in DeleteConfirmDialog now calls stopPropagation and stopImmediatePropagation
2026-05-24 18:40:56 -07:00
439 changed files with 18601 additions and 2398 deletions
+2
View File
@@ -34,11 +34,13 @@ Rules:
How fragments turn into a release: How fragments turn into a release:
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that. - At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
- 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.
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely. - `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something. - The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
Prerelease notes: Prerelease notes:
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md` - prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
- existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md` - prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
- the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes - the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anilist
- Prevent repeated missing-token checks from rapidly exhausting AniList retry attempts or duplicating dead-letter entries for the same episode.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: release
- Release-note polishing now treats pending fragments and reviewed prerelease notes as a cumulative final outcome, so prerelease-only fixes or breakages collapse into the final user-facing change.
@@ -0,0 +1,4 @@
type: changed
area: dictionary
- Keep character dictionary lookup entries scoped to generated Japanese name aliases instead of surfacing raw romanized/English aliases as separate results, and refresh cached v15 snapshots so old English-name entries are regenerated.
@@ -0,0 +1,4 @@
type: fixed
area: character-dictionary
- Block the character dictionary manager when character dictionary annotations are disabled, and notify through the configured OSD/system notification surfaces.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: character-dictionary
- Character dictionary entries are now scoped to the current AniList media for name matching and inline portraits, and a new `Ctrl/Cmd+D` manager modal can remove, reorder, or override loaded dictionary entries.
@@ -0,0 +1,4 @@
type: changed
area: character-dictionary
- **Character Dictionary:** Changed the in-app AniList selector to wait for an explicit title search. The search box is prefilled from the current filename guess, so you can edit it before choosing an override.
+7
View File
@@ -0,0 +1,7 @@
type: added
area: subtitles
- Added optional inline AniList portraits for character-name subtitle matches, including automatic refresh of cached character dictionary snapshots that do not contain portrait data.
- Scoped manual AniList overrides by parent media directory, so separate season folders can keep separate character dictionary selections.
- Fixed large character dictionary imports by serving the merged ZIP through a local URL when supported, with a base64 fallback for older bundled Yomitan builds.
- Allowed subtitle overlay data image sources so inline character portraits render instead of showing a broken image icon.
-4
View File
@@ -1,4 +0,0 @@
type: changed
area: settings
- Simplified configuration option rows by hiding raw config paths and placing the live/restart status beside each option title.
@@ -0,0 +1,7 @@
type: changed
area: docs
- Documented all config options that were present in `config.example.jsonc` but missing from the configuration reference: `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options (`socketPath`, `backend`, `autoStartSubMiner`, `pauseUntilOverlayReady`, `subminerBinaryPath`, `aniskipEnabled`, `aniskipButtonKey`).
- Added a **Playback Startup Flow** diagram to the Architecture page showing how the managed launch (`subminer` CLI, app, Windows shortcut) injects the plugin, establishes the IPC socket, and brings up the overlay via the two convergent triggers.
- Added a **Runtime Sockets** section and diagram to the IPC + Runtime Contracts page showing the mpv IPC socket and app control socket topology.
- Added cross-reference pointers in the MPV Plugin and Troubleshooting pages.
@@ -0,0 +1,4 @@
type: fixed
area: character-dictionary
- Added surname honorific matches for Japanese localized character aliases embedded in AniList alternative names, such as Korean-source characters with Japanese names in parentheses, and refresh cached snapshots so those aliases are regenerated.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Hid the visible subtitle overlay as soon as the character dictionary modal opens, including while AniList lookup is still loading or returns no results.
@@ -0,0 +1,4 @@
type: fixed
area: integrations
- Prevented Discord Rich Presence from falling back to Jellyfin stream URLs, and primed Jellyfin playback titles before loading tokenized streams so presence shows the show/episode title
@@ -0,0 +1,4 @@
type: fixed
area: desktop
- Fixed Hyprland settings windows opening behind the subtitle overlay by promoting SubMiner and Yomitan settings above the overlay without hiding subtitles.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Prevented Jellyfin discovery playback from reloading the active item, misreporting paused mpv playback as still playing, retrying startup unpause after playback is paused again, unpausing after a manual `y-t` overlay toggle during startup, repeatedly restoring the overlay from duplicate ready signals, missing delayed Japanese subtitle selection on startup, letting later German/Russian subtitle loads steal the selected Japanese track, and spawning long-lived sidebar ffmpeg extractors against Jellyfin stream URLs.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Fixed Jellyfin discovery resume playback when a remote play command sends `StartPositionTicks: 0` despite saved progress on the item.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Derived Jellyfin cast device identity from the OS hostname, always reports the client as SubMiner, and ignores legacy configurable Jellyfin client/device identity fields so multiple SubMiner installs no longer share the same remote-session identity.
+11
View File
@@ -0,0 +1,11 @@
type: fixed
area: jellyfin
- Fixed Jellyfin `y-t` overlay hide so the plugin sends an explicit hide command when it knows the overlay is visible, avoiding overlay reloads and paused playback resumes.
- Kept that manual hide sticky across Jellyfin stream redirects that change mpv's path, even when the redirected URL drops mpv's media title.
- Re-armed managed subtitle defaults during those path-changing redirects so Japanese primary subtitles can load on the redirected stream.
- Routed visible-overlay shortcuts and app-side visibility changes back through the mpv plugin so SubMiner overlay toggling stays independent of Jellyfin playback controls.
- Collapsed duplicate visible-overlay toggle events so Hyprland does not process one physical shortcut as hide-then-show.
- Kept passive Linux/Hyprland visible-overlay shows from taking keyboard focus away from mpv/Jellyfin.
- Made Jellyfin external subtitle selection tolerate transient mpv `track-list` read failures and numeric string track IDs so Japanese subtitles are selected after preload on Linux.
- Fixed AppImage-launched Jellyfin playback controls so mpv sends overlay commands to the running SubMiner app-control socket instead of the mounted Electron binary.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Fixed Jellyfin remote controller visibility and progress syncing for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Grouped Jellyfin playback stats under Jellyfin item metadata instead of stream URLs, so watched episodes merge with matching local library titles and keep clean display names.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Improved Jellyfin subtitle timing behavior by preferring default embedded subtitle streams over external sidecars, stripping Jellyfin's server-selected subtitle stream from mpv playback URLs, suppressing mpv's subtitle auto-selection and plugin overlay auto-start while SubMiner stages managed tracks, automatically correcting clear Japanese-vs-English cue timeline offsets, and restoring saved per-stream subtitle delay shifts.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Keep the Jellyfin discovery tray checkbox in sync on Linux after tray, CLI, or startup remote-session changes, with a visible check mark when Linux tray hosts ignore native checkbox rendering.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Restarted stale Jellyfin tray discovery sessions when the server no longer lists the SubMiner cast target, avoiding a needless Jellyfin re-login.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: jellyfin
- Preserved Jellyfin-visible resume progress when mpv resets its position during playback stop by reusing SubMiner's last known playback position for final progress and stopped reports.
- Kept Jellyfin remote Play and Resume distinct so normal Play starts from the beginning, while Resume starts at Jellyfin's requested position without an early mpv seek race.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Fixed Kiku duplicate-card field grouping so local duplicate sentence cards trigger the manual modal or auto merge, modal-open acknowledgement races no longer cancel the flow, and merged card fields follow Kiku's group ordering, sentence-audio, furigana, and tag semantics.
@@ -1,4 +0,0 @@
type: changed
area: config
- Reorganized each known-words deck row in the Settings window into a card with the deck name on its own header line so longer deck names stay readable instead of being truncated.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Fixed `subminer app` on Linux so launching the tray app returns control to the terminal immediately instead of waiting for the tray process to exit.
@@ -0,0 +1,4 @@
type: fixed
area: mpv
- Pass generated session-action CLI args to the mpv plugin.
@@ -0,0 +1,7 @@
type: fixed
area: logging
- Forward SubMiner `logging.level` into launcher-started and Windows shortcut-started mpv sessions, including mpv log verbosity, plugin script logging, and plugin-launched app logging.
- Add numeric `logging.rotation`, defaulting to 7 days of retained daily app, launcher, and mpv logs.
- Log Windows mpv launch diagnostics, IPC socket connection state, subtitle track summaries, Yomitan extension load state, dictionary counts, and expected/active IPC socket values when plugin auto-start skips due to a socket mismatch.
- Add `logging.files` toggles for app, launcher, and mpv logs, with mpv logs disabled by default unless explicitly enabled for debugging.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updater
- Clarified that beta/RC update checks are controlled by `updates.channel`; set it to `"prerelease"` to receive beta/RC updates.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Kept playback paused for Yomitan lookup popups opened from the subtitle sidebar when popup auto-pause is enabled.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Stats: Fixed in-player stats layering so delete confirmations, overlay modals, and update-check dialogs appear above the stats window.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed Windows managed mpv launches from a background SubMiner instance so the existing warm app receives the start command, retargets the new mpv socket, binds to the player window, and receives startup overlay options.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: subtitles
- Fixed frequency annotations for Yomitan single-token compounds with internal particles, such as `目の前`, while keeping pure grammar/kana helper spans unannotated.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Kept Jellyfin picker library discovery working when the running app log level is above info.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: jellyfin
- Showed the visible subtitle overlay automatically during Jellyfin playback so configured `subtitleStyle` appearance applies to Jellyfin subtitles.
- Injected the bundled mpv plugin when SubMiner auto-launches mpv for Jellyfin playback, restoring mpv-side keybindings without needing overlay focus.
+5
View File
@@ -0,0 +1,5 @@
type: changed
area: anki
- `ankiConnect.nPlusOne.enabled` is no longer implicitly set to `true` when `ankiConnect.knownWords.highlightEnabled` is `true`. Users who rely on known-word highlighting and want N+1 target highlighting must now set `ankiConnect.nPlusOne.enabled: true` explicitly.
- Updated known-word cache docs and examples to recommend expression/word fields and removed legacy-option references from user-facing config docs.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: logs
- Add sanitized log ZIP exports from the tray menu and `subminer logs -e`, with home-directory usernames redacted from exported log contents.
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed type: fixed
area: updater area: updater
- Fixed tray update checks for builds that cannot install native app updates, showing a manual install message instead of a restart prompt that cannot apply the update. - Fixed macOS tray update checks for builds that cannot install native app updates, so newer stable or prerelease GitHub releases are reported instead of incorrectly saying the current build is up to date.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Kept the macOS visible overlay stable when clicking from the overlay back into mpv.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: release
- Fixed macOS updater metadata mismatches by giving macOS and Windows ZIP release assets distinct build-time filenames.
@@ -0,0 +1,4 @@
type: fixed
area: logging
- Stop repeated MPV IPC socket warning spam while the app waits in the background for mpv to recreate the IPC socket.
@@ -0,0 +1,4 @@
type: fixed
area: character-dictionary
- Use `subtitleStyle.nameMatchEnabled` as the only switch for character-dictionary sync/builds and hide the legacy `anilist.characterDictionary.enabled` option.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: config
- Added `subtitleStyle.primaryVisibleOnYomitanPopup` to keep hover-mode primary subtitles visible while a Yomitan popup is open.
@@ -1,7 +0,0 @@
type: changed
area: launcher
breaking: true
- Renamed the SubMiner Configuration window to the Settings window across the UI, tray menu, docs, and CLI verbiage.
- Replaced the `--config` flag and `subminer config` (no action) entry points with `--settings` and `subminer settings`. The `subminer config` subcommand now only accepts `path` or `show`.
- Removed the `--settings` alias that previously opened the bundled Yomitan settings popup. Use `--yomitan` to open Yomitan settings.
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Sentence cards now refresh the current secondary subtitle before saving, so SelectionText uses the loaded translation instead of repeating the primary subtitle.
-4
View File
@@ -1,4 +0,0 @@
type: changed
area: config
- Settings: reorganized playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls in the settings modal.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed subtitle sidebar mining so Yomitan-enriched cards use audio and images from the clicked sidebar line instead of the current primary subtitle line.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: tray
- Fixed the Windows tray "Open SubMiner Setup" action so it opens the setup window after first-run setup is already complete.
+17 -16
View File
@@ -46,10 +46,16 @@
// Logging // Logging
// Controls logging verbosity. // Controls logging verbosity.
// Set to debug for full runtime diagnostics. // Set to debug for full runtime diagnostics.
// Hot-reload: logging.level applies live while SubMiner is running. // Hot-reload: logging.level and logging.files apply live while SubMiner is running.
// ========================================== // ==========================================
"logging": { "logging": {
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error "level": "warn", // Minimum log level for runtime logging. Values: debug | info | warn | error
"rotation": 7, // Number of days of app, launcher, and mpv logs to retain.
"files": {
"app": true, // Write SubMiner app runtime logs. Values: true | false
"launcher": true, // Write launcher command logs. Values: true | false
"mpv": false // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false
} // Files setting.
}, // Controls logging verbosity. }, // Controls logging verbosity.
// ========================================== // ==========================================
@@ -83,7 +89,7 @@
"rightStickPress": 10, // Raw button index used for controller R3 input. "rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input. "leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 input. "rightTrigger": 7 // Raw button index used for controller R2 input.
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors. }, // Semantic button-name reference mapping used for debug output. Updating it does not rewrite existing raw binding descriptors.
"bindings": { "bindings": {
"toggleLookup": { "toggleLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
@@ -187,7 +193,7 @@
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes. "multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
"toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility. "toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility.
"markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card. "markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card.
"openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal. "openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal. "openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal. "openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
@@ -383,7 +389,9 @@
"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 "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 "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 "autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. 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
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary. "nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight. "nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights. "knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
@@ -523,8 +531,8 @@
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. "decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
}, // Known words setting. }, // Known words setting.
"behavior": { "behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
@@ -587,11 +595,8 @@
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false "enabled": false, // Enable AniList post-watch progress updates. Values: true | false
"accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup. "accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
"characterDictionary": { "characterDictionary": {
"enabled": false, // Enable automatic Yomitan character dictionary sync for currently watched AniList media. Values: true | false
"refreshTtlHours": 168, // Legacy setting; merged character dictionary retention is now usage-based and this value is ignored.
"maxLoaded": 3, // Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary. "maxLoaded": 3, // Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary.
"evictionPolicy": "delete", // Legacy setting; merged character dictionary eviction is usage-based and this value is ignored. Values: disable | delete "profileScope": "all", // Yomitan profile scope for character dictionary settings updates. Values: all | active
"profileScope": "all", // Yomitan profile scope for dictionary enable/disable updates. Values: all | active
"collapsibleSections": { "collapsibleSections": {
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false "description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
"characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false "characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false
@@ -624,7 +629,7 @@
"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 mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen "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. "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. "socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows "backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false "autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false "pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
@@ -644,14 +649,10 @@
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup. "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login. "username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
+26 -13
View File
@@ -19,18 +19,26 @@ type VersionManifest = {
versions: Array<{ version: string; path: string }>; versions: Array<{ version: string; path: string }>;
}; };
const base = normalizeBase(process.env.SUBMINER_DOCS_BASE ?? '/'); function optionalEnv(value: string | undefined): string | undefined {
const outDir = process.env.SUBMINER_DOCS_OUT_DIR; return value && value !== 'undefined' ? value : undefined;
const docsSourceDir = process.env.SUBMINER_DOCS_SOURCE_DIR ?? process.cwd(); }
const localArchiveDir = resolve(
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR ?? const base = normalizeBase(optionalEnv(process.env.SUBMINER_DOCS_BASE) ?? '/');
join(docsSourceDir, '..', '.tmp/docs-versioned-site'), const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
); const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
const channel = normalizeChannel(process.env.SUBMINER_DOCS_CHANNEL); const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
const docsVersion = process.env.SUBMINER_DOCS_VERSION; const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
const latestStable = process.env.SUBMINER_DOCS_LATEST_STABLE ?? 'v0.14.0'; const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.14.0';
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST); const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
const versionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN ?? 'production'; const versionLinkOrigin =
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
function getLocalArchiveDir(): string {
return resolve(
optionalEnv(process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR) ??
join(docsSourceDir, '..', '.tmp/docs-versioned-site'),
);
}
function normalizeBase(value: string): string { function normalizeBase(value: string): string {
if (!value || value === '/') return '/'; if (!value || value === '/') return '/';
@@ -43,7 +51,7 @@ function normalizeChannel(value: string | undefined): DocsChannel {
} }
function parseVersionManifest(value: string | undefined): VersionManifest { function parseVersionManifest(value: string | undefined): VersionManifest {
if (!value) { if (!value || value === 'undefined') {
return { return {
latestStable, latestStable,
channels: [ channels: [
@@ -218,6 +226,7 @@ function isFile(path: string): boolean {
function archiveFileForPathname(pathname: string): string | null { function archiveFileForPathname(pathname: string): string | null {
if (!shouldHandleLocalVersionRoute(pathname)) return null; if (!shouldHandleLocalVersionRoute(pathname)) return null;
const localArchiveDir = getLocalArchiveDir();
const routePath = decodeURIComponent(pathname).replace(/^\/+/, ''); const routePath = decodeURIComponent(pathname).replace(/^\/+/, '');
const filePath = resolve(localArchiveDir, routePath); const filePath = resolve(localArchiveDir, routePath);
if (filePath !== localArchiveDir && !filePath.startsWith(`${localArchiveDir}${sep}`)) { if (filePath !== localArchiveDir && !filePath.startsWith(`${localArchiveDir}${sep}`)) {
@@ -234,7 +243,11 @@ function archiveFileForPathname(pathname: string): string | null {
} }
function serveLocalArchiveRoute(pathname: string, response: DevServerResponse): boolean { function serveLocalArchiveRoute(pathname: string, response: DevServerResponse): boolean {
if (versionLinkOrigin !== 'local') return false; if (
(optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? versionLinkOrigin) !== 'local'
) {
return false;
}
const filePath = archiveFileForPathname(pathname); const filePath = archiveFileForPathname(pathname);
if (!filePath) return false; if (!filePath) return false;
+1 -2
View File
@@ -98,12 +98,11 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ | | ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | | `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) | | `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) | | `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one | | `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded | | `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format. See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format. Character dictionary sync follows `subtitleStyle.nameMatchEnabled`.
## CLI Commands ## CLI Commands
+1 -1
View File
@@ -37,7 +37,7 @@ In both modes, the enrichment workflow is the same:
5. Writes metadata to the miscInfo field. 5. Writes metadata to the miscInfo field.
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks. Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks.
Known-word sync scope is controlled by `ankiConnect.knownWords.decks` (object map), with `ankiConnect.deck` used as legacy fallback. Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
### Proxy Mode Setup (Yomitan / Texthooker) ### Proxy Mode Setup (Yomitan / Texthooker)
+37
View File
@@ -269,6 +269,43 @@ For domains migrated to reducer-style transitions (for example AniList token/que
- Reducer boundary: when a domain has transition helpers in `src/main/state.ts`, new callsites should route updates through those helpers instead of ad-hoc object mutation in `main.ts` or composers. - Reducer boundary: when a domain has transition helpers in `src/main/state.ts`, new callsites should route updates through those helpers instead of ad-hoc object mutation in `main.ts` or composers.
- Tests for migrated domains should assert both the intended field changes and non-targeted field invariants. - Tests for migrated domains should assert both the intended field changes and non-targeted field invariants.
## Playback Startup Flow
Before the app boots, something has to launch mpv, inject the plugin, and bring the overlay up. SubMiner-managed launches own this step — the `subminer` launcher, the app's own playback, and the packaged Windows shortcut all follow the same path. The launcher reads `config.jsonc`, spawns mpv with the IPC socket and the bundled plugin, and passes runtime settings as `--script-opts`. The plugin never reads a config file: the shipped `subminer.conf` is intentionally empty so command-line opts always win.
Once mpv is up, exactly one of two triggers brings up the overlay. On a first launch the plugin's `file-loaded` hook self-starts the app once the socket is ready (because the launcher injected `auto_start=yes`). When the app is already running — or for explicit `--start-overlay` and YouTube flows — the launcher instead attaches over the control socket and suppresses the plugin's auto-start, so the two never fire together. Both converge on the same app bring-up, which then runs the Program Lifecycle below.
```mermaid
flowchart TB
classDef entry fill:#c6a0f6,stroke:#494d64,color:#24273a,stroke-width:2px,font-weight:bold
classDef extrt fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef decision fill:#f5a97f,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef proc fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef app fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef overlay fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
Entry["Managed launch<br/>subminer CLI · app · Windows shortcut"]:::entry
Entry --> Cfg["Launcher reads config.jsonc<br/>→ plugin runtime config"]:::extrt
Cfg --> Spawn["Spawn mpv<br/>--input-ipc-server=/tmp/subminer-socket<br/>--script=plugin/subminer/main.lua<br/>--script-opts=subminer-… (auto_start, backend, …)"]:::proc
Spawn --> Boot["Plugin boot · read_options('subminer')<br/>empty subminer.conf; CLI opts win"]:::extrt
Boot --> Sock["mpv IPC socket ready"]:::proc
Sock --> Who{"Overlay trigger"}:::decision
Who -->|"app already running, or<br/>--start-overlay / YouTube"| Attach["Launcher startOverlay()<br/>attach via control socket<br/>plugin auto-start suppressed"]:::proc
Who -->|"first launch, auto_start=yes"| Self["Plugin file-loaded hook<br/>polls socket → process.start_overlay()"]:::extrt
Attach --> AppUp
Self --> AppUp
AppUp["Spawn / attach SubMiner app<br/>--start --managed-playback --socket … --backend …"]:::app
AppUp --> Ctrl["App control server up<br/>/tmp/subminer-control-* dedupes a 2nd launch"]:::app
Ctrl --> Life["app.whenReady → Program Lifecycle (below)"]:::app
Life --> Conn["MpvIpcClient connects to /tmp/subminer-socket"]:::overlay
Conn --> Show["Transparent overlay over mpv<br/>Yomitan lookup · mine"]:::overlay
```
The runtime sockets in this flow are detailed in [IPC + Runtime Contracts](./ipc-contracts#runtime-sockets).
## Program Lifecycle ## Program Lifecycle
- **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend. - **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
+4
View File
@@ -1,5 +1,9 @@
# Changelog # Changelog
## Unreleased
- **Character Dictionary:** Loaded entries are now scoped to the current AniList media for subtitle name matching and inline portraits. Added a character dictionary manager at `Ctrl/Cmd+D`; AniList overrides now live inside that manager instead of using a separate default shortcut.
## v0.14.0 (2026-05-12) ## v0.14.0 (2026-05-12)
SubMiner no longer requires a globally-installed mpv plugin. The bundled plugin is injected at runtime only when SubMiner launches mpv — through the `subminer` launcher, the app's managed launch, or the packaged Windows SubMiner mpv shortcut. When you open mpv on its own, SubMiner is not involved and the plugin is never loaded. If you have a legacy global SubMiner plugin under mpv's `scripts` directory, first-run setup detects it and prompts you to remove it before playback starts. SubMiner no longer requires a globally-installed mpv plugin. The bundled plugin is injected at runtime only when SubMiner launches mpv — through the `subminer` launcher, the app's managed launch, or the packaged Windows SubMiner mpv shortcut. When you open mpv on its own, SubMiner is not involved and the plugin is never loaded. If you have a legacy global SubMiner plugin under mpv's `scripts` directory, first-run setup detects it and prompts you to remove it before playback starts.
+38 -18
View File
@@ -14,14 +14,14 @@ The feature has three stages: **snapshot**, **merge**, and **match**.
2. **Merge** — SubMiner maintains a most-recently-used list of media IDs (default: 3). Snapshots from those titles are merged into a single Yomitan ZIP — `character-dictionaries/merged.zip` — which is always named "SubMiner Character Dictionary" so Yomitan treats it as a single stable dictionary across rebuilds. 2. **Merge** — SubMiner maintains a most-recently-used list of media IDs (default: 3). Snapshots from those titles are merged into a single Yomitan ZIP — `character-dictionaries/merged.zip` — which is always named "SubMiner Character Dictionary" so Yomitan treats it as a single stable dictionary across rebuilds.
3. **Match** — During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. Tokens that match a character entry are flagged with `isNameMatch` and highlighted in the overlay with a distinct color. 3. **Match** — During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. SubMiner only accepts character entries for the current AniList media when that media ID is known, then flags matching tokens with `isNameMatch` and highlights them in the overlay with a distinct color.
## Enabling the Feature ## Enabling the Feature
Character dictionary sync is disabled by default. To turn it on: Character dictionary sync is disabled by default. To turn it on:
1. Authenticate with AniList (see [AniList Integration](/anilist-integration#setup)). 1. Authenticate with AniList (see [AniList Integration](/anilist-integration#setup)).
2. Set `anilist.characterDictionary.enabled` to `true` in your config. 2. Set `subtitleStyle.nameMatchEnabled` to `true` in your config or enable **Name Match Enabled** in Settings.
3. Start watching — SubMiner will generate a snapshot for the current media and import the merged dictionary into Yomitan automatically. 3. Start watching — SubMiner will generate a snapshot for the current media and import the merged dictionary into Yomitan automatically.
```jsonc ```jsonc
@@ -29,9 +29,9 @@ Character dictionary sync is disabled by default. To turn it on:
"anilist": { "anilist": {
"enabled": true, "enabled": true,
"accessToken": "your-token", "accessToken": "your-token",
"characterDictionary": {
"enabled": true,
}, },
"subtitleStyle": {
"nameMatchEnabled": true,
}, },
} }
``` ```
@@ -89,23 +89,29 @@ Name matching runs inside Yomitan's scanning pipeline during subtitle tokenizati
1. Yomitan receives subtitle text and scans for dictionary matches. 1. Yomitan receives subtitle text and scans for dictionary matches.
2. Entries from "SubMiner Character Dictionary" are checked with exact primary-source matching — the token must match the entry's `originalText` with `isPrimary: true` and `matchType: 'exact'`. 2. Entries from "SubMiner Character Dictionary" are checked with exact primary-source matching — the token must match the entry's `originalText` with `isPrimary: true` and `matchType: 'exact'`.
3. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer. 3. When the current AniList media ID is known, entries whose embedded media ID belongs to a different title are ignored for name matching and inline portraits.
4. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`). 4. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer.
5. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`).
6. If `subtitleStyle.nameMatchImagesEnabled` is enabled, the renderer also injects a small circular AniList portrait from the cached snapshot image data.
Older snapshot schema versions are regenerated automatically. Current-version snapshots are normally reused, but when `subtitleStyle.nameMatchImagesEnabled` is enabled SubMiner also checks whether the cached snapshot contains usable character portrait data. If it does not, the snapshot is refreshed so the merged dictionary can include images.
Name matches are visually distinct from [N+1 targeting, frequency highlighting, and JLPT tags](/subtitle-annotations) so you can tell at a glance whether a highlighted word is a character name or a vocabulary target. Name matches are visually distinct from [N+1 targeting, frequency highlighting, and JLPT tags](/subtitle-annotations) so you can tell at a glance whether a highlighted word is a character name or a vocabulary target.
**Key settings:** **Key settings:**
| Option | Default | Description | | Option | Default | Description |
| -------------------------------- | --------- | ---------------------------------- | | -------------------------------------- | --------- | ----------------------------------------- |
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting | | `subtitleStyle.nameMatchEnabled` | `false` | Enable dictionary sync and highlighting |
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names | | `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
## Dictionary Entries ## Dictionary Entries
Each character entry in the Yomitan dictionary includes structured content: Each character entry in the Yomitan dictionary includes structured content:
- **Name** — native (Japanese) and romanized forms - **Name** — the matched Japanese name form
- **Known names** — generated non-honorific Japanese aliases for that character, excluding raw romanized/English aliases from lookup results
- **Role badge** — color-coded by role: main (score 100), supporting (90), side (80), background (70) - **Role badge** — color-coded by role: main (score 100), supporting (90), side (80), background (70)
- **Portrait** — character image from AniList, embedded in the ZIP - **Portrait** — character image from AniList, embedded in the ZIP
- **Description** — biography text from AniList (collapsible) - **Description** — biography text from AniList (collapsible)
@@ -130,7 +136,7 @@ The three collapsible sections can be configured to start open or closed:
## Auto-Sync Lifecycle ## Auto-Sync Lifecycle
When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes. When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes.
**Phases:** **Phases:**
@@ -169,10 +175,13 @@ This creates a standalone dictionary ZIP for the target media and saves it along
## Correcting AniList Matches ## Correcting AniList Matches
SubMiner uses `guessit` to infer the anime title from the active filename, then searches AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series. SubMiner uses `guessit` to infer the anime title from the active filename before searching AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series.
Use the in-app selector or CLI to pin the correct AniList media for the whole series: Use the in-app selector or CLI to pin the correct AniList media for the whole series:
- In-app: open the manager with `Ctrl/Cmd+D`, use the **Override** tab/button, edit the prefilled title if needed, then search and choose the correct result.
- CLI: `--dictionary-candidates` still lists matches for the current filename guess.
```bash ```bash
# List candidate AniList matches for a file # List candidate AniList matches for a file
subminer dictionary --candidates "/path/to/episode.mkv" subminer dictionary --candidates "/path/to/episode.mkv"
@@ -185,10 +194,20 @@ SubMiner.AppImage --dictionary-candidates --dictionary-target "/path/to/episode.
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary-target "/path/to/episode.mkv" SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary-target "/path/to/episode.mkv"
# Open the in-app selector from the running app # Open the in-app selector from the running app
subminer app --open-character-dictionary subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
``` ```
Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the filename guess. Later episodes with the same series key use the selected AniList ID automatically. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary. Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the episode's parent directory plus the filename guess. Later episodes in the same directory use the selected AniList ID automatically, while separate season directories can keep separate overrides and character dictionaries. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
## Managing Loaded Entries
Open the manager with `Ctrl/Cmd+D` (`shortcuts.openCharacterDictionaryManager`). The manager shows the merged dictionary's active MRU entries, marks the current anime, and lets you adjust eviction priority for the other loaded entries.
- **Remove** drops a non-current entry from the active merged dictionary and rebuilds/imports once.
- **Up/Down** changes MRU order for future eviction without rebuilding.
- **Override** opens the AniList selector for that entry's title so you can replace a saved loaded entry.
The current anime cannot be removed while you are watching it; it stays loaded until playback changes.
## File Structure ## File Structure
@@ -207,7 +226,7 @@ character-dictionaries/
m170942-va67890.jpg # Voice actor portrait m170942-va67890.jpg # Voice actor portrait
``` ```
**Snapshot format** (v15): each snapshot contains the media ID, title, entry count, timestamp, an array of Yomitan term entries, and base64-encoded images. **Snapshot format** (v17): each snapshot contains the media ID, title, entry count, timestamp, an array of Yomitan term entries, and base64-encoded images.
**ZIP structure** follows the Yomitan dictionary format: **ZIP structure** follows the Yomitan dictionary format:
@@ -224,13 +243,13 @@ merged.zip
| Option | Default | Description | | Option | Default | Description |
| ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------- | | ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------- |
| `anilist.characterDictionary.enabled` | `false` | Enable auto-sync of character dictionary from AniList |
| `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary | | `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary |
| `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only | | `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only |
| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded | | `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded | | `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded | | `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting in subtitles | | `subtitleStyle.nameMatchEnabled` | `false` | Enable character-dictionary sync and name highlighting |
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside matched names |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches | | `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
## Reference Implementation ## Reference Implementation
@@ -252,9 +271,10 @@ If you work with visual novels or want a standalone dictionary generator indepen
## Troubleshooting ## Troubleshooting
- **Names not highlighting:** Confirm `anilist.characterDictionary.enabled` is `true` and `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry — SubMiner needs a media ID to fetch characters. - **Names not highlighting:** Confirm `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry — SubMiner needs a media ID to fetch characters.
- **Inline portraits missing:** Confirm `subtitleStyle.nameMatchImagesEnabled` is `true`. On the next character dictionary sync, SubMiner refreshes current-version snapshots that do not contain usable cached character portrait data. Portraits still require AniList to return an image and the image download to succeed.
- **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase. - **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase.
- **Wrong characters showing:** Open the in-app character dictionary selector (`--open-character-dictionary`) or run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id <id>`. This replaces stale wrong-title entries for that series. If names are only from an older unrelated show, they'll rotate out once you watch enough new titles to push it past `maxLoaded`. - **Wrong characters showing:** Open the in-app character dictionary manager (`Ctrl/Cmd+D`) to remove/reorder loaded titles, then use **Override** to correct the active AniList match. You can also run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id <id>`. SubMiner ignores character entries from other loaded titles for subtitle name matching and inline portraits once the current media ID is known.
- **Yomitan import fails:** SubMiner waits up to 7 seconds for Yomitan to be ready for mutations. If Yomitan is still loading dictionaries or performing another import, the operation may time out. Restarting the overlay typically resolves this. - **Yomitan import fails:** SubMiner waits up to 7 seconds for Yomitan to be ready for mutations. If Yomitan is still loading dictionaries or performing another import, the operation may time out. Restarting the overlay typically resolves this.
- **Portraits missing:** Images are downloaded from AniList CDN during snapshot generation. If the network was unavailable during the initial sync, delete the snapshot file from `character-dictionaries/snapshots/` and let it regenerate. - **Portraits missing:** Images are downloaded from AniList CDN during snapshot generation. If the network was unavailable during the initial sync, delete the snapshot file from `character-dictionaries/snapshots/` and let it regenerate.
+150 -150
View File
@@ -21,7 +21,7 @@ For most users, start with this minimal configuration:
"deck": "YourDeckName", "deck": "YourDeckName",
"knownWords": { "knownWords": {
"decks": { "decks": {
"YourDeckName": ["Word", "Word Reading", "Expression"] "YourDeckName": ["Word"]
} }
}, },
"fields": { "fields": {
@@ -33,7 +33,7 @@ For most users, start with this minimal configuration:
} }
``` ```
`ankiConnect.deck` is still accepted for backward-compatible polling scope and legacy known-word fallback behavior. For known-word cache scope, prefer `ankiConnect.knownWords.decks` with deck-to-fields mapping. Use the known-word deck map to choose which Anki decks and note fields feed the known-word cache.
Then customize as needed using the sections below. Then customize as needed using the sections below.
@@ -54,7 +54,7 @@ The Settings window groups options by workflow instead of mirroring the raw conf
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names, and keybinding fields use click-to-learn controls instead of raw text boxes. Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names, and keybinding fields use click-to-learn controls instead of raw text boxes.
The Settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies. The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies.
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available. Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
@@ -94,35 +94,10 @@ On macOS, these validation warnings also open a native dialog with full details
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically. SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
Hot-reloadable fields: Hot-reloadable settings include subtitle appearance, sidebar controls, keybindings,
logging level, selected source-language preferences, Jimaku/Subsync settings, and
- `subtitleStyle` the Anki known-word, N+1, field, sentence-card, and Kiku options listed in the
- `subtitleSidebar` reference tables below.
- `keybindings`
- `shortcuts`
- `secondarySub.defaultMode`
- `stats.toggleKey`
- `stats.markWatchedKey`
- `logging.level`
- `youtube.primarySubLanguages`
- `jimaku.*`
- `subsync.*`
- `ankiConnect.ai.enabled`
- `ankiConnect.behavior.autoUpdateNewCards`
- `ankiConnect.knownWords.highlightEnabled`
- `ankiConnect.knownWords.refreshMinutes`
- `ankiConnect.knownWords.addMinedWordsImmediately`
- `ankiConnect.knownWords.matchMode`
- `ankiConnect.knownWords.decks`
- `ankiConnect.nPlusOne.enabled`
- `ankiConnect.nPlusOne.minSentenceWords`
- `ankiConnect.fields.word`
- `ankiConnect.fields.audio`
- `ankiConnect.fields.image`
- `ankiConnect.fields.sentence`
- `ankiConnect.fields.miscInfo`
- `ankiConnect.isLapis.sentenceCardModel`
- `ankiConnect.isKiku.fieldGrouping`
When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active. When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
@@ -175,7 +150,7 @@ The configuration file includes several main sections:
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` - [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**AniList**](#anilist) - Optional post-watch progress updates - [**AniList**](#anilist) - Optional post-watch progress updates
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath` - [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch - [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates - [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite - [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
@@ -193,14 +168,24 @@ Control the minimum log level for runtime output:
```json ```json
{ {
"logging": { "logging": {
"level": "info" "level": "warn",
"rotation": 7,
"files": {
"app": true,
"launcher": true,
"mpv": false
}
} }
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------- | ---------------------------------------- | --------------------------------------------------------- | | ---------------- | ---------------------------------------- | -------------------------------------------------------------------- |
| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) | | `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"warn"`) |
| `rotation` | positive integer | Number of days of app, launcher, and mpv logs to retain (default: 7) |
| `files.app` | boolean | Write SubMiner app runtime logs (default: `true`) |
| `files.launcher` | boolean | Write launcher command logs (default: `true`) |
| `files.mpv` | boolean | Write mpv player logs. Enable temporarily for mpv/plugin debugging. |
### Updates ### Updates
@@ -219,7 +204,7 @@ Configure automatic update checks and update notifications:
| Option | Values | Description | | Option | Values | Description |
| -------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------- | | -------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. | | `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. | | `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
| `notificationType` | `"system"` \| `"osd"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. | | `notificationType` | `"system"` \| `"osd"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. |
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. | | `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
@@ -288,9 +273,9 @@ See `config.example.jsonc` for detailed configuration options.
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------- | ------------------------- | --------------------------------------------------- | | ------------------- | ------------------------- | --------------------------------------------------- |
| `enabled` | `true`, `false`, `"auto"` | Built-in subtitle websocket mode (default: `false`) | | `websocket.enabled` | `true`, `false`, `"auto"` | Built-in subtitle websocket mode (default: `false`) |
| `port` | number | WebSocket server port (default: 6677) | | `websocket.port` | number | WebSocket server port (default: 6677) |
### Annotation WebSocket ### Annotation WebSocket
@@ -308,9 +293,9 @@ This stream includes subtitle text plus token metadata (N+1, known-word, frequen
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------- | --------------- | -------------------------------------------------------------- | | ----------------------------- | --------------- | -------------------------------------------------------------- |
| `enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) | | `annotationWebsocket.enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) |
| `port` | number | Annotation websocket port (default: 6678) | | `annotationWebsocket.port` | number | Annotation websocket port (default: 6678) |
### Texthooker ### Texthooker
@@ -343,10 +328,10 @@ See `config.example.jsonc` for detailed configuration options.
```json ```json
{ {
"subtitleStyle": { "subtitleStyle": {
"fontColor": "#cad3f5",
"backgroundColor": "transparent",
"css": { "css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", "font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
"color": "#cad3f5",
"background-color": "transparent",
"font-size": "35px", "font-size": "35px",
"font-weight": "600", "font-weight": "600",
"line-height": "1.35", "line-height": "1.35",
@@ -356,13 +341,15 @@ See `config.example.jsonc` for detailed configuration options.
"text-rendering": "geometricPrecision", "text-rendering": "geometricPrecision",
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", "text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
"font-style": "normal", "font-style": "normal",
"backdrop-filter": "blur(6px)" "backdrop-filter": "blur(6px)",
"--subtitle-hover-token-color": "#f4dbd6",
"--subtitle-hover-token-background-color": "transparent"
}, },
"secondary": { "secondary": {
"fontColor": "#cad3f5",
"backgroundColor": "transparent",
"css": { "css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", "font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
"color": "#cad3f5",
"background-color": "transparent",
"font-size": "24px", "font-size": "24px",
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)" "text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"
} }
@@ -372,21 +359,17 @@ See `config.example.jsonc` for detailed configuration options.
``` ```
| Option | Values | Description | | Option | Values | Description |
| ---------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) | | `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
| `fontSize` | number (px) | Font size in pixels (default: `35`) | | `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) | | `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
| `css` | object | CSS declarations applied to subtitles after normal style defaults; the settings window writes textbox edits here |
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) | | `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. | | `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). | | `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). | | `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) | | `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: `"transparent"`); `hoverBackground` is accepted as an alias | | `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`false` by default) | | `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) | | `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) | | `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) | | `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
@@ -398,32 +381,38 @@ See `config.example.jsonc` for detailed configuration options.
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode | | `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode | | `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) | | `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
| `secondary` | object | Override any of the above for secondary subtitles (optional), including `secondary.css` declarations |
Subtitle CSS custom properties:
| CSS Property | Default | Description |
| ----------------------------------------- | ------------- | --------------------------------------- |
| `--subtitle-hover-token-color` | `#f4dbd6` | Hovered subtitle token text color |
| `--subtitle-hover-token-background-color` | `transparent` | Hovered subtitle token background color |
The Settings window keeps subtitle color controls separate, then saves CSS textboxes to The Settings window keeps subtitle color controls separate, then saves CSS textboxes to
`subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`. The generated example the primary subtitle, secondary subtitle, and sidebar CSS objects. The generated example
uses that same CSS declaration shape; existing top-level style keys such as `fontSize` and uses that same CSS declaration shape.
`textShadow` remain supported for hand-written or older configs.
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`. Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
Lookup behavior: Lookup behavior:
- Set `frequencyDictionary.sourcePath` to a directory containing `term_meta_bank_*.json` for a fully custom source. - Point the source path at a directory containing `term_meta_bank_*.json` for a fully custom source.
- If `sourcePath` is missing or empty, SubMiner searches default install/runtime locations for `frequency-dictionary` directories (for example app resources, user data paths, and current working directory). - If `sourcePath` is missing or empty, SubMiner searches default install/runtime locations for `frequency-dictionary` directories (for example app resources, user data paths, and current working directory).
- In both cases, only terms with a valid `frequencyRank` are used; everything else falls back to no highlighting. - In both cases, only terms with a valid `frequencyRank` are used; everything else falls back to no highlighting.
- `frequencyDictionary.matchMode` controls which token text is used for frequency lookups: `headword` (dictionary form) or `surface` (visible subtitle text). - Match mode controls which token text is used for frequency lookups: `headword` (dictionary form) or `surface` (visible subtitle text).
- Frequency highlighting skips tokens that look like non-lexical SFX/interjection noise (for example kana reduplication or short kana endings like `っ`), even when dictionary ranks exist. - Frequency highlighting skips tokens that look like non-lexical SFX/interjection noise (for example kana reduplication or short kana endings like `っ`), even when dictionary ranks exist.
In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window. In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window.
Character-name highlighting is separate from N+1 and frequency highlighting: Character-name highlighting is separate from N+1 and frequency highlighting:
- `nameMatchEnabled` controls whether SubMiner includes character-dictionary name matches in subtitle token metadata and renderer styling. - `nameMatchEnabled` controls whether SubMiner syncs the character dictionary and includes character-dictionary name matches in subtitle token metadata and renderer styling.
- `nameMatchImagesEnabled` adds small circular portraits beside matched names using the AniList images already cached with character dictionary snapshots.
- `nameMatchColor` sets the highlight color for those matched character names. - `nameMatchColor` sets the highlight color for those matched character names.
- Matches come from the bundled SubMiner character dictionary, including AniList-synced merged dictionaries when enabled. - Matches come from the bundled SubMiner character dictionary, including AniList-synced merged dictionaries when name matching is enabled.
Secondary subtitle defaults: `fontFamily: "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`, `fontSize: 24`, `fontColor: "#cad3f5"`, `textShadow: "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"`, `backgroundColor: "transparent"`, `fontWeight: "600"`. Any property not set in `secondary` falls back to the CSS defaults. Secondary subtitle styling lives in the secondary subtitle CSS object. Any CSS property not set there falls back to the secondary subtitle defaults, then the normal renderer defaults.
**See `config.example.jsonc`** for the complete list of subtitle style configuration options. **See `config.example.jsonc`** for the complete list of subtitle style configuration options.
@@ -440,30 +429,36 @@ Configure the parsed-subtitle sidebar modal.
"toggleKey": "Backslash", "toggleKey": "Backslash",
"pauseVideoOnHover": true, "pauseVideoOnHover": true,
"autoScroll": true, "autoScroll": true,
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", "css": {
"fontSize": 16 "font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
"font-size": "16px",
"color": "#cad3f5",
"background-color": "rgba(73, 77, 100, 0.9)",
"--subtitle-sidebar-max-width": "420px"
}
} }
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------------------------- | --------- | ------------------------------------------------------------------------------------------------------- | | --------------------------- | ------- | ------------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | Enable subtitle sidebar support (`true` by default) | | `subtitleSidebar.enabled` | boolean | Enable subtitle sidebar support (`true` by default) |
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) | | `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout | | `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) | | `subtitleSidebar.toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list (`true` by default) | | `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list (`true` by default) |
| `autoScroll` | boolean | Keep the active cue in view while playback advances | | `autoScroll` | boolean | Keep the active cue in view while playback advances |
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) | | `subtitleSidebar.css` | object | CSS declaration object applied to the sidebar. Use CSS properties plus sidebar custom properties below. |
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
| `backgroundColor` | string | Sidebar shell background color | Sidebar CSS custom properties:
| `textColor` | hex color | Default cue text color |
| `fontFamily` | string | CSS `font-family` value applied to sidebar cue text | | CSS Property | Default | Description |
| `fontSize` | number | Base sidebar cue font size in CSS pixels (default: `16`) | | -------------------------------------------- | --------------------------- | ---------------------------- |
| `timestampColor` | hex color | Cue timestamp color | | `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width |
| `activeLineColor` | hex color | Active cue text color | | `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color |
| `activeLineBackgroundColor` | string | Active cue background color | | `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color |
| `hoverLineBackgroundColor` | string | Hovered cue background color | | `--subtitle-sidebar-active-background-color` | `rgba(138, 173, 244, 0.22)` | Active cue background color |
| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog. The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog.
@@ -527,7 +522,7 @@ See `config.example.jsonc` for detailed configuration options.
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track | | `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) | | `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
`secondarySub.secondarySubLanguages` also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback. The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
**Display modes:** **Display modes:**
@@ -616,7 +611,7 @@ See `config.example.jsonc` for detailed configuration options.
"mineSentence": "CommandOrControl+S", "mineSentence": "CommandOrControl+S",
"mineSentenceMultiple": "CommandOrControl+Shift+S", "mineSentenceMultiple": "CommandOrControl+Shift+S",
"markAudioCard": "CommandOrControl+Shift+A", "markAudioCard": "CommandOrControl+Shift+A",
"openCharacterDictionary": "CommandOrControl+Alt+A", "openCharacterDictionaryManager": "CommandOrControl+D",
"openRuntimeOptions": "CommandOrControl+Shift+O", "openRuntimeOptions": "CommandOrControl+Shift+O",
"openSessionHelp": "CommandOrControl+Slash", "openSessionHelp": "CommandOrControl+Slash",
"openControllerSelect": "Alt+C", "openControllerSelect": "Alt+C",
@@ -629,19 +624,19 @@ See `config.example.jsonc` for detailed configuration options.
``` ```
| Option | Values | Description | | Option | Values | Description |
| ----------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) | | `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) | | `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | | `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) | | `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when `behavior.autoUpdateNewCards` is `false`) | | `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) | | `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) | | `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) | | `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) | | `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | | `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | | `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
| `openCharacterDictionary` | string \| `null` | Opens the character dictionary AniList selector (default: `"CommandOrControl+Alt+A"`) | | `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) |
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | | `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) | | `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) |
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | | `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
@@ -672,7 +667,7 @@ Important behavior:
- Learned bindings are saved under `controller.profiles` for the selected controller id. Global `controller.bindings` remains the fallback for controllers without a profile. - Learned bindings are saved under `controller.profiles` for the selected controller id. Global `controller.bindings` remains the fallback for controllers without a profile.
- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`. - `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block. - The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`. - The button-index map is a semantic reference mapping. Changing it does not rewrite the raw numeric descriptor values already stored under controller bindings.
- Turning keyboard-only mode off clears the keyboard-only token highlight state. - Turning keyboard-only mode off clears the keyboard-only token highlight state.
- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active. - Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active.
@@ -761,11 +756,11 @@ If you bind a discrete action to an axis manually, include `direction`:
} }
``` ```
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings` or `controller.profiles.*.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct. Treat the button-index map as reference-only unless you are copying values from the debug modal. Updating it alone does not rewrite the hardcoded raw numeric values already present in controller bindings or controller profiles. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default. If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
If one controller reports non-standard raw button numbers, override `controller.profiles["<controller id>"].buttonIndices` using values from the `Alt+Shift+C` debug modal. Use global `controller.buttonIndices` only when the mapping should apply to every controller without a profile. If one controller reports non-standard raw button numbers, override that controller profile's button-index map using values from the `Alt+Shift+C` debug modal. Use the global button-index map only when the mapping should apply to every controller without a profile.
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging. If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
@@ -773,19 +768,19 @@ Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing,
### Manual Card Update Shortcuts ### Manual Card Update Shortcuts
When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control: When automatic card updates are disabled, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
| Shortcut | Action | | Shortcut | Action |
| -------------- | ------------------------------------------------------------------------------------------------------------------ | | -------------- | ------------------------------------------------------------------------------------------------------------- |
| `Ctrl+C` | Copy the current subtitle line to clipboard (preserves line breaks) | | `Ctrl+C` | Copy the current subtitle line to clipboard (preserves line breaks) |
| `Ctrl+Shift+C` | Enter multi-copy mode. Press `1-9` to copy that many recent lines, or `Esc` to cancel. Timeout: 3 seconds | | `Ctrl+Shift+C` | Enter multi-copy mode. Press `1-9` to copy that many recent lines, or `Esc` to cancel. Timeout: 3 seconds |
| `Ctrl+V` | Update the last added Anki card using subtitles from clipboard | | `Ctrl+V` | Update the last added Anki card using subtitles from clipboard |
| `Ctrl+G` | Trigger Kiku duplicate field grouping for the last added card (only when `behavior.autoUpdateNewCards` is `false`) | | `Ctrl+G` | Trigger Kiku duplicate field grouping for the last added card (only when automatic card updates are disabled) |
| `Ctrl+S` | Create a sentence card from the current subtitle line | | `Ctrl+S` | Create a sentence card from the current subtitle line |
| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel | | `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel |
| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) | | `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) |
| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) | | `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
| `Ctrl+Alt+A` | Open character dictionary AniList selector | | `Ctrl+D` | Open loaded character dictionary manager |
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) | | `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) | | `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
@@ -826,16 +821,11 @@ When config hot-reload updates shortcut/keybinding/style values, close and reope
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart. Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
Current runtime options: Current runtime options cover automatic card updates, known-word highlighting,
JLPT underlines, frequency highlighting, known-word match mode, and Kiku field
grouping mode.
- `ankiConnect.behavior.autoUpdateNewCards` (`On` / `Off`) Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
- `subtitleStyle.enableJlpt` (`On` / `Off`)
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
- `ankiConnect.knownWords.matchMode` (`headword` / `surface`)
- `ankiConnect.isKiku.fieldGrouping` (`auto` / `manual` / `disabled`)
Annotation toggles (`nPlusOne`, `enableJlpt`, `frequencyDictionary.enabled`) only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
Default shortcut: `Ctrl+Shift+O` Default shortcut: `Ctrl+Shift+O`
@@ -866,8 +856,8 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------ | -------------------- | ---------------------------------------------------------------------------------- | | ------------------ | -------------------- | ------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) | | `ai.enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) |
| `apiKey` | string | Static API key for the shared provider | | `apiKey` | string | Static API key for the shared provider |
| `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) | | `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) |
| `model` | string | Default model identifier requested from the provider (default: `openai/gpt-4o-mini`) | | `model` | string | Default model identifier requested from the provider (default: `openai/gpt-4o-mini`) |
@@ -877,7 +867,7 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf
SubMiner uses the shared provider for: SubMiner uses the shared provider for:
- Anki translation/enrichment when `ankiConnect.ai.enabled` is `true` - Anki translation/enrichment when Anki AI is enabled
### AnkiConnect ### AnkiConnect
@@ -952,8 +942,8 @@ This example is intentionally compact. The option table below documents availabl
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation. **Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description | | Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | | `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | | `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | | `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) | | `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
@@ -961,8 +951,7 @@ This example is intentionally compact. The option table below documents availabl
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) | | `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | | `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | | `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. | | `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | | `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | | `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) | | `fields.image` | string | Card field for images (default: `Picture`) |
@@ -987,15 +976,15 @@ This example is intentionally compact. The option table below documents availabl
| `media.audioPadding` | number (seconds) | Optional padding around audio clip timing (default: `0`). Animated AVIF clips freeze the first frame during leading audio padding. | | `media.audioPadding` | number (seconds) | Optional padding around audio clip timing (default: `0`). Animated AVIF clips freeze the first frame during leading audio padding. |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | | `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | | `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode`; manual clipboard updates always replace generated sentence audio (default: `true`) | | `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) | | `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | | `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | | `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) | | `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) | | `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. | | `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) | | `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). | | `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). | | `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). | | `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | | `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
@@ -1032,21 +1021,21 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
### N+1 Word Highlighting ### N+1 Word Highlighting
When `ankiConnect.knownWords.highlightEnabled` is enabled, SubMiner builds a local cache of known words from Anki to highlight already learned tokens in subtitle rendering. When known-word highlighting is enabled, SubMiner builds a local cache of known words from Anki to highlight already learned tokens in subtitle rendering.
Known-word cache policy: Known-word cache policy:
- Initial sync runs when the integration starts if the cache is missing or stale. - Initial sync runs when the integration starts if the cache is missing or stale.
- `ankiConnect.knownWords.refreshMinutes` controls the minimum time between refreshes; between refreshes, cached words are reused without querying Anki. - The refresh interval controls the minimum time between syncs; between refreshes, cached words are reused without querying Anki.
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists. - `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
- `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`). - The N+1 minimum sentence-word setting controls the token count required before N+1 highlighting can trigger.
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki. - `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
- `ankiConnect.knownWords.decks` accepts an object keyed by deck name. If omitted or empty, it falls back to the legacy `ankiConnect.deck` single-deck scope. - The known-word deck map accepts an object keyed by deck name.
- Prefer expression/word fields such as `Expression` or `Word`. Avoid reading-only fields unless you intentionally want homophone readings to count as known words.
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory. - Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes). - The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
- Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.knownWords.matchMode` to `"surface"` for raw subtitle text matching. - Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set known-word matching to `"surface"` for raw subtitle text matching.
- Legacy moved keys under `ankiConnect.nPlusOne` (`highlightEnabled`, `refreshMinutes`, `matchMode`, `decks`, `knownWord`) and older `ankiConnect.behavior.nPlusOne*` keys are deprecated and only kept for backward compatibility. - A known-word cache match always receives known-word highlighting, even when part-of-speech filters suppress N+1, frequency, or JLPT annotations for that token.
- Legacy top-level `ankiConnect` migration keys (for example `audioField`, `generateAudio`, `imageType`) are compatibility-only, validated before mapping, and ignored with a warning when invalid.
- If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown. - If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown.
- Known-word sync activity is logged at `INFO`/`DEBUG` level with the `anki` logger scope and includes scope, notes returned, and word counts. - Known-word sync activity is logged at `INFO`/`DEBUG` level with the `anki` logger scope and includes scope, notes returned, and word counts.
@@ -1125,7 +1114,7 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy
``` ```
| Option | Values | Description | | Option | Values | Description |
| ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- | | ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. | | `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. | | `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. | | `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
@@ -1145,9 +1134,7 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
"accessToken": "", "accessToken": "",
"characterDictionary": { "characterDictionary": {
"enabled": false, "enabled": false,
"refreshTtlHours": 168,
"maxLoaded": 3, "maxLoaded": 3,
"evictionPolicy": "delete",
"profileScope": "all", "profileScope": "all",
"collapsibleSections": { "collapsibleSections": {
"description": false, "description": false,
@@ -1160,13 +1147,10 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
``` ```
| Option | Values | Description | | Option | Values | Description |
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- | | -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | | `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) | | `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
| `characterDictionary.enabled` | `true`, `false` | Enable automatic import/update of the merged SubMiner character dictionary for recent AniList media |
| `characterDictionary.refreshTtlHours` | number | Legacy compatibility setting. Parsed and preserved, but merged dictionary retention is now usage-based |
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) | | `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | Legacy compatibility setting. Parsed and preserved, but merged dictionary eviction is now usage-based |
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries | | `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries | | `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries | | `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
@@ -1193,7 +1177,7 @@ Current post-watch behavior:
Setup flow details: Setup flow details:
1. Set `anilist.enabled` to `true`. 1. Set `anilist.enabled` to `true`.
2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup. 2. Leave the AniList access-token field empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
3. Approve access in AniList. 3. Approve access in AniList.
4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically. 4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically.
- Encryption backend: Linux defaults to `gnome-libsecret`. - Encryption backend: Linux defaults to `gnome-libsecret`.
@@ -1201,7 +1185,7 @@ Setup flow details:
Token + detection notes: Token + detection notes:
- `anilist.accessToken` can be set directly in config; when blank, SubMiner uses the locally stored encrypted token from setup. - The AniList access token can be set directly in config; when blank, SubMiner uses the locally stored encrypted token from setup.
- Detection quality is best when `guessit` is installed and available on `PATH`. - Detection quality is best when `guessit` is installed and available on `PATH`.
- When `guessit` cannot parse or is missing, SubMiner falls back automatically to internal filename parsing. - When `guessit` cannot parse or is missing, SubMiner falls back automatically to internal filename parsing.
@@ -1237,7 +1221,7 @@ External-profile mode behavior:
- SubMiner does not open its own Yomitan settings window in this mode. - SubMiner does not open its own Yomitan settings window in this mode.
- SubMiner does not import, delete, or update dictionaries/settings in the external profile. - SubMiner does not import, delete, or update dictionaries/settings in the external profile.
- SubMiner character-dictionary features are fully disabled in this mode, including auto-sync, manual generation, and subtitle-side character-dictionary annotations. - SubMiner character-dictionary features are fully disabled in this mode, including auto-sync, manual generation, and subtitle-side character-dictionary annotations.
- First-run setup does not require any internal dictionaries while this mode is configured. If you later launch without `yomitan.externalProfilePath`, setup will require at least one internal Yomitan dictionary unless SubMiner already finds one. - First-run setup does not require any internal dictionaries while this mode is configured. If you later launch without an external Yomitan profile, setup will require at least one internal Yomitan dictionary unless SubMiner already finds one.
### Jellyfin ### Jellyfin
@@ -1253,7 +1237,6 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
"autoAnnounce": false, "autoAnnounce": false,
"remoteControlDeviceName": "SubMiner",
"defaultLibraryId": "", "defaultLibraryId": "",
"directPlayPreferred": true, "directPlayPreferred": true,
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
@@ -1263,26 +1246,22 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
``` ```
| Option | Values | Description | | Option | Values | Description |
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ | | -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) | | `jellyfin.enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) |
| `serverUrl` | string (URL) | Jellyfin server base URL | | `serverUrl` | string (URL) | Jellyfin server base URL |
| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 | | `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 |
| `username` | string | Default username used by `--jellyfin-login` | | `username` | string | Default username used by `--jellyfin-login` |
| `deviceId` | string | Client device id sent in auth headers (default: `subminer`) |
| `clientName` | string | Client name sent in auth headers (default: `SubMiner`) |
| `clientVersion` | string | Client version sent in auth headers (default: `0.1.0`) |
| `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted | | `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted |
| `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support | | `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support |
| `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires `jellyfin.enabled` and `remoteControlEnabled`) | | `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires Jellyfin integration and remote control) |
| `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) | | `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) |
| `remoteControlDeviceName` | string | Device name shown in Jellyfin cast/device lists |
| `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers | | `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers |
| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons | | `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons |
| `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding | | `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding |
| `directPlayContainers` | string[] | Container allowlist for direct play decisions | | `directPlayContainers` | string[] | Container allowlist for direct play decisions |
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) | | `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime. The Settings window also hides low-level client identity and default library fields (`deviceId`, `clientName`, `clientVersion`, and `defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior. Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. SubMiner reports the Jellyfin client as `SubMiner`, derives the Jellyfin device id and visible device name from the OS hostname, and owns the client version internally. The Settings window also hides low-level default library fields (`defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed. - On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
@@ -1297,7 +1276,9 @@ Launcher subcommands:
See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide. See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide.
Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`. Jellyfin remote auto-connect runs only when Jellyfin integration, remote control, and remote auto-connect are all enabled.
Jellyfin playback auto-launched through SubMiner loads the mpv plugin the same way regular playback does, and shows the visible subtitle overlay automatically so `subtitleStyle` applies to subtitles selected from Jellyfin.
When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session. When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session.
@@ -1317,8 +1298,8 @@ Discord Rich Presence is enabled by default. SubMiner publishes a polished activ
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------ | ------------------------------------------------ | ---------------------------------------------------------- | | ------------------------- | ------------------------------------------------ | ---------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) | | `discordPresence.enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) |
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) | | `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | | `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | | `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
@@ -1383,7 +1364,7 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
| Option | Values | Description | | Option | Values | Description |
| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | | ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. | | `immersionTracking.enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. |
| `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `<config dir>/immersion.sqlite`. | | `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `<config dir>/immersion.sqlite`. |
| `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. | | `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. |
| `flushIntervalMs` | integer (`50`-`60000`) | Maximum queue delay before flush. Default `500ms`. | | `flushIntervalMs` | integer (`50`-`60000`) | Maximum queue delay before flush. Default `500ms`. |
@@ -1398,6 +1379,9 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
| `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). | | `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). |
| `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). | | `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). |
| `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). | | `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). |
| `lifetimeSummaries.global` | `true`, `false` | Maintain global lifetime stats rows (default: `true`). |
| `lifetimeSummaries.anime` | `true`, `false` | Maintain per-anime lifetime stats rows (default: `true`). |
| `lifetimeSummaries.media` | `true`, `false` | Maintain per-media lifetime stats rows (default: `true`). |
You can also disable immersion tracking for a single session using: You can also disable immersion tracking for a single session using:
@@ -1427,6 +1411,7 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
{ {
"stats": { "stats": {
"toggleKey": "Backquote", "toggleKey": "Backquote",
"markWatchedKey": "KeyW",
"serverPort": 6969, "serverPort": 6969,
"autoStartServer": true, "autoStartServer": true,
"autoOpenBrowser": false "autoOpenBrowser": false
@@ -1436,7 +1421,8 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
| Option | Values | Description | | Option | Values | Description |
| ----------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------- | | ----------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------- |
| `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. | | `stats.toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
| `markWatchedKey` | Electron key code | Key code to mark the current video as watched and advance to the next playlist entry. Default `KeyW`. |
| `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. | | `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. |
| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. | | `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. |
| `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `false`. | | `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `false`. |
@@ -1456,17 +1442,31 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
{ {
"mpv": { "mpv": {
"executablePath": "", "executablePath": "",
"launchMode": "normal",
"profile": "", "profile": "",
"launchMode": "normal" "socketPath": "\\\\.\\pipe\\subminer-socket",
"backend": "auto",
"autoStartSubMiner": true,
"pauseUntilOverlayReady": true,
"subminerBinaryPath": "",
"aniskipEnabled": true,
"aniskipButtonKey": "TAB"
} }
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ---------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ----------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) | | `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) | | `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) | | `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) |
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
| `pauseUntilOverlayReady`| `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) |
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection and skip markers in the bundled mpv plugin (default: `true`) |
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) |
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list. If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
@@ -1498,14 +1498,14 @@ Current launcher behavior:
- If YouTube/mpv already exposes an authoritative matching subtitle track, SubMiner reuses it; otherwise it downloads and injects only the missing side. - If YouTube/mpv already exposes an authoritative matching subtitle track, SubMiner reuses it; otherwise it downloads and injects only the missing side.
- SubMiner loads the primary subtitle plus a best-effort secondary subtitle. - SubMiner loads the primary subtitle plus a best-effort secondary subtitle.
- Playback waits only for primary subtitle readiness; secondary failures do not block playback. - Playback waits only for primary subtitle readiness; secondary failures do not block playback.
- English secondary subtitles are selected from `secondarySub.secondarySubLanguages` when primary language matches are unavailable. - English secondary subtitles are selected from the secondary-subtitle language list when primary language matches are unavailable.
- Native mpv secondary subtitle rendering stays hidden during this flow so the SubMiner overlay remains the visible secondary subtitle surface. - Native mpv secondary subtitle rendering stays hidden during this flow so the SubMiner overlay remains the visible secondary subtitle surface.
- If primary subtitle loading fails, use `Ctrl+Alt+C` to open the subtitle modal and pick a track. - If primary subtitle loading fails, use `Ctrl+Alt+C` to open the subtitle modal and pick a track.
Language targets are derived from subtitle config: Language targets are derived from subtitle config:
- primary track: `youtube.primarySubLanguages` (falls back to `["ja","jpn"]`) - primary track: `youtube.primarySubLanguages` (falls back to `["ja","jpn"]`)
- secondary track: `secondarySub.secondarySubLanguages` (falls back to English when empty) - secondary track: secondary-subtitle language list (falls back to English when empty)
- Local playback uses the same priorities after mpv reports subtitle track metadata, so sidecar/internal mixed sets can override an incorrect initial `sid=auto` pick. - Local playback uses the same priorities after mpv reports subtitle track metadata, so sidecar/internal mixed sets can override an incorrect initial `sid=auto` pick.
- Tracks are resolved and loaded before mpv starts; the older launcher mode switch has been removed. - Tracks are resolved and loaded before mpv starts; the older launcher mode switch has been removed.
+1 -1
View File
@@ -173,7 +173,7 @@ If you prefer to install it manually, see [manual launcher install](#manual-laun
Download the latest installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest): Download the latest installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
- `SubMiner-<version>.exe` — installer (recommended) - `SubMiner-<version>.exe` — installer (recommended)
- `SubMiner-<version>.zip` — portable fallback - `SubMiner-<version>-win.zip` — portable fallback
Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config during first-run setup. Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config during first-run setup.
+31
View File
@@ -36,6 +36,37 @@ flowchart TB
style E fill:#ed8796,stroke:#494d64,color:#24273a,stroke-width:1.5px style E fill:#ed8796,stroke:#494d64,color:#24273a,stroke-width:1.5px
``` ```
## Runtime Sockets
The renderer↔main bridge above lives *inside* the Electron app. A separate set of OS sockets connects the app to the other runtimes — mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes.
- **mpv IPC socket** (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows): the `MpvIpcClient` in the main process connects here to send JSON commands and subscribe to playback/subtitle properties via `observe_property`. Created by mpv's `--input-ipc-server`.
- **App control socket** (`/tmp/subminer-control-<uid>-<hash>.sock`, or a named pipe on Windows): the launcher and the mpv plugin send CLI-style commands (`--start`, `--show-visible-overlay`, `--texthooker`) to a running app here. It also dedupes a second `subminer` invocation into the existing instance instead of launching twice.
```mermaid
flowchart LR
classDef extrt fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef app fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef ext fill:#a6da95,stroke:#494d64,color:#24273a,stroke-width:1.5px
subgraph MpvProc["mpv process"]
direction TB
Mpv["mpv core"]:::ext
Plugin["SubMiner plugin (Lua)"]:::extrt
end
Launcher["Launcher CLI"]:::extrt
App["SubMiner app (Electron main)"]:::app
App <-->|"mpv IPC socket · /tmp/subminer-socket<br/>JSON commands + property observe"| Mpv
Launcher -->|"app control socket · /tmp/subminer-control-*<br/>--start, --show-visible-overlay, …"| App
Plugin -->|"app control socket<br/>spawn / attach"| App
style MpvProc fill:#363a4f,stroke:#494d64,color:#cad3f5
```
How these sockets are established during launch is covered in [Playback Startup Flow](./architecture#playback-startup-flow).
## Core Surfaces ## Core Surfaces
| File | Role | | File | Role |
+90 -163
View File
@@ -1,191 +1,118 @@
# Jellyfin Integration # Jellyfin Integration
[Jellyfin](https://jellyfin.org) is a free, self-hosted media server — think of it as your own private streaming service for video you own. If you keep your anime on a Jellyfin server, SubMiner can log in, browse it, and play episodes through mpv with the full mining overlay. [Jellyfin](https://jellyfin.org) is a free, self-hosted media server — think of it as your own private streaming service for video you own. If you keep your anime on a Jellyfin server, SubMiner can play episodes through mpv with the full mining overlay.
::: tip Who needs this? ::: tip Who needs this?
This page is only relevant if you already run (or have access to) a Jellyfin server. If you watch local files or YouTube, you can skip it. Most of this integration is driven from the command line, so it is aimed at slightly more advanced users; the in-app setup window (`subminer jellyfin`) is the easiest starting point. This page is only relevant if you already run (or have access to) a Jellyfin server. If you watch local files or YouTube, you can skip it. The in-app setup window (`subminer jellyfin`) is the easiest starting point.
::: :::
SubMiner includes an optional Jellyfin CLI integration for: SubMiner can act as a **cast-to-device target** for Jellyfin (similar to jellyfin-mpv-shim). Sign in once, turn on discovery, and SubMiner shows up in the "Play on…" / cast menu of any Jellyfin app — web, phone, or TV. Pick an episode, cast it to SubMiner, and it plays in SubMiner's mpv window with the full overlay and Yomitan click-to-lookup.
- authenticating against a server This is the recommended way to use Jellyfin with SubMiner. A terminal-only option is covered in [Launcher playback](#launcher-playback) at the end.
- listing libraries and media items
- launching item playback in the connected mpv instance
- receiving Jellyfin remote cast-to-device playback events in-app
- opening an in-app setup window for server URL and authentication
- toggling Jellyfin cast discovery from the tray once configured
## Requirements ## Requirements
- Jellyfin server URL and user credentials - A Jellyfin server plus your username and password
- For `--jellyfin-play`: connected mpv IPC socket (`--start` or existing mpv plugin workflow) - SubMiner installed and running (see [Installation](/installation))
- On Linux, token encryption defaults to `gnome-libsecret`; pass `--password-store=<backend>` to override. - On Linux, the session token is stored with `gnome-libsecret` by default
## Setup ## Quick start
1. Set base config values (`config.jsonc`): ### 1. Start SubMiner
Launch SubMiner so it's running in the system tray.
### 2. Sign in to your server
Open the tray menu and click **Configure Jellyfin**. In the window that opens, enter your **Server URL** (for example `http://127.0.0.1:8096`), **Username**, and **Password**, then click **Login**.
On success, SubMiner:
- saves an encrypted session token — your password is never stored,
- turns the Jellyfin integration on, and
- remembers the server and username for next time.
Reopen this window any time to switch servers or **Logout**.
### 3. Turn on discovery
Discovery is what makes SubMiner appear as a cast target. Two ways to enable it:
- **For the current session** — open the tray menu and tick **Jellyfin Discovery**. (This item appears once you've signed in.)
- **Automatically on every launch** — already on by default. After your first sign-in, SubMiner auto-connects to Jellyfin at startup, so the cast target is ready without touching the tray. You can change this under [Settings](#settings).
### 4. Cast from any Jellyfin app
In the Jellyfin web UI or mobile app, start playing something, open the **cast / "Play on"** menu, and pick your device — SubMiner appears there named after your computer's hostname. Playback opens in SubMiner.
From then on, pause / resume / seek / stop and audio or subtitle track changes you make in the Jellyfin app are mirrored in SubMiner, and your watch progress syncs back to Jellyfin (now-playing and resume position).
## What happens during playback
- **mpv launches automatically.** If mpv isn't already running when you cast, SubMiner starts it with SubMiner defaults and the bundled mpv plugin, so keybindings work right away.
- **The overlay is managed by SubMiner,** so your configured `subtitleStyle` controls how subtitles look. Use the [overlay-toggle shortcut](/shortcuts) to hide it for a session.
- **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load.
- **Direct play first.** When the source allows it and the container is in your direct-play allowlist, SubMiner streams the original file; otherwise it requests a transcoded stream from Jellyfin.
- **Japanese subtitles are auto-selected,** preferring Jellyfin's default and embedded tracks over external sidecar files when several match.
- **Subtitle timing is corrected when possible.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages downloaded subtitle tracks without letting mpv auto-switch between tracks, then selects the Japanese track once after applying any saved or inferred timing delay. When Jellyfin provides both Japanese and English subtitle files, SubMiner compares their cue timelines and applies a global delay if one track is clearly offset. Manual delay shifts you make with SubMiner's adjacent-cue controls are saved per item and subtitle track, then restored the next time you select that track.
## Settings
All Jellyfin options live under **Settings → Integrations → Jellyfin** (open settings from the tray's **Open SubMiner Settings**). The ones that matter for casting:
| Setting | Default | What it does |
| ------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------- |
| **Enabled** | Off | Turns the Jellyfin integration on. Switched on for you when you sign in. |
| **Server Url** | — | Your Jellyfin server. Filled in when you sign in. |
| **Remote Control Enabled** | On | Lets SubMiner act as a cast target. |
| **Remote Control Auto Connect** | On | Connects to Jellyfin at startup so discovery is automatic. Turn off if you'd rather start it from the tray each time. |
| **Auto Announce** | Off | Re-broadcasts visibility on connect. Enable if your device is slow to appear in the cast menu. |
Prefer editing the config file? The same keys live under `jellyfin` in `config.jsonc`:
```jsonc ```jsonc
{ {
"jellyfin": { "jellyfin": {
"enabled": true, "enabled": true,
"serverUrl": "http://127.0.0.1:8096", "serverUrl": "http://127.0.0.1:8096",
"recentServers": ["http://127.0.0.1:8096"],
"username": "your-user",
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
"autoAnnounce": false,
"remoteControlDeviceName": "SubMiner",
"defaultLibraryId": "",
"pullPictures": false,
"iconCacheDir": "/tmp/subminer-jellyfin-icons",
"directPlayPreferred": true,
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
"transcodeVideoCodec": "h264",
}, },
} }
``` ```
2. Authenticate: See [Configuration](/configuration) for the full list (transcode codec, direct-play containers, default library, and more).
## Troubleshooting
**SubMiner doesn't appear in the cast menu**
- Make sure SubMiner is running.
- Make sure you're signed in — reopen **Configure Jellyfin** and log in again if your token expired.
- Make sure discovery is on (tray **Jellyfin Discovery**, or **Remote Control Auto Connect** in settings).
- Make sure SubMiner and the Jellyfin client point at the same server.
**Casting starts but nothing plays**
- Confirm the item plays normally in another Jellyfin client.
- If mpv was closed, give it a moment — SubMiner launches it on demand and retries.
**SubMiner keeps disconnecting**
- Check server/network stability and whether the session token has expired.
## Security notes
- The Jellyfin session (access token + user ID) is kept in SubMiner's local encrypted token storage. Your password is used only to log in and is never saved.
- Treat the token storage and your `config.jsonc` as secrets — don't commit them.
- Advanced/headless: the `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID` environment variables can supply a session without the sign-in window.
## Launcher playback
If you'd rather stay in the terminal, the `subminer` launcher can browse and play Jellyfin media directly, without casting from a Jellyfin app:
```bash ```bash
subminer jellyfin subminer jellyfin -p # alias: subminer jf -p
subminer jellyfin -l \
--server http://127.0.0.1:8096 \
--username your-user \
--password 'your-password'
``` ```
`subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored. This opens an fzf picker (add `-R` for rofi) to browse your libraries and episodes, then plays the selected item in SubMiner's mpv with the same overlay, resume, and subtitle behavior described above. Sign in first (step 2) so the launcher can reach your server. See [Launcher Script](/launcher-script) for the rest of the launcher's features.
3. List libraries:
```bash
SubMiner.AppImage --jellyfin-libraries
```
Launcher wrapper equivalent for interactive playback flow:
```bash
subminer jellyfin -p
```
Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
```bash
subminer jellyfin -d
```
After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart.
Stop discovery session/app:
```bash
subminer app --stop
```
`subminer jf ...` is an alias for `subminer jellyfin ...`.
To clear saved session credentials:
```bash
subminer jellyfin --logout
```
4. List items in a library:
```bash
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search term
```
Optional listing controls:
- `--jellyfin-recursive=true|false` (default: true)
- `--jellyfin-include-item-types=Series,Season,Folder,CollectionFolder,Movie,...`
These are used by the launcher picker flow to:
- keep root search focused on shows/folders/movies (exclude episode rows)
- browse selected anime/show directories as folder-or-file lists
- recurse for playable files only after selecting a folder
5. Start playback:
```bash
SubMiner.AppImage --start
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID
```
Optional stream overrides:
- `--jellyfin-audio-stream-index N`
- `--jellyfin-subtitle-stream-index N`
## Playback Behavior
- Direct play is attempted first when:
- `jellyfin.directPlayPreferred=true`
- media source supports direct stream
- source container matches `jellyfin.directPlayContainers`
- If direct play is not selected/available, SubMiner requests a Jellyfin transcoded stream (`master.m3u8`) using `jellyfin.transcodeVideoCodec`.
- Resume position (`PlaybackPositionTicks`) is applied via mpv seek.
- Media title is set in mpv as `[Jellyfin/<mode>] <title>`.
## Cast To Device Mode (jellyfin-mpv-shim style)
When SubMiner is running with a valid Jellyfin session, it can appear as a
remote playback target in Jellyfin's cast-to-device menu.
### Requirements
- `jellyfin.enabled=true`
- valid `jellyfin.serverUrl` and Jellyfin auth session (env override or stored login session)
- `jellyfin.remoteControlEnabled=true` (default)
- `jellyfin.remoteControlAutoConnect=true` (default) for startup auto-connect
- `jellyfin.autoAnnounce=false` by default (`true` enables auto announce/visibility check logs on connect)
### Behavior
- SubMiner connects to Jellyfin remote websocket and posts playback capabilities.
- Startup auto-connect still requires `remoteControlAutoConnect=true`; the tray `Jellyfin Discovery` checkbox can start discovery later even when startup auto-connect is disabled.
- `Play` events open media in mpv with the same defaults used by `--jellyfin-play`.
- If mpv IPC is not connected at cast time, SubMiner auto-launches mpv in idle mode with SubMiner defaults and retries playback.
- `Playstate` events map to mpv pause/resume/seek/stop controls.
- Stream selection commands (`SetAudioStreamIndex`, `SetSubtitleStreamIndex`) are mapped to mpv track selection.
- SubMiner reports start/progress/stop timeline updates back to Jellyfin so now-playing and resume state stay synchronized.
- `--jellyfin-remote-announce` forces an immediate capability re-broadcast and logs whether server sessions can see the device.
### Troubleshooting
- Device not visible in Jellyfin cast menu:
- ensure SubMiner is running
- ensure session token is valid (`--jellyfin-login` again if needed)
- ensure `remoteControlEnabled` is true
- use tray `Jellyfin Discovery` or `subminer jellyfin -d` to start discovery
- Cast command received but playback does not start:
- verify mpv IPC can connect (`--start` flow)
- verify item is playable from normal `--jellyfin-play --jellyfin-item-id ...`
- Frequent reconnects:
- check Jellyfin server/network stability and token expiration
## Failure Handling
User-visible errors are shown through CLI logs and mpv OSD for:
- invalid credentials
- expired/invalid token
- server/network errors
- missing library/item identifiers
- no playable source
- mpv not connected for playback
## Security Notes and Limitations
- Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted token storage after login/setup.
- Launcher wrappers support `--password-store=<backend>` and forward it through to the app process.
- Optional environment overrides are supported: `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID`.
- Treat both token storage and config files as secrets and avoid committing them.
- Password is used only for login and is not stored.
- Optional setup UI is available via `--jellyfin`; all actions are also available via CLI flags.
- `subminer` wrapper uses Jellyfin subcommands (`subminer jellyfin ...`, alias `subminer jf ...`). Use `SubMiner.AppImage` for direct `--jellyfin-libraries` and `--jellyfin-items`.
- For direct app CLI usage (`SubMiner.AppImage ...`), `--jellyfin-server` can override server URL for login/play flows without editing config.
+2
View File
@@ -64,6 +64,7 @@ subminer video.mkv # play a specific file (default plugin c
subminer https://youtu.be/... # YouTube playback (requires yt-dlp) subminer https://youtu.be/... # YouTube playback (requires yt-dlp)
subminer --backend x11 video.mkv # Force x11 backend for a specific file subminer --backend x11 video.mkv # Force x11 backend for a specific file
subminer -u # check for SubMiner updates subminer -u # check for SubMiner updates
subminer logs -e # export sanitized log ZIP
subminer stats # open immersion dashboard subminer stats # open immersion dashboard
subminer stats -b # start background stats daemon subminer stats -b # start background stats daemon
``` ```
@@ -78,6 +79,7 @@ subminer stats -b # start background stats daemon
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows | | `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
| `subminer doctor` | Dependency + config + socket diagnostics | | `subminer doctor` | Dependency + config + socket diagnostics |
| `subminer settings` | Open the SubMiner settings window | | `subminer settings` | Open the SubMiner settings window |
| `subminer logs -e` | Export a sanitized log ZIP and print its path |
| `subminer config path` | Print active config file path | | `subminer config path` | Print active config file path |
| `subminer config show` | Print active config contents | | `subminer config show` | Print active config contents |
| `subminer mpv status` | Check mpv socket readiness | | `subminer mpv status` | Check mpv socket readiness |
+2
View File
@@ -164,6 +164,8 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
3. For alass, select a reference subtitle track from the video. 3. For alass, select a reference subtitle track from the video.
4. SubMiner runs the sync and reloads the corrected subtitle. 4. SubMiner runs the sync and reloads the corrected subtitle.
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found. Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
## Texthooker ## Texthooker
+2
View File
@@ -163,6 +163,8 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
## Lifecycle ## Lifecycle
For how the plugin's auto-start fits into the full launch sequence — including when the launcher starts the overlay instead of the plugin — see [Playback Startup Flow](./architecture#playback-startup-flow).
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay. - **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback). - **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
- **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts). - **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts).
+17 -16
View File
@@ -46,10 +46,16 @@
// Logging // Logging
// Controls logging verbosity. // Controls logging verbosity.
// Set to debug for full runtime diagnostics. // Set to debug for full runtime diagnostics.
// Hot-reload: logging.level applies live while SubMiner is running. // Hot-reload: logging.level and logging.files apply live while SubMiner is running.
// ========================================== // ==========================================
"logging": { "logging": {
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error "level": "warn", // Minimum log level for runtime logging. Values: debug | info | warn | error
"rotation": 7, // Number of days of app, launcher, and mpv logs to retain.
"files": {
"app": true, // Write SubMiner app runtime logs. Values: true | false
"launcher": true, // Write launcher command logs. Values: true | false
"mpv": false // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false
} // Files setting.
}, // Controls logging verbosity. }, // Controls logging verbosity.
// ========================================== // ==========================================
@@ -83,7 +89,7 @@
"rightStickPress": 10, // Raw button index used for controller R3 input. "rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input. "leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 input. "rightTrigger": 7 // Raw button index used for controller R2 input.
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors. }, // Semantic button-name reference mapping used for debug output. Updating it does not rewrite existing raw binding descriptors.
"bindings": { "bindings": {
"toggleLookup": { "toggleLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
@@ -187,7 +193,7 @@
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes. "multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
"toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility. "toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility.
"markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card. "markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card.
"openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal. "openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal. "openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal. "openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
@@ -383,7 +389,9 @@
"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 "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 "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 "autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. 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
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary. "nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight. "nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights. "knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
@@ -523,8 +531,8 @@
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. "decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
}, // Known words setting. }, // Known words setting.
"behavior": { "behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
@@ -587,11 +595,8 @@
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false "enabled": false, // Enable AniList post-watch progress updates. Values: true | false
"accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup. "accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
"characterDictionary": { "characterDictionary": {
"enabled": false, // Enable automatic Yomitan character dictionary sync for currently watched AniList media. Values: true | false
"refreshTtlHours": 168, // Legacy setting; merged character dictionary retention is now usage-based and this value is ignored.
"maxLoaded": 3, // Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary. "maxLoaded": 3, // Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary.
"evictionPolicy": "delete", // Legacy setting; merged character dictionary eviction is usage-based and this value is ignored. Values: disable | delete "profileScope": "all", // Yomitan profile scope for character dictionary settings updates. Values: all | active
"profileScope": "all", // Yomitan profile scope for dictionary enable/disable updates. Values: all | active
"collapsibleSections": { "collapsibleSections": {
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false "description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
"characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false "characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false
@@ -624,7 +629,7 @@
"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 mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen "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. "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. "socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows "backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false "autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false "pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
@@ -644,14 +649,10 @@
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup. "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login. "username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
+3 -1
View File
@@ -362,7 +362,9 @@ test('dev server serves local archive files for local version links', async () =
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local'; process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = archiveDir; process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = archiveDir;
try { try {
const { default: localDevConfig } = await import('./.vitepress/config?local-dev-redirects'); const { default: localDevConfig } = await import(
`./.vitepress/config?local-dev-redirects-${Date.now()}`
);
let routeHandler: let routeHandler:
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void) | ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
| undefined; | undefined;
+3 -3
View File
@@ -76,9 +76,9 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
## Subtitle & Feature Shortcuts ## Subtitle & Feature Shortcuts
| Shortcut | Action | Config key | | Shortcut | Action | Config key |
| ------------------ | -------------------------------------------------------- | ----------------------------------- | | ------------------ | -------------------------------------------------------- | ----------------------------------------------- |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | | `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| `Ctrl/Cmd+Alt+A` | Open character dictionary AniList selector | `shortcuts.openCharacterDictionary` | | `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | | `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` | | `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | | `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
@@ -131,7 +131,7 @@ When the overlay has focus, press `y` then `d` to toggle DevTools (debugging hel
## Customizing Shortcuts ## Customizing Shortcuts
All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+Alt+A"`. Use `null` to disable a shortcut. All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+D"`. Use `null` to disable a shortcut.
```jsonc ```jsonc
{ {
+11 -6
View File
@@ -12,7 +12,7 @@ N+1 highlighting identifies sentences where you know every word except one, maki
**How it works:** **How it works:**
1. SubMiner queries your Anki decks for existing `Expression` / `Word` field values. 1. SubMiner queries your configured Anki decks for expression/word fields such as `Expression` or `Word`.
2. The results are cached locally (`known-words-cache.json`) and refreshed on a configurable interval. 2. The results are cached locally (`known-words-cache.json`) and refreshed on a configurable interval.
3. When a subtitle line appears, each token is checked against the cache. 3. When a subtitle line appears, each token is checked against the cache.
4. If exactly one unknown word remains in the sentence, it is highlighted with `subtitleStyle.nPlusOneColor` (default: `#c6a0f6`). 4. If exactly one unknown word remains in the sentence, it is highlighted with `subtitleStyle.nPlusOneColor` (default: `#c6a0f6`).
@@ -24,32 +24,36 @@ N+1 highlighting identifies sentences where you know every word except one, maki
| ----------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting | | `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes | | `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) | | `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries |
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) | | `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting. Existing configs with known-word highlighting enabled are treated as enabled for compatibility unless this is explicitly set. | | `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting |
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger | | `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word | | `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens | | `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
Prefer expression/word fields for `ankiConnect.knownWords.decks`. Reading-only fields can mark unrelated homophones as known, so only include them when that tradeoff is intentional.
::: tip ::: tip
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large. Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
::: :::
## Character-Name Highlighting ## Character-Name Highlighting
Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles — portraits, roles, voice actors, and biographical detail. Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles — portraits, roles, voice actors, and biographical detail.
**How it works:** **How it works:**
1. Subtitles are tokenized, then candidate name tokens are matched against the character dictionary via Yomitan's scanning pipeline. 1. Subtitles are tokenized, then candidate name tokens are matched against the character dictionary via Yomitan's scanning pipeline.
2. Matching tokens receive a dedicated style distinct from N+1 and frequency layers. 2. Matching tokens receive a dedicated style distinct from N+1 and frequency layers.
3. This layer can be independently toggled with `subtitleStyle.nameMatchEnabled`. 3. This layer can be independently toggled with `subtitleStyle.nameMatchEnabled`.
4. When `subtitleStyle.nameMatchImagesEnabled` is also enabled, SubMiner shows the cached AniList portrait beside matched names.
**Key settings:** **Key settings:**
| Option | Default | Description | | Option | Default | Description |
| -------------------------------- | --------- | ---------------------------------------- | | -------------------------------------- | --------- | ------------------------------------------------ |
| `subtitleStyle.nameMatchEnabled` | `false` | Enable character-name token highlighting | | `subtitleStyle.nameMatchEnabled` | `false` | Enable character-name token highlighting |
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits next to name tokens |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches | | `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page. For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
@@ -68,7 +72,7 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
**Key settings:** **Key settings:**
| Option | Default | Description | | Option | Default | Description |
| ------------------------------------------------ | ------------ | ---------------------------------------- | | ------------------------------------------------ | ------------ | ---------------------------------------------------------------- |
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting | | `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight | | `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` | | `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
@@ -122,6 +126,7 @@ All annotation layers can be toggled at runtime via the mpv command menu without
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`) - `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
- `subtitleStyle.nameMatchEnabled` (`On` / `Off`) - `subtitleStyle.nameMatchEnabled` (`On` / `Off`)
- `subtitleStyle.nameMatchImagesEnabled` (`On` / `Off`)
- `subtitleStyle.enableJlpt` (`On` / `Off`) - `subtitleStyle.enableJlpt` (`On` / `Off`)
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`) - `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
+3 -1
View File
@@ -14,6 +14,8 @@ SubMiner connects to mpv via a Unix socket (or named pipe on Windows). If the so
SubMiner retries the connection automatically with increasing delays (200 ms, 500 ms, 1 s, 2 s on first connect; 1 s, 2 s, 5 s, 10 s on reconnect). If mpv exits and restarts, the overlay reconnects without needing a restart. SubMiner retries the connection automatically with increasing delays (200 ms, 500 ms, 1 s, 2 s on first connect; 1 s, 2 s, 5 s, 10 s on reconnect). If mpv exits and restarts, the overlay reconnects without needing a restart.
If the overlay never appears at all, see [Playback Startup Flow](./architecture#playback-startup-flow) for how a managed launch starts mpv and brings up the overlay.
## Logging and App Mode ## Logging and App Mode
- Default log output is `info`. - Default log output is `info`.
@@ -114,7 +116,7 @@ Automatic checks log failures quietly so playback is not interrupted.
**"SubMiner is up to date" but a prerelease exists** **"SubMiner is up to date" but a prerelease exists**
SubMiner defaults to stable GitHub releases. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases. SubMiner uses the configured release channel for update checks. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases.
**Launcher update shows a sudo command** **Launcher update shows a sudo command**
+6 -3
View File
@@ -49,7 +49,7 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
### Ways to Launch ### Ways to Launch
| Approach | Use when | How | | Approach | Use when | How |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| **`subminer` launcher** | You want SubMiner to handle everything — launch mpv, set up the socket, start the overlay. **Recommended for most users.** | `subminer video.mkv` | | **`subminer` launcher** | You want SubMiner to handle everything — launch mpv, set up the socket, start the overlay. **Recommended for most users.** | `subminer video.mkv` |
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` | | **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut | | **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
@@ -105,6 +105,7 @@ subminer jellyfin -p # Interactive Jellyfin library/item picker + p
subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app) subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app)
subminer app --stop # Stop background app (including Jellyfin cast broadcast) subminer app --stop # Stop background app (including Jellyfin cast broadcast)
subminer doctor # Dependency + config + socket diagnostics subminer doctor # Dependency + config + socket diagnostics
subminer logs -e # Export a sanitized log ZIP and print its path
subminer config path # Print active config path subminer config path # Print active config path
subminer config show # Print active config contents subminer config show # Print active config contents
subminer mpv socket # Print active mpv socket path subminer mpv socket # Print active mpv socket path
@@ -143,10 +144,11 @@ SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability ann
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series
SubMiner.AppImage --open-character-dictionary # Open in-app AniList selector
SubMiner.AppImage --help # Show all options SubMiner.AppImage --help # Show all options
``` ```
The tray menu includes `Export Logs`, which creates the same sanitized log ZIP as `subminer logs -e` and shows the archive path when complete.
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config. Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
### Logging and App Mode ### Logging and App Mode
@@ -187,6 +189,7 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases. - `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
- `subminer doctor`: health checks for core dependencies and runtime paths. - `subminer doctor`: health checks for core dependencies and runtime paths.
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`). - `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
- `subminer logs -e`: export a sanitized ZIP of today's logs, or the most recent logs when no current-day log exists.
- `subminer config`: config file helpers (`path`, `show`). - `subminer config`: config file helpers (`path`, `show`).
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`). - `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target. - `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
@@ -220,7 +223,7 @@ Setup flow:
AniList character dictionary auto-sync (optional): AniList character dictionary auto-sync (optional):
- Enable with `anilist.characterDictionary.enabled=true` in config. - Enable with `subtitleStyle.nameMatchEnabled=true` in config or **Name Match Enabled** in Settings.
- SubMiner syncs the currently watched AniList media into a per-media snapshot, then rebuilds one merged `SubMiner Character Dictionary` from the most recently used snapshots. - SubMiner syncs the currently watched AniList media into a per-media snapshot, then rebuilds one merged `SubMiner Character Dictionary` from the most recently used snapshots.
- Rotation limit defaults to 3 recent media snapshots in that merged dictionary (`maxLoaded`). - Rotation limit defaults to 3 recent media snapshots in that merged dictionary (`maxLoaded`).
+3 -2
View File
@@ -88,10 +88,11 @@ Notes:
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed. - AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation. - Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled. - Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS ZIP, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer. - macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS `SubMiner-<version>-mac.zip`, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks. - macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks.
- Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks. - Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks.
- Build config emits distinct ZIP names: `SubMiner-<version>-mac.zip` for the macOS Squirrel updater payload and `SubMiner-<version>-win.zip` for the Windows portable fallback. The user-facing DMG and Windows installer keep the unqualified `SubMiner-<version>` basename.
- Linux GitHub release metadata and asset downloads also use `/usr/bin/curl` instead of Electron networking for the same reason. - Linux GitHub release metadata and asset downloads also use `/usr/bin/curl` instead of Electron networking for the same reason.
- Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed. - Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. Manual tray and launcher checks still use GitHub release metadata to report newer releases, but automatic notifications stay quiet when native app installation is unsupported. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed.
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code. - The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior. - Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
+1 -1
View File
@@ -163,7 +163,7 @@ test('buildSubminerScriptOpts includes aniskip payload fields', () => {
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/); const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/); assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/); assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
assert.match(opts, /subminer-log_level=debug/); assert.doesNotMatch(opts, /subminer-log_level=/);
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/); assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
assert.match(opts, /subminer-aniskip_season=1/); assert.match(opts, /subminer-aniskip_season=1/);
assert.match(opts, /subminer-aniskip_episode=5/); assert.match(opts, /subminer-aniskip_episode=5/);
+1 -4
View File
@@ -564,7 +564,7 @@ export function buildSubminerScriptOpts(
appPath: string, appPath: string,
socketPath: string, socketPath: string,
aniSkipMetadata: AniSkipMetadata | null, aniSkipMetadata: AniSkipMetadata | null,
logLevel: LogLevel = 'info', _logLevel: LogLevel = 'info',
extraParts: string[] = [], extraParts: string[] = [],
): string { ): string {
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path=')); const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
@@ -574,9 +574,6 @@ export function buildSubminerScriptOpts(
...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]), ...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]),
...extraParts.map(sanitizeScriptOptValue), ...extraParts.map(sanitizeScriptOptValue),
]; ];
if (logLevel !== 'info') {
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);
}
if (aniSkipMetadata && aniSkipMetadata.title) { if (aniSkipMetadata && aniSkipMetadata.title) {
parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`); parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`);
} }
+28 -4
View File
@@ -1,19 +1,43 @@
import { launchTexthookerOnly, runAppCommandWithInherit } from '../mpv.js'; import {
launchAppBackgroundDetached,
launchTexthookerOnly,
runAppCommandWithInherit,
} from '../mpv.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
export function runAppPassthroughCommand(context: LauncherCommandContext): boolean { type AppCommandDeps = {
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
launchAppBackgroundDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
};
const defaultAppCommandDeps: AppCommandDeps = {
runAppCommandWithInherit,
launchAppBackgroundDetached,
};
export function runAppPassthroughCommand(
context: LauncherCommandContext,
deps: AppCommandDeps = defaultAppCommandDeps,
): boolean {
const { args, appPath } = context; const { args, appPath } = context;
if (!appPath) { if (!appPath) {
return false; return false;
} }
if (args.settings) { if (args.settings) {
runAppCommandWithInherit(appPath, ['--settings']); deps.runAppCommandWithInherit(appPath, ['--settings']);
return true; return true;
} }
if (!args.appPassthrough) { if (!args.appPassthrough) {
return false; return false;
} }
runAppCommandWithInherit(appPath, args.appArgs); if (args.appArgs.length === 0) {
deps.launchAppBackgroundDetached(appPath, args.logLevel);
return true;
}
deps.runAppCommandWithInherit(appPath, args.appArgs);
return true; return true;
} }
+88
View File
@@ -6,7 +6,9 @@ import type { LauncherCommandContext } from './context.js';
import { runConfigCommand } from './config-command.js'; import { runConfigCommand } from './config-command.js';
import { runDictionaryCommand } from './dictionary-command.js'; import { runDictionaryCommand } from './dictionary-command.js';
import { runDoctorCommand } from './doctor-command.js'; import { runDoctorCommand } from './doctor-command.js';
import { runLogsCommand } from './logs-command.js';
import { runMpvPreAppCommand } from './mpv-command.js'; import { runMpvPreAppCommand } from './mpv-command.js';
import { runAppPassthroughCommand } from './app-command.js';
import { runStatsCommand } from './stats-command.js'; import { runStatsCommand } from './stats-command.js';
import { runUpdateCommand } from './update-command.js'; import { runUpdateCommand } from './update-command.js';
@@ -168,6 +170,92 @@ test('doctor command forwards refresh-known-words to app binary', () => {
assert.deepEqual(forwarded, [['--refresh-known-words']]); 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');
},
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', () => {
const context = createContext();
context.args.appPassthrough = true;
context.args.appArgs = [];
const calls: string[] = [];
const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: () => {
calls.push('attached');
},
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']);
});
test('app command keeps explicit passthrough args attached', () => {
const context = createContext();
context.args.appPassthrough = true;
context.args.appArgs = ['--settings'];
const forwarded: string[][] = [];
const detached: string[] = [];
const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
launchAppBackgroundDetached: () => {
detached.push('detached');
},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [['--settings']]);
assert.deepEqual(detached, []);
});
test('mpv pre-app command exits non-zero when socket is not ready', async () => { test('mpv pre-app command exits non-zero when socket is not ready', async () => {
const context = createContext(); const context = createContext();
context.args.mpvStatus = true; context.args.mpvStatus = true;
+2 -1
View File
@@ -1,4 +1,5 @@
import { runAppCommandWithInherit } from '../mpv.js'; import { runAppCommandWithInherit } from '../mpv.js';
import { shouldForwardLogLevel } from '../types.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
interface DictionaryCommandDeps { interface DictionaryCommandDeps {
@@ -35,7 +36,7 @@ export function runDictionaryCommand(
if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) { if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) {
forwarded.push('--dictionary-target', args.dictionaryTarget); forwarded.push('--dictionary-target', args.dictionaryTarget);
} }
if (args.logLevel !== 'info') { if (shouldForwardLogLevel(args.logLevel)) {
forwarded.push('--log-level', args.logLevel); forwarded.push('--log-level', args.logLevel);
} }
+5 -4
View File
@@ -2,6 +2,7 @@ import { fail } from '../log.js';
import { runAppCommandWithInherit } from '../mpv.js'; import { runAppCommandWithInherit } from '../mpv.js';
import { commandExists } from '../util.js'; import { commandExists } from '../util.js';
import { runJellyfinPlayMenu } from '../jellyfin.js'; import { runJellyfinPlayMenu } from '../jellyfin.js';
import { shouldForwardLogLevel } from '../types.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
export async function runJellyfinCommand(context: LauncherCommandContext): Promise<boolean> { export async function runJellyfinCommand(context: LauncherCommandContext): Promise<boolean> {
@@ -18,7 +19,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.jellyfin) { if (args.jellyfin) {
const forwarded = ['--jellyfin']; const forwarded = ['--jellyfin'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded); appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded); runAppCommandWithInherit(appPath, forwarded);
return true; return true;
@@ -42,7 +43,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
'--jellyfin-password', '--jellyfin-password',
password, password,
]; ];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded); appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded); runAppCommandWithInherit(appPath, forwarded);
return true; return true;
@@ -50,7 +51,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.jellyfinLogout) { if (args.jellyfinLogout) {
const forwarded = ['--jellyfin-logout']; const forwarded = ['--jellyfin-logout'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded); appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded); runAppCommandWithInherit(appPath, forwarded);
return true; return true;
@@ -69,7 +70,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.jellyfinDiscovery) { if (args.jellyfinDiscovery) {
const forwarded = ['--background', '--jellyfin-remote-announce']; const forwarded = ['--background', '--jellyfin-remote-announce'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded); appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded); runAppCommandWithInherit(appPath, forwarded);
return true; return true;
+24
View File
@@ -0,0 +1,24 @@
import { exportLogsArchiveForCurrentUser } from '../../src/main/runtime/log-export.js';
import type { ExportLogsResult } from '../../src/main/runtime/log-export.js';
import type { LauncherCommandContext } from './context.js';
interface LogsCommandDeps {
exportLogsArchive(): ExportLogsResult;
}
const defaultDeps: LogsCommandDeps = {
exportLogsArchive: () => exportLogsArchiveForCurrentUser(),
};
export function runLogsCommand(
context: LauncherCommandContext,
deps: LogsCommandDeps = defaultDeps,
): boolean {
if (!context.args.logsExport) {
return false;
}
const result = deps.exportLogsArchive();
context.processAdapter.writeStdout(`${result.zipPath}\n`);
return true;
}
@@ -36,6 +36,7 @@ function createContext(): LauncherCommandContext {
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
logLevel: 'info', logLevel: 'info',
logRotation: 7,
passwordStore: '', passwordStore: '',
target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw', target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
targetKind: 'url', targetKind: 'url',
@@ -55,6 +56,7 @@ function createContext(): LauncherCommandContext {
stats: false, stats: false,
doctor: false, doctor: false,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsExport: false,
version: false, version: false,
settings: false, settings: false,
configPath: false, configPath: false,
@@ -321,6 +323,7 @@ test('plugin auto-start playback attaches a warm background app through the laun
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => { test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
const context = createContext(); const context = createContext();
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
const originalAppData = process.env.APPDATA;
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-')); const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner'); const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
fs.mkdirSync(expectedConfigDir, { recursive: true }); fs.mkdirSync(expectedConfigDir, { recursive: true });
@@ -347,6 +350,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
try { try {
process.env.XDG_CONFIG_HOME = xdgConfigHome; process.env.XDG_CONFIG_HOME = xdgConfigHome;
process.env.APPDATA = xdgConfigHome;
await runPlaybackCommandWithDeps(context, { await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {}, ensurePlaybackSetupReady: async () => {},
@@ -376,6 +380,11 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
} else { } else {
process.env.XDG_CONFIG_HOME = originalXdgConfigHome; 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 }); fs.rmSync(xdgConfigHome, { recursive: true, force: true });
} }
}); });
+2 -1
View File
@@ -4,6 +4,7 @@ import path from 'node:path';
import { runAppCommandAttached } from '../mpv.js'; import { runAppCommandAttached } from '../mpv.js';
import { nowMs } from '../time.js'; import { nowMs } from '../time.js';
import { sleep } from '../util.js'; import { sleep } from '../util.js';
import { shouldForwardLogLevel } from '../types.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
type StatsCommandResponse = { type StatsCommandResponse = {
@@ -156,7 +157,7 @@ export async function runStatsCommand(
if (args.statsCleanupLifetime) { if (args.statsCleanupLifetime) {
forwarded.push('--stats-cleanup-lifetime'); forwarded.push('--stats-cleanup-lifetime');
} }
if (args.logLevel !== 'info') { if (shouldForwardLogLevel(args.logLevel)) {
forwarded.push('--log-level', args.logLevel); forwarded.push('--log-level', args.logLevel);
} }
const attachedExitPromise = resolvedDeps.runAppCommandAttached( const attachedExitPromise = resolvedDeps.runAppCommandAttached(
+1
View File
@@ -13,6 +13,7 @@ test('launcher root help lists subcommands', () => {
assert.match(output, /doctor/); assert.match(output, /doctor/);
assert.match(output, /config/); assert.match(output, /config/);
assert.match(output, /mpv/); assert.match(output, /mpv/);
assert.match(output, /logs/);
assert.match(output, /dictionary\|dict/); assert.match(output, /dictionary\|dict/);
assert.match(output, /texthooker/); assert.match(output, /texthooker/);
assert.match(output, /app\|bin/); assert.match(output, /app\|bin/);
+50 -1
View File
@@ -1,12 +1,14 @@
import { fail } from './log.js'; import { fail } from './log.js';
import type { import type {
Args, Args,
LauncherLoggingConfig,
LauncherJellyfinConfig, LauncherJellyfinConfig,
LauncherMpvConfig, LauncherMpvConfig,
LauncherYoutubeSubgenConfig, LauncherYoutubeSubgenConfig,
LogLevel, LogLevel,
PluginRuntimeConfig, PluginRuntimeConfig,
} from './types.js'; } from './types.js';
import { normalizeLogRotation } from '../src/shared/log-files.js';
import { import {
applyInvocationsToArgs, applyInvocationsToArgs,
applyRootOptionsToArgs, applyRootOptionsToArgs,
@@ -52,6 +54,52 @@ export function loadLauncherMpvConfig(): LauncherMpvConfig {
return parseLauncherMpvConfig(root); return parseLauncherMpvConfig(root);
} }
function parseLogLevelConfig(value: unknown): LogLevel | undefined {
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (
normalized === 'debug' ||
normalized === 'info' ||
normalized === 'warn' ||
normalized === 'error'
) {
return normalized;
}
return undefined;
}
function parseLogRotationConfig(value: unknown): LauncherLoggingConfig['rotation'] {
return normalizeLogRotation(value);
}
function parseLogFileConfig(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined;
}
export function loadLauncherLoggingConfig(): LauncherLoggingConfig {
const root = readLauncherMainConfigObject();
if (!root) return {};
const logging =
root.logging && typeof root.logging === 'object' && !Array.isArray(root.logging)
? (root.logging as Record<string, unknown>)
: null;
const files =
logging?.files && typeof logging.files === 'object' && !Array.isArray(logging.files)
? (logging.files as Record<string, unknown>)
: null;
return {
level: parseLogLevelConfig(logging?.level),
rotation: parseLogRotationConfig(logging?.rotation),
files: files
? {
app: parseLogFileConfig(files.app),
launcher: parseLogFileConfig(files.launcher),
mpv: parseLogFileConfig(files.mpv),
}
: undefined,
};
}
export function hasLauncherExternalYomitanProfileConfig(): boolean { export function hasLauncherExternalYomitanProfileConfig(): boolean {
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null; return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
} }
@@ -65,9 +113,10 @@ export function parseArgs(
scriptName: string, scriptName: string,
launcherConfig: LauncherYoutubeSubgenConfig, launcherConfig: LauncherYoutubeSubgenConfig,
launcherMpvConfig: LauncherMpvConfig = {}, launcherMpvConfig: LauncherMpvConfig = {},
launcherLoggingConfig: LauncherLoggingConfig = {},
): Args { ): Args {
const topLevelCommand = resolveTopLevelCommand(argv); const topLevelCommand = resolveTopLevelCommand(argv);
const parsed = createDefaultArgs(launcherConfig, launcherMpvConfig); const parsed = createDefaultArgs(launcherConfig, launcherMpvConfig, launcherLoggingConfig);
if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) { if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) {
parsed.appPassthrough = true; parsed.appPassthrough = true;
+15
View File
@@ -51,6 +51,13 @@ test('createDefaultArgs seeds mpv profile from launcher config', () => {
assert.equal(parsed.profile, 'anime'); assert.equal(parsed.profile, 'anime');
}); });
test('createDefaultArgs seeds log level from launcher logging config', () => {
const parsed = createDefaultArgs({}, {}, { level: 'debug', rotation: 14 });
assert.equal(parsed.logLevel, 'debug');
assert.equal(parsed.logRotation, 14);
});
test('applyRootOptionsToArgs appends CLI mpv profile to configured profile', () => { test('applyRootOptionsToArgs appends CLI mpv profile to configured profile', () => {
const parsed = createDefaultArgs({}, { profile: 'anime' }); const parsed = createDefaultArgs({}, { profile: 'anime' });
@@ -131,6 +138,8 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
doctorTriggered: false, doctorTriggered: false,
doctorLogLevel: null, doctorLogLevel: null,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false, texthookerTriggered: false,
texthookerLogLevel: null, texthookerLogLevel: null,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
@@ -175,6 +184,8 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
doctorTriggered: false, doctorTriggered: false,
doctorLogLevel: null, doctorLogLevel: null,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false, texthookerTriggered: false,
texthookerLogLevel: null, texthookerLogLevel: null,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
@@ -212,6 +223,8 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
doctorTriggered: false, doctorTriggered: false,
doctorLogLevel: null, doctorLogLevel: null,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false, texthookerTriggered: false,
texthookerLogLevel: null, texthookerLogLevel: null,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
@@ -247,6 +260,8 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
doctorTriggered: false, doctorTriggered: false,
doctorLogLevel: null, doctorLogLevel: null,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: true, texthookerTriggered: true,
texthookerLogLevel: null, texthookerLogLevel: null,
texthookerOpenBrowser: true, texthookerOpenBrowser: true,
+9 -1
View File
@@ -4,6 +4,7 @@ import { fail } from '../log.js';
import type { import type {
Args, Args,
Backend, Backend,
LauncherLoggingConfig,
LauncherMpvConfig, LauncherMpvConfig,
LauncherYoutubeSubgenConfig, LauncherYoutubeSubgenConfig,
LogLevel, LogLevel,
@@ -106,6 +107,7 @@ function parseDictionaryAnilistId(value: string): number {
export function createDefaultArgs( export function createDefaultArgs(
launcherConfig: LauncherYoutubeSubgenConfig, launcherConfig: LauncherYoutubeSubgenConfig,
mpvConfig: LauncherMpvConfig = {}, mpvConfig: LauncherMpvConfig = {},
loggingConfig: LauncherLoggingConfig = {},
): Args { ): Args {
const configuredSecondaryLangs = uniqueNormalizedLangCodes( const configuredSecondaryLangs = uniqueNormalizedLangCodes(
launcherConfig.secondarySubLanguages ?? [], launcherConfig.secondarySubLanguages ?? [],
@@ -162,6 +164,7 @@ export function createDefaultArgs(
statsCleanupLifetime: false, statsCleanupLifetime: false,
doctor: false, doctor: false,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsExport: false,
version: false, version: false,
update: false, update: false,
settings: false, settings: false,
@@ -195,7 +198,8 @@ export function createDefaultArgs(
texthookerOnly: false, texthookerOnly: false,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
logLevel: 'info', logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7,
passwordStore: '', passwordStore: '',
target: '', target: '',
targetKind: '', targetKind: '',
@@ -260,6 +264,10 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
} }
if (invocations.doctorTriggered) parsed.doctor = true; if (invocations.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true; if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
if (invocations.logsTriggered && !invocations.logsExport) {
fail('Logs command requires -e or --export.');
}
if (invocations.logsExport) parsed.logsExport = true;
if (invocations.texthookerTriggered) parsed.texthookerOnly = true; if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
if (invocations.texthookerOpenBrowser) parsed.texthookerOpenBrowser = true; if (invocations.texthookerOpenBrowser) parsed.texthookerOpenBrowser = true;
+16
View File
@@ -41,6 +41,8 @@ export interface CliInvocations {
doctorTriggered: boolean; doctorTriggered: boolean;
doctorLogLevel: string | null; doctorLogLevel: string | null;
doctorRefreshKnownWords: boolean; doctorRefreshKnownWords: boolean;
logsTriggered: boolean;
logsExport: boolean;
texthookerTriggered: boolean; texthookerTriggered: boolean;
texthookerLogLevel: string | null; texthookerLogLevel: string | null;
texthookerOpenBrowser: boolean; texthookerOpenBrowser: boolean;
@@ -91,6 +93,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'config', 'config',
'settings', 'settings',
'mpv', 'mpv',
'logs',
'dictionary', 'dictionary',
'dict', 'dict',
'stats', 'stats',
@@ -158,6 +161,8 @@ export function parseCliPrograms(
let statsLogLevel: string | null = null; let statsLogLevel: string | null = null;
let doctorLogLevel: string | null = null; let doctorLogLevel: string | null = null;
let doctorRefreshKnownWords = false; let doctorRefreshKnownWords = false;
let logsTriggered = false;
let logsExport = false;
let texthookerLogLevel: string | null = null; let texthookerLogLevel: string | null = null;
let texthookerOpenBrowser = false; let texthookerOpenBrowser = false;
let doctorTriggered = false; let doctorTriggered = false;
@@ -294,6 +299,15 @@ export function parseCliPrograms(
doctorRefreshKnownWords = options.refreshKnownWords === true; doctorRefreshKnownWords = options.refreshKnownWords === true;
}); });
commandProgram
.command('logs')
.description('Log file helpers')
.option('-e, --export', 'Export sanitized log archive')
.action((options: Record<string, unknown>) => {
logsTriggered = true;
logsExport = options.export === true;
});
commandProgram commandProgram
.command('config') .command('config')
.description('Config file helpers (path|show)') .description('Config file helpers (path|show)')
@@ -388,6 +402,8 @@ export function parseCliPrograms(
doctorTriggered, doctorTriggered,
doctorLogLevel, doctorLogLevel,
doctorRefreshKnownWords, doctorRefreshKnownWords,
logsTriggered,
logsExport,
texthookerTriggered, texthookerTriggered,
texthookerLogLevel, texthookerLogLevel,
texthookerOpenBrowser, texthookerOpenBrowser,
+3 -1
View File
@@ -40,6 +40,7 @@ function validBackendOrDefault(value: unknown, fallback: Backend): Backend {
export function parsePluginRuntimeConfigFromMainConfig( export function parsePluginRuntimeConfigFromMainConfig(
root: Record<string, unknown> | null, root: Record<string, unknown> | null,
logLevel: LogLevel = 'info',
): PluginRuntimeConfig { ): PluginRuntimeConfig {
const mpvConfig = root ? parseLauncherMpvConfig(root) : {}; const mpvConfig = root ? parseLauncherMpvConfig(root) : {};
const texthooker = rootObject(root, 'texthooker'); const texthooker = rootObject(root, 'texthooker');
@@ -48,6 +49,7 @@ export function parsePluginRuntimeConfigFromMainConfig(
socketPath: mpvConfig.socketPath ?? DEFAULT_SOCKET_PATH, socketPath: mpvConfig.socketPath ?? DEFAULT_SOCKET_PATH,
binaryPath: mpvConfig.subminerBinaryPath ?? '', binaryPath: mpvConfig.subminerBinaryPath ?? '',
backend: validBackendOrDefault(mpvConfig.backend, 'auto'), backend: validBackendOrDefault(mpvConfig.backend, 'auto'),
logLevel,
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true), autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false), autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true), autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
@@ -65,7 +67,7 @@ export function buildPluginRuntimeScriptOptParts(
} }
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject()); const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject(), logLevel);
log( log(
'debug', 'debug',
+18 -9
View File
@@ -9,6 +9,7 @@ import type {
JellyfinItemEntry, JellyfinItemEntry,
JellyfinGroupEntry, JellyfinGroupEntry,
} from './types.js'; } from './types.js';
import { shouldForwardLogLevel } from './types.js';
import { log, fail, getMpvLogPath } from './log.js'; import { log, fail, getMpvLogPath } from './log.js';
import { nowMs } from './time.js'; import { nowMs } from './time.js';
import { commandExists, resolvePathMaybe, sleep } from './util.js'; import { commandExists, resolvePathMaybe, sleep } from './util.js';
@@ -361,6 +362,21 @@ export function classifyJellyfinChildSelection(
fail('Selected Jellyfin item is not playable.'); fail('Selected Jellyfin item is not playable.');
} }
export function buildForwardedJellyfinAppArgs(args: Args, appArgs: string[]): string[] {
const forwarded = [...appArgs];
const serverOverride = sanitizeServerUrl(args.jellyfinServer || '');
if (serverOverride) {
forwarded.push('--jellyfin-server', serverOverride);
}
if (args.passwordStore) {
forwarded.push('--password-store', args.passwordStore);
}
if (!forwarded.some((arg) => arg === '--log-level' || arg.startsWith('--log-level='))) {
forwarded.push('--log-level', args.logLevel);
}
return forwarded;
}
async function runAppJellyfinListCommand( async function runAppJellyfinListCommand(
appPath: string, appPath: string,
args: Args, args: Args,
@@ -384,14 +400,7 @@ async function runAppJellyfinCommand(
appArgs: string[], appArgs: string[],
label: string, label: string,
): Promise<{ status: number; output: string; error: string; logOffset: number }> { ): Promise<{ status: number; output: string; error: string; logOffset: number }> {
const forwardedBase = [...appArgs]; const forwardedBase = buildForwardedJellyfinAppArgs(args, appArgs);
const serverOverride = sanitizeServerUrl(args.jellyfinServer || '');
if (serverOverride) {
forwardedBase.push('--jellyfin-server', serverOverride);
}
if (args.passwordStore) {
forwardedBase.push('--password-store', args.passwordStore);
}
const readLogAppendedSince = (offset: number): string => { const readLogAppendedSince = (offset: number): string => {
const logPath = getMpvLogPath(); const logPath = getMpvLogPath();
@@ -1028,7 +1037,7 @@ export async function runJellyfinPlayMenu(
fail(`MPV IPC socket not ready: ${mpvSocketPath}`); fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
} }
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`]; const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore); if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play'); runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
} }
+24 -5
View File
@@ -1,6 +1,14 @@
import type { LogLevel } from './types.js'; import type { LogLevel } from './types.js';
import { DEFAULT_MPV_LOG_FILE, getDefaultLauncherLogFile } from './types.js'; import { getDefaultLauncherLogFile } from './types.js';
import { appendLogLine, resolveDefaultLogFilePath } from '../src/shared/log-files.js'; import {
appendLogLine,
DEFAULT_LOG_ROTATION,
isLogFileEnabled,
normalizeLogRotation,
pruneLogDirectoryForPath,
resolveDefaultLogFilePath,
type LogRotation,
} from '../src/shared/log-files.js';
export const COLORS = { export const COLORS = {
red: '\x1b[0;31m', red: '\x1b[0;31m',
@@ -22,25 +30,36 @@ export function shouldLog(level: LogLevel, configured: LogLevel): boolean {
} }
export function getMpvLogPath(): string { export function getMpvLogPath(): string {
if (!isLogFileEnabled('mpv')) return '';
const envPath = process.env.SUBMINER_MPV_LOG?.trim(); const envPath = process.env.SUBMINER_MPV_LOG?.trim();
if (envPath) return envPath; const logPath = envPath || resolveDefaultLogFilePath('mpv');
return DEFAULT_MPV_LOG_FILE; pruneLogDirectoryForPath(logPath, getLogRotation());
return logPath;
} }
export function getLauncherLogPath(): string { export function getLauncherLogPath(): string {
if (!isLogFileEnabled('launcher')) return '';
const envPath = process.env.SUBMINER_LAUNCHER_LOG?.trim(); const envPath = process.env.SUBMINER_LAUNCHER_LOG?.trim();
if (envPath) return envPath; if (envPath) return envPath;
return getDefaultLauncherLogFile(); return getDefaultLauncherLogFile();
} }
export function getAppLogPath(): string { export function getAppLogPath(): string {
if (!isLogFileEnabled('app')) return '';
const envPath = process.env.SUBMINER_APP_LOG?.trim(); const envPath = process.env.SUBMINER_APP_LOG?.trim();
if (envPath) return envPath; if (envPath) return envPath;
return resolveDefaultLogFilePath('app'); return resolveDefaultLogFilePath('app');
} }
function getLogRotation(): LogRotation {
return normalizeLogRotation(process.env.SUBMINER_LOG_ROTATION) ?? DEFAULT_LOG_ROTATION;
}
function appendTimestampedLog(logPath: string, message: string): void { function appendTimestampedLog(logPath: string, message: string): void {
appendLogLine(logPath, `[${new Date().toISOString()}] ${message}`); if (!logPath.trim()) return;
appendLogLine(logPath, `[${new Date().toISOString()}] ${message}`, {
rotation: getLogRotation(),
});
} }
export function appendToMpvLog(message: string): void { export function appendToMpvLog(message: string): void {
+54 -2
View File
@@ -17,6 +17,7 @@ import {
parseEpisodePathFromDisplay, parseEpisodePathFromDisplay,
buildRootSearchGroups, buildRootSearchGroups,
classifyJellyfinChildSelection, classifyJellyfinChildSelection,
buildForwardedJellyfinAppArgs,
} from './jellyfin.js'; } from './jellyfin.js';
type RunResult = { type RunResult = {
@@ -123,6 +124,29 @@ test('short version flag prints installed app version without requiring app bina
}); });
}); });
test('logs export writes sanitized archive without requiring app binary', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const logsDir =
process.platform === 'win32'
? path.join(xdgConfigHome, 'SubMiner', 'logs')
: path.join(homeDir, '.config', 'SubMiner', 'logs');
fs.mkdirSync(logsDir, { recursive: true });
fs.writeFileSync(path.join(logsDir, 'app-2026-W21.log'), `/home/kyle/video.mkv\n`, 'utf8');
const result = runLauncher(['logs', '-e'], makeTestEnv(homeDir, xdgConfigHome));
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
const zipPath = result.stdout.trim();
assert.match(zipPath, /subminer-logs-.+\.zip$/);
assert.equal(fs.existsSync(zipPath), true);
const archive = fs.readFileSync(zipPath);
assert.equal(archive.includes(Buffer.from('/home/kyle')), false);
assert.equal(archive.includes(Buffer.from('/home/<user>')), true);
});
});
test('config path prefers jsonc over json for same directory', () => { test('config path prefers jsonc over json for same directory', () => {
withTempDir((root) => { withTempDir((root) => {
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');
@@ -394,7 +418,7 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
}); });
}); });
test('launcher forwards non-info log level into mpv plugin script opts', { timeout: 15000 }, () => { test('launcher forwards non-info log level into mpv logging args', { timeout: 15000 }, () => {
withTempDir((root) => { withTempDir((root) => {
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg'); const xdgConfigHome = path.join(root, 'xdg');
@@ -429,6 +453,11 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo
autoStartSubMiner: true, autoStartSubMiner: true,
pauseUntilOverlayReady: true, pauseUntilOverlayReady: true,
}, },
logging: {
files: {
mpv: true,
},
},
}), }),
); );
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n'); fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
@@ -467,7 +496,9 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
const result = runLauncher(['--log-level', 'debug', videoPath], env); const result = runLauncher(['--log-level', 'debug', videoPath], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
assert.match(fs.readFileSync(mpvArgsPath, 'utf8'), /--script-opts=.*subminer-log_level=debug/); const mpvArgs = fs.readFileSync(mpvArgsPath, 'utf8');
assert.match(mpvArgs, /--msg-level=all=warn,subminer=debug/);
assert.doesNotMatch(mpvArgs, /--script-opts=.*subminer-log_level=debug/);
}); });
}); });
@@ -878,6 +909,27 @@ test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
]); ]);
}); });
test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => {
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', () => { test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
const parsed = parseJellyfinErrorFromAppOutput(` const parsed = parseJellyfinErrorFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning [subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
+20 -1
View File
@@ -1,7 +1,9 @@
import path from 'node:path'; import path from 'node:path';
import packageJson from '../package.json'; import packageJson from '../package.json';
import { applyLogFileTogglesToEnv } from '../src/shared/log-files.js';
import { import {
loadLauncherJellyfinConfig, loadLauncherJellyfinConfig,
loadLauncherLoggingConfig,
loadLauncherMpvConfig, loadLauncherMpvConfig,
loadLauncherYoutubeSubgenConfig, loadLauncherYoutubeSubgenConfig,
parseArgs, parseArgs,
@@ -16,6 +18,7 @@ import { runConfigCommand } from './commands/config-command.js';
import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js'; import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js';
import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js'; import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js';
import { runDictionaryCommand } from './commands/dictionary-command.js'; import { runDictionaryCommand } from './commands/dictionary-command.js';
import { runLogsCommand } from './commands/logs-command.js';
import { runStatsCommand } from './commands/stats-command.js'; import { runStatsCommand } from './commands/stats-command.js';
import { runJellyfinCommand } from './commands/jellyfin-command.js'; import { runJellyfinCommand } from './commands/jellyfin-command.js';
import { runPlaybackCommand } from './commands/playback-command.js'; import { runPlaybackCommand } from './commands/playback-command.js';
@@ -61,7 +64,19 @@ async function main(): Promise<void> {
const scriptName = path.basename(scriptPath); const scriptName = path.basename(scriptPath);
const launcherConfig = loadLauncherYoutubeSubgenConfig(); const launcherConfig = loadLauncherYoutubeSubgenConfig();
const launcherMpvConfig = loadLauncherMpvConfig(); const launcherMpvConfig = loadLauncherMpvConfig();
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig, launcherMpvConfig); const launcherLoggingConfig = loadLauncherLoggingConfig();
applyLogFileTogglesToEnv(launcherLoggingConfig.files);
process.env.SUBMINER_LOG_ROTATION =
launcherLoggingConfig.rotation !== undefined
? String(launcherLoggingConfig.rotation)
: (process.env.SUBMINER_LOG_ROTATION ?? '7');
const args = parseArgs(
process.argv.slice(2),
scriptName,
launcherConfig,
launcherMpvConfig,
launcherLoggingConfig,
);
if (args.version) { if (args.version) {
console.log(`SubMiner ${APP_VERSION}`); console.log(`SubMiner ${APP_VERSION}`);
@@ -87,6 +102,10 @@ async function main(): Promise<void> {
return; return;
} }
if (runLogsCommand(context)) {
return;
}
const resolvedAppPath = ensureAppPath(context); const resolvedAppPath = ensureAppPath(context);
state.appPath = resolvedAppPath; state.appPath = resolvedAppPath;
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`); log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
+33
View File
@@ -14,6 +14,7 @@ import {
buildMpvEnv, buildMpvEnv,
cleanupPlaybackSession, cleanupPlaybackSession,
detectBackend, detectBackend,
launchAppBackgroundDetached,
findAppBinary, findAppBinary,
launchAppCommandDetached, launchAppCommandDetached,
launchTexthookerOnly, launchTexthookerOnly,
@@ -256,6 +257,7 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
'--sub-file-paths=.;subs;subtitles', '--sub-file-paths=.;subs;subtitles',
'--sid=auto', '--sid=auto',
'--secondary-sid=auto', '--secondary-sid=auto',
'--sub-visibility=no',
'--secondary-sub-visibility=no', '--secondary-sub-visibility=no',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
@@ -424,6 +426,34 @@ test('launchAppCommandDetached handles child process spawn errors', async () =>
} }
}); });
test('launchAppBackgroundDetached starts background child directly', async () => {
const { dir } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
const argsPath = path.join(dir, 'args.txt');
const envPath = path.join(dir, 'env.txt');
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" > ${JSON.stringify(argsPath)}`,
`printf '%s\\n' "$SUBMINER_BACKGROUND_CHILD" > ${JSON.stringify(envPath)}`,
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
launchAppBackgroundDetached(appPath, 'info');
const deadline = Date.now() + 1000;
while ((!fs.existsSync(argsPath) || !fs.existsSync(envPath)) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.equal(fs.readFileSync(argsPath, 'utf8').trim(), '--start\n--background');
assert.equal(fs.readFileSync(envPath, 'utf8').trim(), '1');
fs.rmSync(dir, { recursive: true, force: true });
});
test('stopOverlay logs a warning when stop command cannot be spawned', () => { test('stopOverlay logs a warning when stop command cannot be spawned', () => {
const originalWrite = process.stdout.write; const originalWrite = process.stdout.write;
const writes: string[] = []; const writes: string[] = [];
@@ -536,6 +566,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
logLevel: 'error', logLevel: 'error',
logRotation: 7,
passwordStore: '', passwordStore: '',
target: '', target: '',
targetKind: '', targetKind: '',
@@ -555,6 +586,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
stats: false, stats: false,
doctor: false, doctor: false,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsExport: false,
version: false, version: false,
settings: false, settings: false,
configPath: false, configPath: false,
@@ -819,6 +851,7 @@ test('startOverlay uses caller config dir for app control socket discovery', asy
const { dir, socketPath } = createTempSocketPath(); const { dir, socketPath } = createTempSocketPath();
const configDir = path.join(dir, 'launcher-config'); const configDir = path.join(dir, 'launcher-config');
const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' }); const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' });
fs.mkdirSync(configDir, { recursive: true });
const appPath = path.join(dir, 'fake-subminer.sh'); const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log'); const appInvocationsPath = path.join(dir, 'app-invocations.log');
const receivedControlArgv: string[][] = []; const receivedControlArgv: string[][] = [];
+59 -14
View File
@@ -4,6 +4,7 @@ import os from 'node:os';
import net from 'node:net'; import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js'; import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js';
import { import {
isAppControlServerAvailable as checkAppControlServerAvailable, isAppControlServerAvailable as checkAppControlServerAvailable,
sendAppControlCommand, sendAppControlCommand,
@@ -14,7 +15,11 @@ import {
type InstalledMpvPluginDetection, type InstalledMpvPluginDetection,
} from '../src/main/runtime/first-run-setup-plugin.js'; } from '../src/main/runtime/first-run-setup-plugin.js';
import type { LogLevel, Backend, Args, MpvTrack, PluginRuntimeConfig } from './types.js'; import type { LogLevel, Backend, Args, MpvTrack, PluginRuntimeConfig } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js'; import {
DEFAULT_MPV_SUBMINER_ARGS,
DEFAULT_YOUTUBE_YTDL_FORMAT,
shouldForwardLogLevel,
} from './types.js';
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js'; import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js'; import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js'; import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
@@ -47,13 +52,17 @@ type SpawnTarget = {
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
}; };
type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve'>; type PathModule = Pick<
typeof path,
'dirname' | 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve' | 'isAbsolute' | 'normalize'
>;
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid'); const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900; const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700; const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC'; const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_'; const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
export interface LauncherRuntimePluginPlan { export interface LauncherRuntimePluginPlan {
scriptPath: string | null; scriptPath: string | null;
@@ -62,6 +71,12 @@ export interface LauncherRuntimePluginPlan {
errorMessage: string | null; errorMessage: string | null;
} }
function resolvePluginCandidatePath(candidate: string, pathModule: PathModule): string {
return pathModule.isAbsolute(candidate)
? pathModule.normalize(candidate)
: pathModule.resolve(candidate);
}
export function parseMpvArgString(input: string): string[] { export function parseMpvArgString(input: string): string[] {
const chars = input; const chars = input;
const args: string[] = []; const args: string[] = [];
@@ -291,12 +306,12 @@ export function resolveLauncherRuntimePluginPath(options: {
pathModule?: typeof path; pathModule?: typeof path;
existsSync?: (candidate: string) => boolean; existsSync?: (candidate: string) => boolean;
}): string | null { }): string | null {
const platform = options.platform ?? process.platform;
const pathModule = options.pathModule ?? path; const pathModule = options.pathModule ?? path;
const existsSync = options.existsSync ?? fs.existsSync; const existsSync = options.existsSync ?? fs.existsSync;
const env = options.env ?? process.env; const env = options.env ?? process.env;
const dirname = options.dirname ?? __dirname; const dirname = options.dirname ?? __dirname;
const cwd = options.cwd ?? process.cwd(); const cwd = options.cwd ?? process.cwd();
const platform = options.platform ?? process.platform;
const homeDir = options.homeDir ?? os.homedir(); const homeDir = options.homeDir ?? os.homedir();
const candidates: string[] = []; const candidates: string[] = [];
@@ -344,7 +359,7 @@ export function resolveLauncherRuntimePluginPath(options: {
const seen = new Set<string>(); const seen = new Set<string>();
for (const candidate of candidates) { for (const candidate of candidates) {
const resolved = pathModule.resolve(candidate); const resolved = resolvePluginCandidatePath(candidate, pathModule);
if (seen.has(resolved)) continue; if (seen.has(resolved)) continue;
seen.add(resolved); seen.add(resolved);
const entrypoint = normalizeRuntimePluginEntrypoint(resolved, { pathModule, existsSync }); const entrypoint = normalizeRuntimePluginEntrypoint(resolved, { pathModule, existsSync });
@@ -941,7 +956,7 @@ export async function startMpv(
); );
} }
mpvArgs.push(`--script-opts=${scriptOpts}`); mpvArgs.push(`--script-opts=${scriptOpts}`);
mpvArgs.push(`--log-file=${getMpvLogPath()}`); mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
try { try {
fs.rmSync(socketPath, { force: true }); fs.rmSync(socketPath, { force: true });
@@ -1021,7 +1036,7 @@ export async function startOverlay(
socketPath, socketPath,
...extraAppArgs, ...extraAppArgs,
]; ];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) overlayArgs.push('--log-level', args.logLevel);
if (args.useTexthooker) overlayArgs.push('--texthooker'); if (args.useTexthooker) overlayArgs.push('--texthooker');
const controlResult = await sendAppControlCommand(overlayArgs, { const controlResult = await sendAppControlCommand(overlayArgs, {
@@ -1166,7 +1181,7 @@ export function launchTexthookerOnly(
): never { ): never {
const overlayArgs = ['--texthooker']; const overlayArgs = ['--texthooker'];
if (args.texthookerOpenBrowser) overlayArgs.push('--open-browser'); if (args.texthookerOpenBrowser) overlayArgs.push('--open-browser');
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) overlayArgs.push('--log-level', args.logLevel);
log('info', args.logLevel, 'Launching texthooker mode...'); log('info', args.logLevel, 'Launching texthooker mode...');
const result = runSyncAppCommand(appPath, overlayArgs, true); const result = runSyncAppCommand(appPath, overlayArgs, true);
@@ -1244,7 +1259,7 @@ function stopManagedOverlayApp(args: Args): void {
log('info', args.logLevel, 'Stopping SubMiner overlay...'); log('info', args.logLevel, 'Stopping SubMiner overlay...');
const stopArgs = ['--stop']; const stopArgs = ['--stop'];
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) stopArgs.push('--log-level', args.logLevel);
const target = resolveAppSpawnTarget(state.appPath, stopArgs); const target = resolveAppSpawnTarget(state.appPath, stopArgs);
const result = spawnSync(target.command, target.args, { const result = spawnSync(target.command, target.args, {
@@ -1296,6 +1311,8 @@ function buildAppEnv(
...baseEnv, ...baseEnv,
SUBMINER_APP_LOG: getAppLogPath(), SUBMINER_APP_LOG: getAppLogPath(),
SUBMINER_MPV_LOG: getMpvLogPath(), SUBMINER_MPV_LOG: getMpvLogPath(),
SUBMINER_LOG_LEVEL: extraEnv.SUBMINER_LOG_LEVEL ?? baseEnv.SUBMINER_LOG_LEVEL,
SUBMINER_LOG_ROTATION: extraEnv.SUBMINER_LOG_ROTATION ?? baseEnv.SUBMINER_LOG_ROTATION,
}; };
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RUN_AS_NODE;
clearTransportedAppArgs(env); clearTransportedAppArgs(env);
@@ -1316,10 +1333,13 @@ function buildAppEnv(
} }
export function buildMpvEnv( export function buildMpvEnv(
args: Pick<Args, 'backend'>, args: Pick<Args, 'backend' | 'logLevel' | 'logRotation'>,
baseEnv: NodeJS.ProcessEnv = process.env, baseEnv: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv { ): NodeJS.ProcessEnv {
const env = buildAppEnv(baseEnv); const env = buildAppEnv(baseEnv, {
SUBMINER_LOG_LEVEL: args.logLevel,
SUBMINER_LOG_ROTATION: String(args.logRotation),
});
if (!shouldForceX11MpvBackend(args, env)) { if (!shouldForceX11MpvBackend(args, env)) {
return env; return env;
} }
@@ -1576,15 +1596,24 @@ export function runAppCommandWithInheritLogged(
export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void { export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void {
const startArgs = ['--start']; const startArgs = ['--start'];
if (logLevel !== 'info') startArgs.push('--log-level', logLevel); if (shouldForwardLogLevel(logLevel)) startArgs.push('--log-level', logLevel);
launchAppCommandDetached(appPath, startArgs, logLevel, 'start'); launchAppCommandDetached(appPath, startArgs, logLevel, 'start');
} }
export function launchAppBackgroundDetached(appPath: string, logLevel: LogLevel): void {
const startArgs = ['--start', '--background'];
if (shouldForwardLogLevel(logLevel)) startArgs.push('--log-level', logLevel);
launchAppCommandDetached(appPath, startArgs, logLevel, 'app', {
[BACKGROUND_CHILD_ENV]: '1',
});
}
export function launchAppCommandDetached( export function launchAppCommandDetached(
appPath: string, appPath: string,
appArgs: string[], appArgs: string[],
logLevel: LogLevel, logLevel: LogLevel,
label: string, label: string,
extraEnv: NodeJS.ProcessEnv = {},
): void { ): void {
if (maybeCaptureAppArgs(appArgs)) { if (maybeCaptureAppArgs(appArgs)) {
return; return;
@@ -1596,6 +1625,22 @@ export function launchAppCommandDetached(
`${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`, `${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`,
); );
const appLogPath = getAppLogPath(); const appLogPath = getAppLogPath();
if (!appLogPath) {
try {
const proc = spawn(target.command, target.args, {
stdio: 'ignore',
detached: true,
env: buildAppEnv(process.env, { ...target.env, ...extraEnv }),
});
proc.once('error', (error) => {
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
});
proc.unref();
} catch (error) {
log('warn', logLevel, `${label}: failed to launch detached app: ${(error as Error).message}`);
}
return;
}
fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); fs.mkdirSync(path.dirname(appLogPath), { recursive: true });
const stdoutFd = fs.openSync(appLogPath, 'a'); const stdoutFd = fs.openSync(appLogPath, 'a');
const stderrFd = fs.openSync(appLogPath, 'a'); const stderrFd = fs.openSync(appLogPath, 'a');
@@ -1603,7 +1648,7 @@ export function launchAppCommandDetached(
const proc = spawn(target.command, target.args, { const proc = spawn(target.command, target.args, {
stdio: ['ignore', stdoutFd, stderrFd], stdio: ['ignore', stdoutFd, stderrFd],
detached: true, detached: true,
env: buildAppEnv(process.env, target.env), env: buildAppEnv(process.env, { ...target.env, ...extraEnv }),
}); });
proc.once('error', (error) => { proc.once('error', (error) => {
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`); log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
@@ -1654,7 +1699,7 @@ export function launchMpvIdleDetached(
runtimeScriptOpts, runtimeScriptOpts,
)}`, )}`,
); );
mpvArgs.push(`--log-file=${getMpvLogPath()}`); mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
mpvArgs.push(`--input-ipc-server=${socketPath}`); mpvArgs.push(`--input-ipc-server=${socketPath}`);
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, { const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, {
normalizeWindowsShellArgs: false, normalizeWindowsShellArgs: false,
@@ -1704,7 +1749,7 @@ export async function waitForUnixSocketReady(
const deadline = nowMs() + timeoutMs; const deadline = nowMs() + timeoutMs;
while (nowMs() < deadline) { while (nowMs() < deadline) {
try { try {
if (fs.existsSync(socketPath)) { if (process.platform === 'win32' || fs.existsSync(socketPath)) {
const ready = await canConnectUnixSocket(socketPath); const ready = await canConnectUnixSocket(socketPath);
if (ready) return true; if (ready) return true;
} }
+15
View File
@@ -244,3 +244,18 @@ test('parseArgs maps doctor refresh-known-words flag', () => {
assert.equal(parsed.doctor, true); assert.equal(parsed.doctor, true);
assert.equal(parsed.doctorRefreshKnownWords, true); assert.equal(parsed.doctorRefreshKnownWords, true);
}); });
test('parseArgs maps logs export flag', () => {
const parsed = parseArgs(['logs', '-e'], 'subminer', {});
assert.equal(parsed.logsExport, true);
});
test('parseArgs requires an explicit logs action', () => {
const exit = withProcessExitIntercept(() => {
parseArgs(['logs'], 'subminer', {});
});
assert.equal(exit.code, 1);
assert.match(exit.stderr, /Logs command requires -e or --export/);
});
+2 -2
View File
@@ -365,8 +365,8 @@ export function findRofiTheme(scriptPath: string): string | null {
} else { } else {
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share'); const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share');
candidates.push(path.join(xdgDataHome, 'SubMiner/themes', ROFI_THEME_FILE)); candidates.push(path.join(xdgDataHome, 'SubMiner/themes', ROFI_THEME_FILE));
candidates.push(path.join('/usr/local/share/SubMiner/themes', ROFI_THEME_FILE)); candidates.push(path.posix.join('/usr/local/share/SubMiner/themes', ROFI_THEME_FILE));
candidates.push(path.join('/usr/share/SubMiner/themes', ROFI_THEME_FILE)); candidates.push(path.posix.join('/usr/share/SubMiner/themes', ROFI_THEME_FILE));
} }
candidates.push(path.join(scriptDir, 'assets', 'themes', ROFI_THEME_FILE)); candidates.push(path.join(scriptDir, 'assets', 'themes', ROFI_THEME_FILE));
+65 -11
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
@@ -23,6 +24,8 @@ type SmokeCase = {
artifactsDir: string; artifactsDir: string;
binDir: string; binDir: string;
xdgConfigHome: string; xdgConfigHome: string;
appDataDir: string;
localAppDataDir: string;
homeDir: string; homeDir: string;
socketDir: string; socketDir: string;
socketPath: string; socketPath: string;
@@ -40,6 +43,19 @@ function writeExecutable(filePath: string, body: string): void {
fs.chmodSync(filePath, 0o755); fs.chmodSync(filePath, 0o755);
} }
function writeFixtureExecutable(basePath: string, body: string): string {
if (process.platform !== 'win32') {
writeExecutable(basePath, body);
return basePath;
}
const scriptPath = `${basePath}.js`;
const commandPath = `${basePath}.cmd`;
fs.writeFileSync(scriptPath, body);
fs.writeFileSync(commandPath, `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`);
return commandPath;
}
function createSmokeCase(name: string): SmokeCase { function createSmokeCase(name: string): SmokeCase {
const baseDir = path.join(process.cwd(), '.tmp', 'launcher-smoke'); const baseDir = path.join(process.cwd(), '.tmp', 'launcher-smoke');
fs.mkdirSync(baseDir, { recursive: true }); fs.mkdirSync(baseDir, { recursive: true });
@@ -48,19 +64,21 @@ function createSmokeCase(name: string): SmokeCase {
const artifactsDir = path.join(root, 'artifacts'); const artifactsDir = path.join(root, 'artifacts');
const binDir = path.join(root, 'bin'); const binDir = path.join(root, 'bin');
const xdgConfigHome = path.join(root, 'xdg'); const xdgConfigHome = path.join(root, 'xdg');
const appDataDir = path.join(root, 'AppData', 'Roaming');
const localAppDataDir = path.join(root, 'AppData', 'Local');
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');
const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-smoke-sock-')); const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-smoke-sock-'));
const socketPath = path.join(socketDir, 'subminer.sock'); const socketPath = path.join(socketDir, 'subminer.sock');
const videoPath = path.join(root, 'video.mkv'); const videoPath = path.join(root, 'video.mkv');
const fakeAppPath = path.join(binDir, 'fake-subminer'); const fakeAppBasePath = path.join(binDir, 'fake-subminer');
const fakeMpvPath = path.join(binDir, 'mpv'); const fakeMpvBasePath = path.join(binDir, 'mpv');
const mpvOverlayLogPath = path.join(artifactsDir, 'mpv-overlay.log'); const mpvOverlayLogPath = path.join(artifactsDir, 'mpv-overlay.log');
fs.mkdirSync(artifactsDir, { recursive: true }); fs.mkdirSync(artifactsDir, { recursive: true });
fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(videoPath, 'fake video fixture'); fs.writeFileSync(videoPath, 'fake video fixture');
const configDir = getDefaultConfigDir({ xdgConfigHome, homeDir }); const configDir = getDefaultConfigDir({ xdgConfigHome, appDataDir, homeDir });
fs.mkdirSync(configDir, { recursive: true }); fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), JSON.stringify({ mpv: { socketPath } })); fs.writeFileSync(path.join(configDir, 'config.jsonc'), JSON.stringify({ mpv: { socketPath } }));
const setupState = createDefaultSetupState(); const setupState = createDefaultSetupState();
@@ -74,8 +92,8 @@ function createSmokeCase(name: string): SmokeCase {
const fakeAppStartLogPath = path.join(artifactsDir, 'fake-app-start.log'); const fakeAppStartLogPath = path.join(artifactsDir, 'fake-app-start.log');
const fakeAppStopLogPath = path.join(artifactsDir, 'fake-app-stop.log'); const fakeAppStopLogPath = path.join(artifactsDir, 'fake-app-stop.log');
writeExecutable( const fakeMpvPath = writeFixtureExecutable(
fakeMpvPath, fakeMpvBasePath,
`#!/usr/bin/env bun `#!/usr/bin/env bun
const fs = require('node:fs'); const fs = require('node:fs');
const net = require('node:net'); const net = require('node:net');
@@ -113,8 +131,8 @@ process.on('SIGTERM', closeAndExit);
`, `,
); );
writeExecutable( const fakeAppPath = writeFixtureExecutable(
fakeAppPath, fakeAppBasePath,
`#!/usr/bin/env bun `#!/usr/bin/env bun
const fs = require('node:fs'); const fs = require('node:fs');
@@ -146,6 +164,8 @@ process.exit(0);
artifactsDir, artifactsDir,
binDir, binDir,
xdgConfigHome, xdgConfigHome,
appDataDir,
localAppDataDir,
homeDir, homeDir,
socketDir, socketDir,
socketPath, socketPath,
@@ -157,14 +177,23 @@ process.exit(0);
} }
function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv { function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv {
return { const env: NodeJS.ProcessEnv = {
...process.env, ...process.env,
HOME: smokeCase.homeDir, HOME: smokeCase.homeDir,
XDG_CONFIG_HOME: smokeCase.xdgConfigHome, XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
APPDATA: smokeCase.appDataDir,
LOCALAPPDATA: smokeCase.localAppDataDir,
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath, SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
SUBMINER_MPV_LOG: smokeCase.mpvOverlayLogPath, SUBMINER_MPV_LOG: smokeCase.mpvOverlayLogPath,
PATH: `${smokeCase.binDir}${path.delimiter}${process.env.PATH || ''}`,
}; };
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'PATH';
env[pathKey] = `${smokeCase.binDir}${path.delimiter}${env[pathKey] || ''}`;
for (const key of Object.keys(env)) {
if (key !== pathKey && key.toLowerCase() === 'path') {
delete env[key];
}
}
return env;
} }
function runLauncher( function runLauncher(
@@ -247,6 +276,31 @@ async function waitForFile(filePath: string, timeoutMs = 1500): Promise<void> {
throw new Error(`Timed out waiting for file ${filePath} after ${timeoutMs}ms`); throw new Error(`Timed out waiting for file ${filePath} after ${timeoutMs}ms`);
} }
async function waitForSocketReady(socketPath: string, timeoutMs = 1500): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!fs.existsSync(socketPath)) {
await new Promise<void>((resolve) => setTimeout(resolve, 50));
continue;
}
const ready = await new Promise<boolean>((resolve) => {
const socket = new net.Socket();
socket.once('connect', () => {
socket.end();
resolve(true);
});
socket.once('error', () => {
socket.destroy();
resolve(false);
});
socket.connect(socketPath);
});
if (ready) return true;
await new Promise<void>((resolve) => setTimeout(resolve, 50));
}
return false;
}
async function startFakeControlServer( async function startFakeControlServer(
smokeCase: SmokeCase, smokeCase: SmokeCase,
): Promise<{ socketPath: string; logPath: string; stop: () => Promise<void> }> { ): Promise<{ socketPath: string; logPath: string; stop: () => Promise<void> }> {
@@ -351,7 +405,7 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
}); });
try { try {
await new Promise<void>((resolve) => setTimeout(resolve, 120)); await waitForSocketReady(smokeCase.socketPath);
const result = runLauncher( const result = runLauncher(
smokeCase, smokeCase,
['mpv', 'status', '--log-level', 'debug'], ['mpv', 'status', '--log-level', 'debug'],
@@ -390,7 +444,7 @@ test(
const env = makeTestEnv(smokeCase); const env = makeTestEnv(smokeCase);
const result = runLauncher( const result = runLauncher(
smokeCase, smokeCase,
['--backend', 'x11', '--start-overlay', smokeCase.videoPath], ['--backend', 'x11', '--log-level', 'info', '--start-overlay', smokeCase.videoPath],
env, env,
'overlay-start-stop', 'overlay-start-stop',
); );
+18 -1
View File
@@ -1,7 +1,11 @@
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js'; import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js';
import { resolveDefaultLogFilePath } from '../src/shared/log-files.js'; import {
resolveDefaultLogFilePath,
type LogFileToggles,
type LogRotation,
} from '../src/shared/log-files.js';
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js'; export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
export const ROFI_THEME_FILE = 'subminer.rasi'; export const ROFI_THEME_FILE = 'subminer.rasi';
@@ -60,12 +64,16 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [
'--sub-file-paths=.;subs;subtitles', '--sub-file-paths=.;subs;subtitles',
'--sid=auto', '--sid=auto',
'--secondary-sid=auto', '--secondary-sid=auto',
'--sub-visibility=no',
'--secondary-sub-visibility=no', '--secondary-sub-visibility=no',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
] as const; ] as const;
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export function shouldForwardLogLevel(level: LogLevel): boolean {
return level === 'debug' || level === 'error';
}
export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows'; export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows';
export type JimakuLanguagePreference = 'ja' | 'en' | 'none'; export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
@@ -105,6 +113,7 @@ export interface Args {
texthookerOpenBrowser: boolean; texthookerOpenBrowser: boolean;
useRofi: boolean; useRofi: boolean;
logLevel: LogLevel; logLevel: LogLevel;
logRotation: LogRotation;
passwordStore: string; passwordStore: string;
target: string; target: string;
targetKind: '' | 'file' | 'url'; targetKind: '' | 'file' | 'url';
@@ -131,6 +140,7 @@ export interface Args {
dictionaryTarget?: string; dictionaryTarget?: string;
doctor: boolean; doctor: boolean;
doctorRefreshKnownWords: boolean; doctorRefreshKnownWords: boolean;
logsExport: boolean;
version: boolean; version: boolean;
update?: boolean; update?: boolean;
settings: boolean; settings: boolean;
@@ -185,10 +195,17 @@ export interface LauncherMpvConfig {
aniskipButtonKey?: string; aniskipButtonKey?: string;
} }
export interface LauncherLoggingConfig {
level?: LogLevel;
rotation?: LogRotation;
files?: Partial<LogFileToggles>;
}
export interface PluginRuntimeConfig { export interface PluginRuntimeConfig {
socketPath: string; socketPath: string;
binaryPath: string; binaryPath: string;
backend: Backend; backend: Backend;
logLevel?: LogLevel;
autoStart: boolean; autoStart: boolean;
autoStartVisibleOverlay: boolean; autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean; autoStartPauseUntilReady: boolean;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "subminer", "name": "subminer",
"version": "0.15.0-beta.3", "version": "0.15.0-beta.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "subminer", "name": "subminer",
"version": "0.15.0-beta.3", "version": "0.15.0-beta.8",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
+9 -3
View File
@@ -2,7 +2,7 @@
"name": "subminer", "name": "subminer",
"productName": "SubMiner", "productName": "SubMiner",
"desktopName": "SubMiner.desktop", "desktopName": "SubMiner.desktop",
"version": "0.15.0-beta.4", "version": "0.15.0-beta.10",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",
@@ -50,8 +50,8 @@
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/main/runtime/character-dictionary-manager-gate.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/log-export.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/main/character-dictionary-runtime/term-building.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
"test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/stats-window-lifecycle.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/main/runtime/log-export.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
@@ -158,6 +158,7 @@
] ]
}, },
"mac": { "mac": {
"artifactName": "SubMiner-${version}-mac.${ext}",
"target": [ "target": [
"dmg", "dmg",
"zip" "zip"
@@ -174,7 +175,11 @@
} }
] ]
}, },
"dmg": {
"artifactName": "SubMiner-${version}.${ext}"
},
"win": { "win": {
"artifactName": "SubMiner-${version}-win.${ext}",
"target": [ "target": [
"nsis", "nsis",
"zip" "zip"
@@ -182,6 +187,7 @@
"icon": "assets/SubMiner.ico" "icon": "assets/SubMiner.ico"
}, },
"nsis": { "nsis": {
"artifactName": "SubMiner-${version}.${ext}",
"oneClick": false, "oneClick": false,
"perMachine": false, "perMachine": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
+7
View File
@@ -114,6 +114,13 @@ function M.create(ctx)
end end
end end
if not environment.is_windows() then
local appimage_path = resolve_binary_candidate(os.getenv("APPIMAGE"))
if appimage_path and appimage_path ~= "" then
return appimage_path
end
end
return nil return nil
end end
+10
View File
@@ -13,6 +13,16 @@ function M.create(ctx)
local APP_RUNNING_CACHE_TTL_SECONDS = 2 local APP_RUNNING_CACHE_TTL_SECONDS = 2
local function is_windows() local function is_windows()
local platform = mp.get_property("platform") or ""
if platform ~= "" then
local normalized = platform:lower()
if normalized == "windows" or normalized == "win32" then
return true
end
if normalized == "macos" or normalized == "darwin" or normalized == "osx" or normalized == "linux" then
return false
end
end
return package.config:sub(1, 1) == "\\" return package.config:sub(1, 1) == "\\"
end end
+75 -2
View File
@@ -33,6 +33,20 @@ function M.create(ctx)
return nil return nil
end end
local function resolve_media_title()
local media_title = mp.get_property("media-title")
if type(media_title) == "string" and media_title ~= "" then
return media_title
end
local filename = mp.get_property("filename")
if type(filename) == "string" and filename ~= "" then
return filename
end
return nil
end
local function is_reload_end_file(reason) local function is_reload_end_file(reason)
return reason == "reload" or reason == "redirect" return reason == "reload" or reason == "redirect"
end end
@@ -71,6 +85,10 @@ function M.create(ctx)
if not has_matching_subminer_socket() then if not has_matching_subminer_socket() then
return false return false
end end
if state.skip_managed_subtitle_rearm_once then
state.skip_managed_subtitle_rearm_once = false
return true
end
mp.set_property_native("sub-auto", "fuzzy") mp.set_property_native("sub-auto", "fuzzy")
mp.set_property_native("sid", "auto") mp.set_property_native("sid", "auto")
mp.set_property_native("secondary-sid", "auto") mp.set_property_native("secondary-sid", "auto")
@@ -108,7 +126,9 @@ function M.create(ctx)
subminer_log( subminer_log(
"info", "info",
"lifecycle", "lifecycle",
"Skipping auto-start: input-ipc-server does not match configured socket_path" "Skipping auto-start: input-ipc-server does not match configured socket_path ("
.. process.describe_mpv_ipc_socket_match(opts.socket_path)
.. ")"
) )
schedule_aniskip_fetch("file-loaded", 0) schedule_aniskip_fetch("file-loaded", 0)
return return
@@ -125,6 +145,10 @@ function M.create(ctx)
local function on_start_file() local function on_start_file()
if state.pending_reload_media_identity ~= nil then if state.pending_reload_media_identity ~= nil then
local media_identity = resolve_media_identity()
if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then
rearm_managed_subtitle_load_defaults()
end
return return
end end
rearm_managed_subtitle_load_defaults() rearm_managed_subtitle_load_defaults()
@@ -132,24 +156,56 @@ function M.create(ctx)
local function on_file_loaded() local function on_file_loaded()
local media_identity = resolve_media_identity() local media_identity = resolve_media_identity()
local media_title = resolve_media_title()
local retry_generation = next_auto_start_retry_generation() local retry_generation = next_auto_start_retry_generation()
local previous_media_identity = state.current_media_identity local previous_media_identity = state.current_media_identity
local pending_reload_title = state.pending_reload_media_title
local pending_reload_reason = state.pending_reload_reason
local same_media_reload = ( local same_media_reload = (
media_identity ~= nil media_identity ~= nil
and state.pending_reload_media_identity ~= nil and state.pending_reload_media_identity ~= nil
and media_identity == state.pending_reload_media_identity and media_identity == state.pending_reload_media_identity
) or (
state.pending_reload_media_identity ~= nil
and media_title ~= nil
and pending_reload_title ~= nil
and media_title == pending_reload_title
) or (
pending_reload_reason == "redirect"
and state.pending_reload_media_identity ~= nil
) )
local same_media_loaded = ( local same_media_loaded = (
media_identity ~= nil media_identity ~= nil
and previous_media_identity ~= nil and previous_media_identity ~= nil
and media_identity == previous_media_identity and media_identity == previous_media_identity
) )
local new_media_loaded = media_identity ~= nil and not same_media_reload and not same_media_loaded
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
state.pending_reload_media_title = nil
state.pending_reload_reason = nil
state.current_media_identity = media_identity state.current_media_identity = media_identity
state.current_media_title = media_title
if state.app_managed_playback_pending then
state.app_managed_playback_pending = false
state.app_managed_playback_active = true
elseif new_media_loaded then
state.app_managed_playback_active = false
end
if new_media_loaded then
state.suppress_ready_overlay_restore = false
end
if same_media_reload then if same_media_reload then
subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload") subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload")
if state.overlay_running and resolve_auto_start_enabled() and process.has_matching_mpv_ipc_socket(opts.socket_path) then if state.app_managed_playback_active then
return
end
if
state.overlay_running
and not state.suppress_ready_overlay_restore
and resolve_auto_start_enabled()
and process.has_matching_mpv_ipc_socket(opts.socket_path)
then
process.run_control_command_async("show-visible-overlay", { process.run_control_command_async("show-visible-overlay", {
socket_path = opts.socket_path, socket_path = opts.socket_path,
}) })
@@ -167,6 +223,11 @@ function M.create(ctx)
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
end end
if state.app_managed_playback_active then
subminer_log("debug", "lifecycle", "Skipping plugin auto-start for app-managed subtitle preload")
return
end
if should_auto_start then if should_auto_start then
start_overlay_when_socket_ready(retry_generation, media_identity, same_media_loaded, 1) start_overlay_when_socket_ready(retry_generation, media_identity, same_media_loaded, 1)
return return
@@ -182,7 +243,12 @@ function M.create(ctx)
hover.clear_hover_overlay() hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
state.current_media_identity = nil state.current_media_identity = nil
state.current_media_title = nil
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
state.pending_reload_media_title = nil
state.pending_reload_reason = nil
state.app_managed_playback_pending = false
state.app_managed_playback_active = false
end end
local function register_lifecycle_hooks() local function register_lifecycle_hooks()
@@ -198,11 +264,18 @@ function M.create(ctx)
local reason = type(event) == "table" and event.reason or nil local reason = type(event) == "table" and event.reason or nil
if is_reload_end_file(reason) then if is_reload_end_file(reason) then
state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity() state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity()
state.pending_reload_media_title = state.current_media_title or resolve_media_title()
state.pending_reload_reason = reason
return return
end end
next_auto_start_retry_generation() next_auto_start_retry_generation()
state.current_media_identity = nil state.current_media_identity = nil
state.current_media_title = nil
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
state.pending_reload_media_title = nil
state.pending_reload_reason = nil
state.app_managed_playback_pending = false
state.app_managed_playback_active = false
if state.overlay_running and reason ~= "quit" then if state.overlay_running and reason ~= "quit" then
process.hide_visible_overlay() process.hide_visible_overlay()
end end

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