Compare commits

..

38 Commits

Author SHA1 Message Date
536db5ff85 update docs 2026-02-28 14:31:43 -08:00
39288a62b6 fix nplusone min sentence count 2026-02-28 14:31:43 -08:00
93e392910c feat: source frequency ranks from installed Yomitan dictionaries 2026-02-28 14:31:43 -08:00
185528aee6 feat(renderer): show JLPT level on token hover 2026-02-28 14:31:43 -08:00
870acb45d5 fix(anki): avoid unintended kiku grouping on lookup 2026-02-28 14:31:43 -08:00
40787e8b71 fix(plugin): gate auto-start overlay by matching mpv IPC socket 2026-02-28 14:31:43 -08:00
98fd2a731e fix(anki): harden proxy auto-enrichment and docs 2026-02-28 14:31:43 -08:00
de8c15fd56 fix(plugin): make auto-start visible overlay flag deterministic 2026-02-28 14:31:43 -08:00
370274e78a update docs 2026-02-28 14:31:43 -08:00
9e0c5e478e fix: simplify mpv sub-visibility suppression and gate yomitan sync state 2026-02-28 14:31:43 -08:00
3f1702b0f6 fix(plugin): add auto-start option compatibility aliases 2026-02-28 14:31:43 -08:00
66c24767fb fix: re-enable modal input capture on active overlay window 2026-02-28 14:31:43 -08:00
f8e961d105 feat(anki): add proxy transport and tokenizer annotation controls 2026-02-28 14:31:43 -08:00
34a0feae71 refactor(plugin): split mpv plugin into modules and trim startup overhead 2026-02-28 14:31:43 -08:00
db5e3f9e50 fix(runtime): avoid loading disabled integrations 2026-02-28 14:31:43 -08:00
30a76d7767 fix: lazy initialize immersion tracker 2026-02-28 14:31:43 -08:00
1e645f961b feat: make startup warmups configurable with low-power mode 2026-02-28 14:31:43 -08:00
3a1d746a2e fix(plugin): gate aniskip lookups to subminer contexts 2026-02-28 14:31:43 -08:00
17fa10ba36 fix(startup): replace fixed overlay sleeps with readiness retries 2026-02-28 14:31:43 -08:00
d6c4a85a3b fix(macos): show full config warning details 2026-02-28 14:31:43 -08:00
19c7448f26 fix: always hide mpv primary subtitles for visible overlay 2026-02-28 14:31:43 -08:00
b212986682 fix(overlay): honor mpv subtitle binding config and tidy modal close 2026-02-28 14:31:43 -08:00
d07b0aa957 fix(overlay): tolerate minimal webContents in bridge send path 2026-02-28 14:31:43 -08:00
603af36a48 small fixes 2026-02-28 14:31:43 -08:00
5ef3396205 change default plugin options
enable auto_start and auto_start_visible_overlay
2026-02-28 14:31:43 -08:00
721036342d fix(plugin): honor auto-start and retry visible overlay startup action 2026-02-28 14:31:43 -08:00
c7c91077fd feat(config): refresh subtitle style defaults and drop plugin legacy startup alias 2026-02-28 14:31:43 -08:00
771ea5777f fix: address claude review action items 2026-02-28 14:31:43 -08:00
151752b17a fix: suppress mpv primary subtitles when visible overlay is enabled 2026-02-28 14:31:43 -08:00
62f53071ec fix: type annotate overlay runtime test mock callbacks 2026-02-28 14:31:43 -08:00
337e3268f1 fix: address claude review feedback on overlay refactor 2026-02-28 14:31:43 -08:00
fa0cb00f70 feat: bind overlay state to secondary subtitle mpv visibility 2026-02-28 14:31:43 -08:00
a33a87bf8f refactor: remove invisible subtitle overlay code 2026-02-28 14:31:43 -08:00
3c2c8453be test(main): add overlay modal runtime fallback open-state regression coverage 2026-02-28 14:31:43 -08:00
3c5ba3a3d3 fix(main): restore modal pointer events after fallback reveal when open confirmed 2026-02-28 14:31:42 -08:00
1ae46cd4ba fix(renderer): calibrate macOS invisible overlay spacing 2026-02-28 14:31:42 -08:00
1e2b43a7dc fix(renderer): calibrate invisible overlay metrics and hover mapping 2026-02-28 14:31:42 -08:00
0de278f3ab fix(renderer): tighten macOS invisible overlay multiline line height 2026-02-28 14:31:42 -08:00
251 changed files with 1608 additions and 6236 deletions

View File

@@ -242,7 +242,7 @@ jobs:
run: | run: |
tar -czf "release/subminer-assets.tar.gz" \ tar -czf "release/subminer-assets.tar.gz" \
config.example.jsonc \ config.example.jsonc \
plugin/subminer \ plugin/subminer.lua \
plugin/subminer.conf \ plugin/subminer.conf \
assets/themes/subminer.rasi assets/themes/subminer.rasi
@@ -304,7 +304,7 @@ jobs:
### Optional Assets (config example + mpv plugin + rofi theme) ### Optional Assets (config example + mpv plugin + rofi theme)
1. Download `subminer-assets.tar.gz` 1. Download `subminer-assets.tar.gz`
2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc` 2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc`
3. Copy `plugin/subminer/` directory contents to `~/.config/mpv/scripts/` 3. Copy `plugin/subminer.lua` to `~/.config/mpv/scripts/`
4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/` 4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/`
5. Copy `assets/themes/subminer.rasi` to: 5. Copy `assets/themes/subminer.rasi` to:
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi` - Linux: `~/.local/share/SubMiner/themes/subminer.rasi`

View File

@@ -1,3 +1,4 @@
<!-- BACKLOG.MD MCP GUIDELINES START --> <!-- BACKLOG.MD MCP GUIDELINES START -->
<CRITICAL_INSTRUCTION> <CRITICAL_INSTRUCTION>
@@ -16,7 +17,6 @@ This project uses Backlog.md MCP for all task and project management activities.
- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work - **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work
These guides cover: These guides cover:
- Decision framework for when to create tasks - Decision framework for when to create tasks
- Search-first workflow to avoid duplicates - Search-first workflow to avoid duplicates
- Links to detailed guides for task creation, execution, and finalization - Links to detailed guides for task creation, execution, and finalization

View File

@@ -1,4 +1,4 @@
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config docs-dev docs docs-preview docs-watch dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop .PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config docs-dev docs docs-preview dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
APP_NAME := subminer APP_NAME := subminer
THEME_SOURCE := assets/themes/subminer.rasi THEME_SOURCE := assets/themes/subminer.rasi
@@ -57,7 +57,6 @@ help:
" dev-toggle Toggle overlay in a running local Electron app" \ " dev-toggle Toggle overlay in a running local Electron app" \
" dev-stop Stop a running local Electron app" \ " dev-stop Stop a running local Electron app" \
" docs-dev Run VitePress docs dev server" \ " docs-dev Run VitePress docs dev server" \
" docs-watch Run VitePress docs dev + Backlog browser together" \
" docs Build VitePress static docs" \ " docs Build VitePress static docs" \
" docs-preview Preview built VitePress docs" \ " docs-preview Preview built VitePress docs" \
" install-linux Install Linux wrapper/theme/app artifacts" \ " install-linux Install Linux wrapper/theme/app artifacts" \
@@ -161,9 +160,6 @@ generate-example-config: ensure-bun
docs-dev: ensure-bun docs-dev: ensure-bun
@bun run docs:dev @bun run docs:dev
docs-watch: ensure-bun
@bun run docs:watch
docs: ensure-bun docs: ensure-bun
@bun run docs:build @bun run docs:build

View File

@@ -14,7 +14,7 @@
<div align="center"> <div align="center">
[![SubMiner demo (Animated preview)](./assets/minecard.webp)](./assets/minecard.mp4) [![SubMiner demo (GIF preview)](./assets/minecard.gif)](./assets/minecard.mp4)
</div> </div>
@@ -28,7 +28,6 @@ SubMiner is an Electron overlay that sits on top of mpv. It turns your video pla
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation - **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
- **Instant auto-enrichment** — Optional local AnkiConnect proxy enriches new Yomitan cards immediately - **Instant auto-enrichment** — Optional local AnkiConnect proxy enriches new Yomitan cards immediately
- **Reading annotations** — Combines N+1 targeting, frequency-dictionary highlighting, and JLPT underlining while you read - **Reading annotations** — Combines N+1 targeting, frequency-dictionary highlighting, and JLPT underlining while you read
- **Hover-aware playback** — By default, hovering subtitle text pauses mpv and resumes on mouse leave (`subtitleStyle.autoPauseVideoOnHover`)
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync - **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
- **Immersion tracking** — SQLite-powered stats on your watch time and mining activity - **Immersion tracking** — SQLite-powered stats on your watch time and mining activity
- **Custom texthooker page** — Built-in custom texthooker page and websocket, no extra setup - **Custom texthooker page** — Built-in custom texthooker page and websocket, no extra setup
@@ -66,18 +65,18 @@ cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
``` ```
### 3. Set up Yomitan Dictionaries ### 3. Set up Yomitan Dictionaries
```bash ```bash
subminer app --yomitan subminer app --start --yomitan
``` ```
### 4. Mine ### 4. Mine
```bash ```bash
subminer app --start --background subminer app --start --background
subminer video.mkv # default plugin config auto-starts visible overlay + resumes playback when ready subminer video.mkv # y-t toggles overlay visibility
subminer --start video.mkv # optional explicit overlay start when plugin auto_start=no
``` ```
## Requirements ## Requirements

BIN
assets/kiku-integration.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 MiB

After

Width:  |  Height:  |  Size: 12 MiB

BIN
assets/minecard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 MiB

View File

@@ -1,11 +1,11 @@
project_name: 'SubMiner' project_name: "SubMiner"
default_status: 'To Do' default_status: "To Do"
statuses: ['To Do', 'In Progress', 'Done'] statuses: ["To Do", "In Progress", "Done"]
labels: [] labels: []
definition_of_done: [] definition_of_done: []
date_format: yyyy-mm-dd date_format: yyyy-mm-dd
max_column_width: 20 max_column_width: 20
default_editor: 'nvim' default_editor: "nvim"
auto_open_browser: false auto_open_browser: false
default_port: 6420 default_port: 6420
remote_operations: true remote_operations: true
@@ -13,4 +13,4 @@ auto_commit: false
bypass_git_hooks: false bypass_git_hooks: false
check_active_branches: true check_active_branches: true
active_branch_days: 30 active_branch_days: 30
task_prefix: 'task' task_prefix: "task"

View File

@@ -6,7 +6,6 @@ title: >-
status: Done status: Done
assignee: [] assignee: []
created_date: '2026-02-28 02:38' created_date: '2026-02-28 02:38'
updated_date: '2026-02-28 22:36'
labels: [] labels: []
dependencies: [] dependencies: []
references: references:
@@ -20,24 +19,20 @@ references:
- src/renderer/renderer.ts - src/renderer/renderer.ts
- docs/plans/2026-02-26-secondary-subtitles-main-overlay.md - docs/plans/2026-02-26-secondary-subtitles-main-overlay.md
priority: medium priority: medium
ordinal: 1000
--- ---
## Description ## Description
<!-- SECTION:DESCRIPTION:BEGIN --> <!-- SECTION:DESCRIPTION:BEGIN -->
Scope: Branch-only commits main..HEAD on refactor-overlay (a14c9da through 9e4e588) rebuilt overlay behavior around visible overlay mode and removed legacy invisible overlay paths. Scope: Branch-only commits main..HEAD on refactor-overlay (a14c9da through 9e4e588) rebuilt overlay behavior around visible overlay mode and removed legacy invisible overlay paths.
Delivered behavior: Delivered behavior:
- Removed renderer invisible overlay layout/offset helpers and main hover-highlight runtime code paths. - Removed renderer invisible overlay layout/offset helpers and main hover-highlight runtime code paths.
- Added explicit overlay-to-mpv subtitle visibility synchronization so visible overlay state controls primary subtitle visibility consistently. - Added explicit overlay-to-mpv subtitle visibility synchronization so visible overlay state controls primary subtitle visibility consistently.
- Hardened overlay runtime/bootstrap lifecycle around modal fallback open state and bridge send path edge cases. - Hardened overlay runtime/bootstrap lifecycle around modal fallback open state and bridge send path edge cases.
- Updated plugin/config/docs defaults to reflect visible-overlay-first behavior and subtitle binding controls. - Updated plugin/config/docs defaults to reflect visible-overlay-first behavior and subtitle binding controls.
Risk/impact context: Risk/impact context:
- Large cross-layer refactor touching runtime wiring, renderer event handling, and plugin behavior. - Large cross-layer refactor touching runtime wiring, renderer event handling, and plugin behavior.
- Regression coverage added/updated for overlay runtime, mpv protocol handling, renderer cleanup, and subtitle rendering paths. - Regression coverage added/updated for overlay runtime, mpv protocol handling, renderer cleanup, and subtitle rendering paths.
<!-- SECTION:DESCRIPTION:END --> <!-- SECTION:DESCRIPTION:END -->
@@ -45,7 +40,5 @@ Risk/impact context:
## Final Summary ## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN --> <!-- SECTION:FINAL_SUMMARY:BEGIN -->
Completed and validated in branch commit set before merge. Refactor reduces dead overlay modes, centralizes subtitle visibility behavior, and documents new defaults/constraints. Completed and validated in branch commit set before merge. Refactor reduces dead overlay modes, centralizes subtitle visibility behavior, and documents new defaults/constraints.
<!-- SECTION:FINAL_SUMMARY:END --> <!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,43 @@
---
id: TASK-70
title: Polish YouTube subtitle generation pipeline
status: To Do
assignee: []
created_date: '2026-02-26 07:37'
labels: []
dependencies: []
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
$Current YouTube subtitle generation in launcher/youtube.ts is functional but has implicit behavior, low observability, and unnecessary full-file work. This task modernizes the existing pipeline without changing core architecture.
Scope:
- Make track selection explicit (manual > auto > whisper per primary/secondary) with deterministic reasons.
- Avoid running whisper/audio work when a track is already satisfied.
- Add bounded execution for yt-dlp and whisper subprocesses.
- Improve stage-level logging for both automatic and preprocess modes.
- Make secondary track fallback decisions explicit and not implicit.
- Preserve existing user behavior except where policy is clarified.
Files expected:
- launcher/youtube.ts
- launcher/commands/playback-command.ts (if mode/status behavior requires)
- launcher/types.ts (if schema updates needed)
- launcher/config/args-normalizer.ts (if timeout/config options added)
- launcher/util.ts (if runExternalCommand timeout controls added)
- Add/update launcher subtitle-generation tests
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Define deterministic track priority for each track: manual, then auto, then whisper (per track) and record source choice with reason.
- [ ] #2 If manual or auto satisfies a track, skip whisper for that same track and avoid unrelated full extraction/transcription work.
- [ ] #3 Introduce timeout or budget caps for yt-dlp and whisper calls; timeout should fail safe and unblock automatic playback.
- [ ] #4 Emit explicit status logs at each stage: metadata load, manual sub fetch, auto sub fetch, whisper audio extraction, whisper run, publish/load, final success/failure summary.
- [ ] #5 Make secondary handling explicit: transcribe target and translate target must only run when required by config and not by side-effect of primary logic.
- [ ] #6 Keep preprocess and automatic modes stable in success paths while making behavior in failure paths explicit and bounded.
- [ ] #7 Add tests for track-combination cases: primary available, secondary available, both missing, partial fallback, both missing with missing whisper config, timeout/error behavior.
- [ ] #8 Document any behavior changes if user-visible, especially fallback order, timeout behavior, and fallback disablement.
<!-- AC:END -->

View File

@@ -6,7 +6,6 @@ title: >-
status: Done status: Done
assignee: [] assignee: []
created_date: '2026-02-28 02:38' created_date: '2026-02-28 02:38'
updated_date: '2026-02-28 22:36'
labels: [] labels: []
dependencies: [] dependencies: []
references: references:
@@ -19,17 +18,14 @@ references:
- docs/anki-integration.md - docs/anki-integration.md
- config.example.jsonc - config.example.jsonc
priority: medium priority: medium
ordinal: 2000
--- ---
## Description ## Description
<!-- SECTION:DESCRIPTION:BEGIN --> <!-- SECTION:DESCRIPTION:BEGIN -->
Scope: Current unmerged working-tree changes implement an optional local AnkiConnect-compatible proxy and transport switching for card enrichment. Scope: Current unmerged working-tree changes implement an optional local AnkiConnect-compatible proxy and transport switching for card enrichment.
Delivered behavior: Delivered behavior:
- Added proxy server that forwards AnkiConnect requests and enqueues addNote/addNotes note IDs for post-create enrichment, with de-duplication and loop-configuration protection. - Added proxy server that forwards AnkiConnect requests and enqueues addNote/addNotes note IDs for post-create enrichment, with de-duplication and loop-configuration protection.
- Added follow-up response-shape compatibility handling so proxy enqueue works for both envelope (`{result,error}`) and bare JSON payloads, including `multi` variants. - Added follow-up response-shape compatibility handling so proxy enqueue works for both envelope (`{result,error}`) and bare JSON payloads, including `multi` variants.
- Added config schema/defaults/resolution for ankiConnect.proxy (enabled, host, port, upstreamUrl) with validation warnings and fallback behavior. - Added config schema/defaults/resolution for ankiConnect.proxy (enabled, host, port, upstreamUrl) with validation warnings and fallback behavior.
@@ -38,7 +34,6 @@ Delivered behavior:
- Updated user docs/config examples for proxy mode setup, troubleshooting, and mining workflow behavior. - Updated user docs/config examples for proxy mode setup, troubleshooting, and mining workflow behavior.
Risk/impact context: Risk/impact context:
- New network surface on local host/port; correctness depends on safe proxy upstream configuration and robust response handling. - New network surface on local host/port; correctness depends on safe proxy upstream configuration and robust response handling.
- Tests added for proxy queue behavior, config resolution, and parser sync routines. - Tests added for proxy queue behavior, config resolution, and parser sync routines.
<!-- SECTION:DESCRIPTION:END --> <!-- SECTION:DESCRIPTION:END -->
@@ -46,7 +41,5 @@ Risk/impact context:
## Final Summary ## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN --> <!-- SECTION:FINAL_SUMMARY:BEGIN -->
Completed implementation in branch working tree; ready to merge once local changes are committed and test gate passes. Completed implementation in branch working tree; ready to merge once local changes are committed and test gate passes.
<!-- SECTION:FINAL_SUMMARY:END --> <!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -4,7 +4,6 @@ title: 'macOS config validation UX: show full warning details in native dialog'
status: Done status: Done
assignee: [] assignee: []
created_date: '2026-02-28 02:38' created_date: '2026-02-28 02:38'
updated_date: '2026-02-28 22:36'
labels: [] labels: []
dependencies: [] dependencies: []
references: references:
@@ -13,30 +12,24 @@ references:
- src/main/runtime/startup-config.ts - src/main/runtime/startup-config.ts
- docs/configuration.md - docs/configuration.md
priority: low priority: low
ordinal: 3000
--- ---
## Description ## Description
<!-- SECTION:DESCRIPTION:BEGIN --> <!-- SECTION:DESCRIPTION:BEGIN -->
Scope: Commit cc2f9ef improves startup config-warning visibility on macOS by ensuring full details are surfaced in the native UI path and reflected in docs. Scope: Commit cc2f9ef improves startup config-warning visibility on macOS by ensuring full details are surfaced in the native UI path and reflected in docs.
Delivered behavior: Delivered behavior:
- Config validation/runtime wiring updated so macOS users can access complete warning details instead of truncated notification-only text. - Config validation/runtime wiring updated so macOS users can access complete warning details instead of truncated notification-only text.
- Added/updated tests around config validation and startup config warning flows. - Added/updated tests around config validation and startup config warning flows.
- Updated configuration docs to clarify platform-specific warning presentation behavior. - Updated configuration docs to clarify platform-specific warning presentation behavior.
Risk/impact context: Risk/impact context:
- Low runtime risk; primarily user-facing diagnostics clarity improvement. - Low runtime risk; primarily user-facing diagnostics clarity improvement.
<!-- SECTION:DESCRIPTION:END --> <!-- SECTION:DESCRIPTION:END -->
## Final Summary ## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN --> <!-- SECTION:FINAL_SUMMARY:BEGIN -->
Completed small follow-up fix to reduce config-debug friction on macOS. Completed small follow-up fix to reduce config-debug friction on macOS.
<!-- SECTION:FINAL_SUMMARY:END --> <!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,9 @@
--- ---
id: TASK-73 id: TASK-73
title: 'MPV plugin: split into modules and optimize startup/command runtime' title: 'MPV plugin: split into modules and optimize startup/command runtime'
status: Done status: In Progress
assignee: [] assignee: []
created_date: '2026-02-28 20:50' created_date: '2026-02-28 20:50'
updated_date: '2026-02-28 22:36'
labels: [] labels: []
dependencies: [] dependencies: []
references: references:
@@ -33,17 +32,14 @@ references:
- docs/architecture.md - docs/architecture.md
- README.md - README.md
priority: medium priority: medium
ordinal: 4000
--- ---
## Description ## Description
<!-- SECTION:DESCRIPTION:BEGIN --> <!-- SECTION:DESCRIPTION:BEGIN -->
Scope: Replace monolithic `plugin/subminer.lua` with modular plugin runtime; optimize command execution paths; align install/docs/tests; fix launcher smoke instability. Scope: Replace monolithic `plugin/subminer.lua` with modular plugin runtime; optimize command execution paths; align install/docs/tests; fix launcher smoke instability.
Delivered behavior: Delivered behavior:
- Full plugin cutover to `plugin/subminer/main.lua` + module directory (no runtime compatibility shim with old monolith file). - Full plugin cutover to `plugin/subminer/main.lua` + module directory (no runtime compatibility shim with old monolith file).
- Process/control command path moved toward async subprocess usage for non-start actions (`stop`, `toggle`, `settings`, restart stop leg), reducing synchronous blocking in mpv script runtime. - Process/control command path moved toward async subprocess usage for non-start actions (`stop`, `toggle`, `settings`, restart stop leg), reducing synchronous blocking in mpv script runtime.
- AniSkip path guarded: lookup runs only in SubMiner context (launcher metadata, explicit script-message refresh, or detected running app), instead of every opened file. - AniSkip path guarded: lookup runs only in SubMiner context (launcher metadata, explicit script-message refresh, or detected running app), instead of every opened file.
@@ -55,7 +51,6 @@ Delivered behavior:
- Playback command cleanup race fixed when mpv exits before exit-listener registration. - Playback command cleanup race fixed when mpv exits before exit-listener registration.
Risk/impact context: Risk/impact context:
- mpv plugin loading path changed from single-file to module directory; packaging/install paths must stay consistent with release assets. - mpv plugin loading path changed from single-file to module directory; packaging/install paths must stay consistent with release assets.
- Async control/AniSkip path changes reduce blocking but can surface timing differences; regression checks added for cold start, file-load gating, and explicit refresh behavior. - Async control/AniSkip path changes reduce blocking but can surface timing differences; regression checks added for cold start, file-load gating, and explicit refresh behavior.
<!-- SECTION:DESCRIPTION:END --> <!-- SECTION:DESCRIPTION:END -->
@@ -63,22 +58,18 @@ Risk/impact context:
## Final Summary ## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN --> <!-- SECTION:FINAL_SUMMARY:BEGIN -->
AniSkip gate/async update delivered in plugin runtime: AniSkip gate/async update delivered in plugin runtime:
- `plugin/subminer/lifecycle.lua`: deferred AniSkip fetch and overlay-start trigger. - `plugin/subminer/lifecycle.lua`: deferred AniSkip fetch and overlay-start trigger.
- `plugin/subminer/aniskip.lua`: async lookup pipeline + context guard + session caches. - `plugin/subminer/aniskip.lua`: async lookup pipeline + context guard + session caches.
- `plugin/subminer/environment.lua`: async app-running detection with short cache. - `plugin/subminer/environment.lua`: async app-running detection with short cache.
- `plugin/subminer/messages.lua`: explicit script-message trigger wiring. - `plugin/subminer/messages.lua`: explicit script-message trigger wiring.
Regression coverage updated: Regression coverage updated:
- `scripts/test-plugin-start-gate.lua` now verifies: - `scripts/test-plugin-start-gate.lua` now verifies:
- no sync `ps`/`curl` on non-context file load - no sync `ps`/`curl` on non-context file load
- no AniSkip network lookup on non-context file load - no AniSkip network lookup on non-context file load
- script-message refresh forces async AniSkip lookup - script-message refresh forces async AniSkip lookup
Validation run: Validation run:
- `bun run test:plugin:src` pass. - `bun run test:plugin:src` pass.
<!-- SECTION:FINAL_SUMMARY:END --> <!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,9 @@
--- ---
id: TASK-74 id: TASK-74
title: 'Startup warmups: configurable warmup vs defer with low-power mode' title: 'Startup warmups: configurable warmup vs defer with low-power mode'
status: Done status: In Progress
assignee: [] assignee: []
created_date: '2026-02-27 21:05' created_date: '2026-02-27 21:05'
updated_date: '2026-03-01 04:14'
labels: [] labels: []
dependencies: [] dependencies: []
references: references:
@@ -16,24 +15,18 @@ references:
- src/main/runtime/startup-warmups.ts - src/main/runtime/startup-warmups.ts
- src/main/runtime/startup-warmups-main-deps.ts - src/main/runtime/startup-warmups-main-deps.ts
- src/main/runtime/composers/mpv-runtime-composer.ts - src/main/runtime/composers/mpv-runtime-composer.ts
- src/core/services/startup.ts
- src/main.ts - src/main.ts
- src/config/config.test.ts - src/config/config.test.ts
- src/main/runtime/startup-warmups.test.ts - src/main/runtime/startup-warmups.test.ts
- src/main/runtime/startup-warmups-main-deps.test.ts
- src/core/services/app-ready.test.ts
priority: medium priority: medium
ordinal: 7000
--- ---
## Description ## Description
<!-- SECTION:DESCRIPTION:BEGIN --> <!-- SECTION:DESCRIPTION:BEGIN -->
Add startup warmup controls to allow per-integration warmup or deferred first-use loading. Add startup warmup controls to allow per-integration warmup or deferred first-use loading.
Scope: Scope:
- New config section `startupWarmups` with toggles for `mecab`, `yomitanExtension`, `subtitleDictionaries`, and `jellyfinRemoteSession`. - New config section `startupWarmups` with toggles for `mecab`, `yomitanExtension`, `subtitleDictionaries`, and `jellyfinRemoteSession`.
- New `startupWarmups.lowPowerMode` policy: defer everything except Yomitan extension. - New `startupWarmups.lowPowerMode` policy: defer everything except Yomitan extension.
- Keep default behavior as full warmup. - Keep default behavior as full warmup.
@@ -44,9 +37,7 @@ Scope:
## Final Summary ## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN --> <!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented: Implemented:
- Added `startupWarmups` to config types/defaults/options/template/resolve. - Added `startupWarmups` to config types/defaults/options/template/resolve.
- Warmup scheduler now uses per-integration gating functions. - Warmup scheduler now uses per-integration gating functions.
- Low-power mode now defers MeCab, subtitle dictionaries, and Jellyfin remote session warmups while still warming Yomitan extension. - Low-power mode now defers MeCab, subtitle dictionaries, and Jellyfin remote session warmups while still warming Yomitan extension.
@@ -54,23 +45,7 @@ Implemented:
- Added/updated tests across config and runtime warmup modules. - Added/updated tests across config and runtime warmup modules.
Validation: Validation:
- `bun run test:config:src` - `bun run test:config:src`
- `bun run test:core:src` - `bun run test:core:src`
- `tsc --noEmit` - `tsc --noEmit`
<!-- SECTION:FINAL_SUMMARY:END -->
Follow-up updates:
- Startup now triggers warmups earlier in app-ready flow (right after config validation/log-level setup) instead of waiting for initial args/overlay actions. Goal: tokenization warmup is already done or mostly done by first visible-subs toggle.
- Tokenization warmup scheduling consolidated as `subtitle-tokenization` stage; when enabled by toggles, it runs Yomitan extension first, then MeCab/dictionary warmups.
- Added per-stage debug logs for warmup progress and skip reasons:
- `stage start/ready: yomitan-extension`
- `stage start/ready: mecab`
- `stage start/ready: subtitle-dictionaries`
- `stage start/ready: jellyfin-remote-session`
- `stage skipped: jellyfin-remote-session (disabled|auto-connect off)`
- Added regression tests for stage-level logging and earlier startup ordering:
- `src/main/runtime/startup-warmups.test.ts`
- `src/main/runtime/startup-warmups-main-deps.test.ts`
- `src/core/services/app-ready.test.ts`
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,41 +0,0 @@
---
id: TASK-75
title: 'Tokenizer: configurable POS exclusions for N+1 and frequency annotations'
status: Done
assignee: []
created_date: '2026-03-01 01:23'
updated_date: '2026-03-01 04:14'
labels: []
dependencies: []
priority: medium
ordinal: 6000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
N+1 and frequency highlighting should ignore non-learning tokens (e.g., particles/auxiliary forms) based on MeCab POS1 tags, while remaining user-configurable.
Problem example: for subtitle phrase containing になれば, the highlighted N+1 target should not be the non-useful inflection/token piece when POS indicates an excluded class.
Implement configurable exclusion defaults with add/remove overrides so users can tune behavior without code changes.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Default exclusion set omits non-useful POS1 classes from both N+1 candidate selection and frequency highlighting.
- [x] #2 Users can add extra POS1 exclusions and remove defaults via config.
- [x] #3 Tokenizer/annotation tests cover default behavior and config add/remove overrides.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented configurable annotation POS exclusions with defaults+add/remove for both MeCab POS1 and POS2, wired to N+1 candidate selection and frequency highlighting. Added POS2 default exclusion (非自立), expanded POS1 defaults for function words, added Yomitan->MeCab enrichment to carry pos2/pos3 metadata, updated config docs/examples, and added regression tests including になれば case.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,39 +0,0 @@
---
id: TASK-76
title: 'Tokenizer: remove POS exclusion config surface and keep hardcoded defaults'
status: Done
assignee: []
created_date: '2026-03-01 02:45'
updated_date: '2026-03-01 04:14'
labels: []
dependencies: []
priority: medium
ordinal: 5000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Remove user-facing config keys for annotation POS exclusions. Keep N+1/frequency POS exclusion behavior as built-in defaults with no config required.
Scope: remove config parsing/registry/docs/example for annotationFilters.pos1Exclusions/pos2Exclusions while preserving runtime filtering behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 No user-facing config option exists for annotation POS exclusions.
- [x] #2 Runtime N+1/frequency exclusion behavior remains active via built-in defaults.
- [x] #3 Config/docs/example/tests updated accordingly.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Removed user-facing subtitleStyle.annotationFilters POS exclusion configuration (schema/resolver/options/docs/example). POS-based N+1/frequency filtering now always uses built-in defaults in runtime. Preserved robust exclusion behavior including merged-token overlap POS handling and N+1-only MeCab enrichment path.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,45 +0,0 @@
---
id: TASK-77
title: 'Subtitle hover: auto-pause playback with config toggle'
status: Done
assignee: []
created_date: '2026-02-28 22:43'
updated_date: '2026-02-28 22:43'
labels: []
dependencies: []
priority: medium
ordinal: 8000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add a user-facing subtitle config option to pause mpv playback when the cursor hovers subtitle text and resume playback when the cursor leaves.
Scope:
- New config key: `subtitleStyle.autoPauseVideoOnHover`.
- Default should be enabled.
- Hover pause/resume must not unpause if playback was already paused before hover.
- Docs/examples/tests updated.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `subtitleStyle.autoPauseVideoOnHover` exists and defaults to `true`.
- [x] #2 Overlay pauses playback on subtitle hover and resumes on leave only when hover-triggered pause occurred.
- [x] #3 Main/renderer IPC exposes pause-state query for safe hover behavior.
- [x] #4 Config docs/examples and user docs/readme mention the new behavior and toggle.
- [x] #5 Regression tests cover config parsing/validation and hover behavior edge cases.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented `subtitleStyle.autoPauseVideoOnHover` with default `true`, wired through config defaults/resolution/types, renderer state/style, and mouse hover handlers. Added playback pause-state IPC (`getPlaybackPaused`) to avoid false resume when media was already paused. Added renderer hover behavior tests (including race/cancel case) and config/resolve tests. Updated config examples and docs (`README`, usage, shortcuts, mining workflow, configuration) to document default hover pause/resume behavior and disable path.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,53 +0,0 @@
---
id: TASK-78
title: 'Launcher + mpv plugin: auto-start visible overlay pause-until-ready and single-start guard'
status: Done
assignee: []
created_date: '2026-02-28 22:45'
updated_date: '2026-02-28 22:45'
labels: []
dependencies: []
priority: medium
ordinal: 9000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add startup gating behavior for wrapper + mpv plugin flow so playback starts paused when visible overlay auto-start is enabled, then auto-resumes only after subtitle tokenization is ready.
Scope:
- Plugin option `auto_start_pause_until_ready` (default `yes`).
- Launcher reads plugin runtime config and starts mpv paused when `auto_start=yes`, `auto_start_visible_overlay=yes`, and `auto_start_pause_until_ready=yes`.
- Main process signals readiness via mpv script message after tokenized subtitle delivery.
- Prevent duplicate auto-start attempts from showing `SubMiner already running` OSD.
- Keep startup/loading OSD messaging visible and update docs/tests.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launcher reads `auto_start`, `auto_start_visible_overlay`, and `auto_start_pause_until_ready` from `subminer.conf` and starts mpv with `--pause=yes` when all are enabled.
- [x] #2 Plugin pauses on eligible auto-start and resumes only on readiness signal or timeout fallback.
- [x] #3 Main process emits `script-message subminer-autoplay-ready` after subtitle tokenization is ready.
- [x] #4 Auto-start duplicate triggers are idempotent (no duplicate `--start` behavior and no spurious `Already running` OSD for auto-start path).
- [x] #5 Docs and regression tests cover defaults, startup gating behavior, and duplicate-start suppression.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented startup pause gate across launcher/plugin/main runtime:
- Added plugin runtime config parsing in launcher (`auto_start`, `auto_start_visible_overlay`, `auto_start_pause_until_ready`) and mpv start-paused behavior for eligible runs.
- Added plugin auto-play gate state, timeout fallback, and readiness release via `subminer-autoplay-ready` script message.
- Added main-process readiness signaling after tokenization delivery, including unpause fallback command path.
- Split auto-start visibility control into separate control commands and added duplicate auto-start idempotency guard to suppress repeated auto-start `Already running` noise.
- Updated plugin defaults to enabled (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`) and refreshed docs (`README`, usage, launcher, installation, plugin/config docs).
- Added/updated regression coverage (`scripts/test-plugin-start-gate.lua`, launcher smoke/unit tests) validating paused startup, readiness resume, and duplicate-start suppression.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -5,6 +5,7 @@
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
*/ */
{ {
// ========================================== // ==========================================
// Overlay Auto-Start // Overlay Auto-Start
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles. // When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
@@ -16,7 +17,7 @@
// Control whether browser opens automatically for texthooker. // Control whether browser opens automatically for texthooker.
// ========================================== // ==========================================
"texthooker": { "texthooker": {
"openBrowser": true, // Open browser setting. Values: true | false "openBrowser": true // Open browser setting. Values: true | false
}, // Control whether browser opens automatically for texthooker. }, // Control whether browser opens automatically for texthooker.
// ========================================== // ==========================================
@@ -26,7 +27,7 @@
// ========================================== // ==========================================
"websocket": { "websocket": {
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false "enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
"port": 6677, // Built-in subtitle websocket server port. "port": 6677 // Built-in subtitle websocket server port.
}, // Built-in WebSocket server broadcasts subtitle text to connected clients. }, // Built-in WebSocket server broadcasts subtitle text to connected clients.
// ========================================== // ==========================================
@@ -35,7 +36,7 @@
// Set to debug for full runtime diagnostics. // Set to debug for full runtime diagnostics.
// ========================================== // ==========================================
"logging": { "logging": {
"level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
}, // Controls logging verbosity. Keep this as an object; do not replace with a bare string. }, // Controls logging verbosity. Keep this as an object; do not replace with a bare string.
// ========================================== // ==========================================
@@ -56,7 +57,7 @@
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting. "openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================
@@ -76,7 +77,7 @@
"secondarySub": { "secondarySub": {
"secondarySubLanguages": [], // Secondary sub languages setting. "secondarySubLanguages": [], // Secondary sub languages setting.
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false "autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
"defaultMode": "hover", // Default mode setting. "defaultMode": "hover" // Default mode setting.
}, // Dual subtitle track options. }, // Dual subtitle track options.
// ========================================== // ==========================================
@@ -87,7 +88,7 @@
"defaultMode": "auto", // Subsync default mode. Values: auto | manual "defaultMode": "auto", // Subsync default mode. Values: auto | manual
"alass_path": "", // Alass path setting. "alass_path": "", // Alass path setting.
"ffsubsync_path": "", // Ffsubsync path setting. "ffsubsync_path": "", // Ffsubsync path setting.
"ffmpeg_path": "", // Ffmpeg path setting. "ffmpeg_path": "" // Ffmpeg path setting.
}, // Subsync engine and executable paths. }, // Subsync engine and executable paths.
// ========================================== // ==========================================
@@ -95,7 +96,7 @@
// Initial vertical subtitle position from the bottom. // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
"subtitlePosition": { "subtitlePosition": {
"yPercent": 10, // Y percent setting. "yPercent": 10 // Y percent setting.
}, // Initial vertical subtitle position from the bottom. }, // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
@@ -106,7 +107,6 @@
"subtitleStyle": { "subtitleStyle": {
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. 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 "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
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv. "hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv. "hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting. "fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
@@ -129,19 +129,24 @@
"N2": "#f5a97f", // N2 setting. "N2": "#f5a97f", // N2 setting.
"N3": "#f9e2af", // N3 setting. "N3": "#f9e2af", // N3 setting.
"N4": "#a6e3a1", // N4 setting. "N4": "#a6e3a1", // N4 setting.
"N5": "#8aadf4", // N5 setting. "N5": "#8aadf4" // N5 setting.
}, // Jlpt colors setting. }, // Jlpt colors setting.
"frequencyDictionary": { "frequencyDictionary": {
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, SubMiner searches installed/default frequency-dictionary locations. "sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, SubMiner searches installed/default frequency-dictionary locations.
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000). "topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
"matchMode": "headword", // Frequency lookup text selection mode. Values: headword | surface
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX). "bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#a6e3a1",
"#8aadf4"
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting. }, // Frequency dictionary setting.
"secondary": { "secondary": {
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting. "fontFamily": "Manrope, Inter", // Font family setting.
"fontSize": 24, // Font size setting. "fontSize": 24, // Font size setting.
"fontColor": "#cad3f5", // Font color setting. "fontColor": "#cad3f5", // Font color setting.
"lineHeight": 1.35, // Line height setting. "lineHeight": 1.35, // Line height setting.
@@ -153,8 +158,8 @@
"backgroundColor": "transparent", // Background color setting. "backgroundColor": "transparent", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting. "backdropFilter": "blur(6px)", // Backdrop filter setting.
"fontWeight": "normal", // Font weight setting. "fontWeight": "normal", // Font weight setting.
"fontStyle": "normal", // Font style setting. "fontStyle": "normal" // Font style setting.
}, // Secondary setting. } // Secondary setting.
}, // Primary and secondary subtitle styling. }, // Primary and secondary subtitle styling.
// ========================================== // ==========================================
@@ -171,15 +176,17 @@
"enabled": false, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false "enabled": false, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy. "host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
"port": 8766, // Bind port for local AnkiConnect proxy. "port": 8766, // Bind port for local AnkiConnect proxy.
"upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. "upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
}, // Proxy setting. }, // Proxy setting.
"tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. "tags": [
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"fields": { "fields": {
"audio": "ExpressionAudio", // Audio setting. "audio": "ExpressionAudio", // Audio setting.
"image": "Picture", // Image setting. "image": "Picture", // Image setting.
"sentence": "Sentence", // Sentence setting. "sentence": "Sentence", // Sentence setting.
"miscInfo": "MiscInfo", // Misc info setting. "miscInfo": "MiscInfo", // Misc info setting.
"translation": "SelectionText", // Translation setting. "translation": "SelectionText" // Translation setting.
}, // Fields setting. }, // Fields setting.
"ai": { "ai": {
"enabled": false, // Enabled setting. Values: true | false "enabled": false, // Enabled setting. Values: true | false
@@ -188,7 +195,7 @@
"model": "openai/gpt-4o-mini", // Model setting. "model": "openai/gpt-4o-mini", // Model setting.
"baseUrl": "https://openrouter.ai/api", // Base url setting. "baseUrl": "https://openrouter.ai/api", // Base url setting.
"targetLanguage": "English", // Target language setting. "targetLanguage": "English", // Target language setting.
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting. "systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting.
}, // Ai setting. }, // Ai setting.
"media": { "media": {
"generateAudio": true, // Generate audio setting. Values: true | false "generateAudio": true, // Generate audio setting. Values: true | false
@@ -201,7 +208,7 @@
"animatedCrf": 35, // Animated crf setting. "animatedCrf": 35, // Animated crf setting.
"audioPadding": 0.5, // Audio padding setting. "audioPadding": 0.5, // Audio padding setting.
"fallbackDuration": 3, // Fallback duration setting. "fallbackDuration": 3, // Fallback duration setting.
"maxMediaDuration": 30, // Max media duration setting. "maxMediaDuration": 30 // Max media duration setting.
}, // Media setting. }, // Media setting.
"behavior": { "behavior": {
"overwriteAudio": true, // Overwrite audio setting. Values: true | false "overwriteAudio": true, // Overwrite audio setting. Values: true | false
@@ -209,7 +216,7 @@
"mediaInsertMode": "append", // Media insert mode setting. "mediaInsertMode": "append", // Media insert mode setting.
"highlightWord": true, // Highlight word setting. Values: true | false "highlightWord": true, // Highlight word setting. Values: true | false
"notificationType": "osd", // Notification type setting. "notificationType": "osd", // Notification type setting.
"autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting. }, // Behavior setting.
"nPlusOne": { "nPlusOne": {
"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
@@ -218,20 +225,20 @@
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names. "decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight. "nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
"knownWord": "#a6da95", // Color used for legacy known-word highlights. "knownWord": "#a6da95" // Color used for legacy known-word highlights.
}, // N plus one setting. }, // N plus one setting.
"metadata": { "metadata": {
"pattern": "[SubMiner] %f (%t)", // Pattern setting. "pattern": "[SubMiner] %f (%t)" // Pattern setting.
}, // Metadata setting. }, // Metadata setting.
"isLapis": { "isLapis": {
"enabled": false, // Enabled setting. Values: true | false "enabled": false, // Enabled setting. Values: true | false
"sentenceCardModel": "Japanese sentences", // Sentence card model setting. "sentenceCardModel": "Japanese sentences" // Sentence card model setting.
}, // Is lapis setting. }, // Is lapis setting.
"isKiku": { "isKiku": {
"enabled": false, // Enabled setting. Values: true | false "enabled": false, // Enabled setting. Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false "deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
}, // Is kiku setting. } // Is kiku setting.
}, // Automatic Anki updates and media generation options. }, // Automatic Anki updates and media generation options.
// ========================================== // ==========================================
@@ -241,7 +248,7 @@
"jimaku": { "jimaku": {
"apiBaseUrl": "https://jimaku.cc", // Api base url setting. "apiBaseUrl": "https://jimaku.cc", // Api base url setting.
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
"maxEntryResults": 10, // Maximum Jimaku search results returned. "maxEntryResults": 10 // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults. }, // Jimaku API configuration and defaults.
// ========================================== // ==========================================
@@ -252,7 +259,10 @@
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off "mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine. "whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
"whisperModel": "", // Path to whisper model used for fallback transcription. "whisperModel": "", // Path to whisper model used for fallback transcription.
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher. "primarySubLanguages": [
"ja",
"jpn"
] // Comma-separated primary subtitle language priority used by the launcher.
}, // Defaults for subminer YouTube subtitle extraction/transcription mode. }, // Defaults for subminer YouTube subtitle extraction/transcription mode.
// ========================================== // ==========================================
@@ -261,7 +271,7 @@
// ========================================== // ==========================================
"anilist": { "anilist": {
"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.
}, // Anilist API credentials and update behavior. }, // Anilist API credentials and update behavior.
// ========================================== // ==========================================
@@ -285,8 +295,16 @@
"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
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions. "directPlayContainers": [
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable. "mkv",
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
}, // Optional Jellyfin integration for auth, browsing, and playback launch. }, // Optional Jellyfin integration for auth, browsing, and playback launch.
// ========================================== // ==========================================
@@ -297,7 +315,7 @@
"discordPresence": { "discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false "enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"updateIntervalMs": 3000, // Minimum interval between presence payload updates. "updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750, // Debounce delay used to collapse bursty presence updates. "debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session. }, // Optional Discord Rich Presence activity card updates for current playback/study session.
// ========================================== // ==========================================
@@ -319,7 +337,7 @@
"telemetryDays": 30, // Telemetry retention window in days. "telemetryDays": 30, // Telemetry retention window in days.
"dailyRollupsDays": 365, // Daily rollup retention window in days. "dailyRollupsDays": 365, // Daily rollup retention window in days.
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days. "monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
"vacuumIntervalDays": 7, // Minimum days between VACUUM runs. "vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
}, // Retention setting. } // Retention setting.
}, // Enable/disable immersion tracking. } // Enable/disable immersion tracking.
} }

View File

@@ -69,7 +69,6 @@ export default {
{ text: 'Launcher Script', link: '/launcher-script' }, { text: 'Launcher Script', link: '/launcher-script' },
{ text: 'Usage', link: '/usage' }, { text: 'Usage', link: '/usage' },
{ text: 'Mining Workflow', link: '/mining-workflow' }, { text: 'Mining Workflow', link: '/mining-workflow' },
// { text: 'Feature Demos', link: '/demos' },
], ],
}, },
{ {

View File

@@ -1,7 +1,6 @@
# Anki Integration # Anki Integration
SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on to create and update Anki cards with sentence context, audio, and screenshots. SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on to create and update Anki cards with sentence context, audio, and screenshots.
This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
## Prerequisites ## Prerequisites
@@ -275,7 +274,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
### What Gets Merged ### What Gets Merged
| Field | Merge behavior | | Field | Merge behavior |
| -------- | --------------------------------------------------------------- | | -------- | -------------------------------------------------------------- |
| Sentence | Both sentences preserved (exact duplicate text is deduplicated) | | Sentence | Both sentences preserved (exact duplicate text is deduplicated) |
| Audio | Both `[sound:...]` entries kept (exact duplicates deduplicated) | | Audio | Both `[sound:...]` entries kept (exact duplicates deduplicated) |
| Image | Both images kept (exact duplicates deduplicated) | | Image | Both images kept (exact duplicates deduplicated) |
@@ -300,7 +299,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
"enabled": false, "enabled": false,
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 8766, "port": 8766,
"upstreamUrl": "http://127.0.0.1:8765", "upstreamUrl": "http://127.0.0.1:8765"
}, },
"fields": { "fields": {
"audio": "ExpressionAudio", "audio": "ExpressionAudio",

View File

@@ -72,467 +72,27 @@ Restart-required changes:
The configuration file includes several main sections: The configuration file includes several main sections:
**Core Settings**
- [**Logging**](#logging) - Runtime log level
- [**Auto-Start Overlay**](#auto-start-overlay) - Automatically show overlay on MPV connection
- [**Startup Warmups**](#startup-warmups) - Control what preloads on startup vs first-use defer
- [**WebSocket Server**](#websocket-server) - Built-in subtitle broadcasting server
- [**Texthooker**](#texthooker) - Control browser opening behavior
**Subtitle Display**
- [**Subtitle Style**](#subtitle-style) - Appearance customization
- [**Subtitle Position**](#subtitle-position) - Overlay vertical positioning
- [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support
**Keyboard & Controls**
- [**Keybindings**](#keybindings) - MPV command shortcuts
- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts
- [**Manual Card Update Shortcuts**](#manual-card-update-shortcuts) - Shortcuts for manual Anki card workflows
- [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
**Anki Integration**
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media - [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types - [**Auto-Start Overlay**](#auto-start-overlay) - Automatically show overlay on MPV connection
- [**N+1 Word Highlighting**](#n1-word-highlighting) - Known-word cache and single-target highlighting - [**Visible Overlay Subtitle Binding**](#visible-overlay-subtitle-binding) - Link visible overlay toggles to MPV subtitle visibility
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
**External Integrations**
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` - [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**Subtitle Position Edit**](#subtitle-position-edit) - Fine-tune subtitle alignment in overlay
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**AniList**](#anilist) - Optional post-watch progress updates - [**AniList**](#anilist) - Optional post-watch progress updates
- [**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
- [**Keybindings**](#keybindings) - MPV command shortcuts
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
- [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support
- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts
- [**Subtitle Position**](#subtitle-position) - Overlay vertical positioning
- [**Subtitle Style**](#subtitle-style) - Appearance customization
- [**Texthooker**](#texthooker) - Control browser opening behavior
- [**WebSocket Server**](#websocket-server) - Built-in subtitle broadcasting server
- [**Startup Warmups**](#startup-warmups) - Control what preloads on startup vs first-use defer
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite - [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
- [**YouTube Subtitle Generation**](#youtube-subtitle-generation) - Launcher defaults for yt-dlp + local whisper fallback - [**YouTube Subtitle Generation**](#youtube-subtitle-generation) - Launcher defaults for yt-dlp + local whisper fallback
## Core Settings
### Logging
Control the minimum log level for runtime output:
```json
{
"logging": {
"level": "info"
}
}
```
| Option | Values | Description |
| ------- | ----------------------------------- | ------------------------------------------------ |
| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) |
### Auto-Start Overlay
Control whether the overlay automatically becomes visible when it connects to mpv:
```json
{
"auto_start_overlay": false
}
```
| Option | Values | Description |
| -------------------- | --------------- | ------------------------------------------------------ |
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) |
The mpv plugin controls startup overlay visibility via `auto_start_visible_overlay` in `subminer.conf`.
For wrapper-driven playback, `subminer.conf` can also enable startup pause gating with
`auto_start_pause_until_ready` (requires `auto_start=yes` + `auto_start_visible_overlay=yes`).
Current plugin defaults in `subminer.conf` are:
- `auto_start=yes`
- `auto_start_visible_overlay=yes`
- `auto_start_pause_until_ready=yes`
### Startup Warmups
Control which startup warmups run in the background versus deferring to first real usage:
```json
{
"startupWarmups": {
"lowPowerMode": false,
"mecab": true,
"yomitanExtension": true,
"subtitleDictionaries": true,
"jellyfinRemoteSession": true
}
}
```
| Option | Values | Description |
| ----------------------- | --------------- | ------------------------------------------------------------------------------------------------- |
| `lowPowerMode` | `true`, `false` | Defer all warmups except Yomitan extension |
| `mecab` | `true`, `false` | Warm up MeCab tokenizer at startup |
| `yomitanExtension` | `true`, `false` | Warm up Yomitan extension at startup |
| `subtitleDictionaries` | `true`, `false` | Warm up JLPT + frequency dictionaries at startup |
| `jellyfinRemoteSession` | `true`, `false` | Warm up Jellyfin remote session at startup (still requires Jellyfin remote auto-connect settings) |
Defaults warm everything (`true` for all toggles, `lowPowerMode: false`). Setting a warmup toggle to `false` defers that work until first usage.
### WebSocket Server
The overlay includes a built-in WebSocket server that broadcasts subtitle text to connected clients (such as texthooker-ui) for external processing.
By default, the server uses "auto" mode: it starts automatically unless [mpv_websocket](https://github.com/kuroahna/mpv_websocket) is detected at `~/.config/mpv/mpv_websocket`. If you have mpv_websocket installed, the built-in server is skipped to avoid conflicts.
See `config.example.jsonc` for detailed configuration options.
```json
{
"websocket": {
"enabled": "auto",
"port": 6677
}
}
```
| Option | Values | Description |
| --------- | ------------------------- | -------------------------------------------------------- |
| `enabled` | `true`, `false`, `"auto"` | `"auto"` (default) disables if mpv_websocket is detected |
| `port` | number | WebSocket server port (default: 6677) |
### Texthooker
Control whether the browser opens automatically when texthooker starts:
See `config.example.jsonc` for detailed configuration options.
```json
{
"texthooker": {
"openBrowser": true
}
}
```
## Subtitle Display
### Subtitle Style
Customize the appearance of primary and secondary subtitles:
See `config.example.jsonc` for detailed configuration options.
```json
{
"subtitleStyle": {
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP",
"fontSize": 35,
"fontColor": "#cad3f5",
"fontWeight": "600",
"lineHeight": 1.35,
"letterSpacing": "-0.01em",
"wordSpacing": 0,
"fontKerning": "normal",
"textRendering": "geometricPrecision",
"textShadow": "0 3px 10px rgba(0,0,0,0.69)",
"fontStyle": "normal",
"backgroundColor": "rgb(30, 32, 48, 0.88)",
"backdropFilter": "blur(6px)",
"secondary": {
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif",
"fontSize": 24,
"fontColor": "#cad3f5",
"backgroundColor": "transparent"
}
}
}
```
| Option | Values | Description |
| ---------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- |
| `fontFamily` | string | CSS font-family value (default: `"M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP"`) |
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
| `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: `"rgb(30, 32, 48, 0.88)"`) |
| `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. |
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
| `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 |
| `nPlusOneColor` | string | Existing n+1 highlight color (default: `#c6a0f6`) |
| `knownWordColor` | string | Existing known-word highlight color (default: `#a6da95`) |
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
| `secondary` | object | Override any of the above for secondary subtitles (optional) |
JLPT underlining is powered by offline term-meta bank files at runtime. See [`docs/jlpt-vocab-bundle.md`](jlpt-vocab-bundle.md) for required files, source/version refresh steps, and deterministic fallback behavior.
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:
- Set `frequencyDictionary.sourcePath` to 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).
- 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).
- 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.
Secondary subtitle defaults: `fontFamily: "Inter, Noto Sans, Helvetica Neue, sans-serif"`, `fontSize: 24`, `fontColor: "#cad3f5"`, `backgroundColor: "transparent"`. Any property not set in `secondary` falls back to the CSS defaults.
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
`jlptColors` keys are:
| Key | Default | Description |
| ---- | --------- | ----------------------- |
| `N1` | `#ed8796` | JLPT N1 underline color |
| `N2` | `#f5a97f` | JLPT N2 underline color |
| `N3` | `#f9e2af` | JLPT N3 underline color |
| `N4` | `#a6e3a1` | JLPT N4 underline color |
| `N5` | `#8aadf4` | JLPT N5 underline color |
**Image Quality Notes:**
- `imageQuality` affects JPG and WebP only; PNG is lossless and ignores this setting
- JPG quality is mapped to FFmpeg's scale (2-31, lower = better)
- WebP quality uses FFmpeg's native 0-100 scale
### Subtitle Position
Set the initial vertical subtitle position (measured from the bottom of the screen):
```json
{
"subtitlePosition": {
"yPercent": 10
}
}
```
| Option | Values | Description |
| ---------- | ---------------- | ---------------------------------------------------------------------- |
| `yPercent` | number (0 - 100) | Distance from the bottom as a percent of screen height (default: `10`) |
In the overlay, you can fine-tune subtitle position at runtime with `Right-click + drag` on subtitle text.
### Secondary Subtitles
Display a second subtitle track (e.g., English alongside Japanese) in the overlay:
See `config.example.jsonc` for detailed configuration options.
```json
{
"secondarySub": {
"secondarySubLanguages": ["eng", "en"],
"autoLoadSecondarySub": true,
"defaultMode": "hover"
}
}
```
| Option | Values | Description |
| ----------------------- | ---------------------------------- | ------------------------------------------------------ |
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) |
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
**Display modes:**
- **hidden** — Secondary subtitles not shown
- **visible** — Always visible at top of overlay
- **hover** — Only visible when hovering over the subtitle area (default)
**See `config.example.jsonc`** for additional secondary subtitle configuration options.
## Keyboard & Controls
### Keybindings
Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv:
See `config.example.jsonc` for detailed configuration options and more examples.
**Default keybindings:**
| Key | Command | Description |
| ----------------- | ---------------------------- | ------------------------------------- |
| `Space` | `["cycle", "pause"]` | Toggle pause |
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
| `KeyQ` | `["quit"]` | Quit mpv |
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
**Custom keybindings example:**
```json
{
"keybindings": [
{ "key": "ArrowRight", "command": ["seek", 5] },
{ "key": "ArrowLeft", "command": ["seek", -5] },
{ "key": "Shift+ArrowRight", "command": ["seek", 30] },
{ "key": "KeyR", "command": ["script-binding", "immersive/auto-replay"] },
{ "key": "KeyA", "command": ["script-message", "ankiconnect-add-note"] }
]
}
```
**Key format:** Use `KeyboardEvent.code` values (`Space`, `ArrowRight`, `KeyR`, etc.) with optional modifiers (`Ctrl+`, `Alt+`, `Shift+`, `Meta+`).
**Disable a default binding:** Set command to `null`:
```json
{ "key": "Space", "command": null }
```
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`), SubMiner also shows an mpv OSD notification after the command runs.
**See `config.example.jsonc`** for more keybinding examples and configuration options.
### Shortcuts Configuration
Customize or disable the overlay keyboard shortcuts:
See `config.example.jsonc` for detailed configuration options.
```json
{
"shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
"copySubtitle": "CommandOrControl+C",
"copySubtitleMultiple": "CommandOrControl+Shift+C",
"updateLastCardFromClipboard": "CommandOrControl+V",
"triggerFieldGrouping": "CommandOrControl+G",
"triggerSubsync": "Ctrl+Alt+S",
"mineSentence": "CommandOrControl+S",
"mineSentenceMultiple": "CommandOrControl+Shift+S",
"markAudioCard": "CommandOrControl+Shift+A",
"openRuntimeOptions": "CommandOrControl+Shift+O",
"openJimaku": "Ctrl+Shift+J",
"multiCopyTimeoutMs": 3000
}
}
```
| Option | Values | Description |
| ----------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `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"`) |
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
| `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`) |
| `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"`) |
| `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`) |
| `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"`) |
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
Set any shortcut to `null` to disable it.
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
### 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:
| Shortcut | Action |
| -------------- | ------------------------------------------------------------------------------------------------------------------ |
| `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+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+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+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+O` | Open runtime options palette (session-only live toggles) |
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
**Multi-line copy workflow:**
1. Press `Ctrl+Shift+C`
2. Press a number key (`1-9`) within 3 seconds
3. The specified number of most recent subtitle lines are copied
4. Press `Ctrl+V` to update the last added card with the copied lines
These shortcuts are only active when the overlay window is visible and automatically disabled when hidden.
### Session Help Modal
The session help modal is opened with `Y-H` by default (falls back to `Y-K` if needed) and shows the current session keybindings and color legend.
You can filter the modal quickly with `/`:
- Type any part of the action name or shortcut in the search bar.
- Search is case-insensitive and ignores spaces/punctuation (`+`, `-`, `_`, `/`) so `ctrl w`, `ctrl+w`, and `ctrl+s` all match.
- Results are filtered across active MPV shortcuts, configured overlay shortcuts, and color legend items.
While the modal is open:
- `Esc`: close the modal (or clear the filter when text is entered)
- `↑/↓`, `j/k`: move selection
- Mouse/trackpad: click to select and activate rows
The list is generated at runtime from:
- Your active mpv keybindings (`keybindings`).
- Your configured overlay shortcuts (`shortcuts`, including runtime-loaded config values).
- Current subtitle color settings from `subtitleStyle`.
When config hot-reload updates shortcut/keybinding/style values, close and reopen the help modal to refresh the displayed entries.
### Runtime Option Palette
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:
- `ankiConnect.behavior.autoUpdateNewCards` (`On` / `Off`)
- `ankiConnect.nPlusOne.highlightEnabled` (`On` / `Off`)
- `subtitleStyle.enableJlpt` (`On` / `Off`)
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
- `ankiConnect.nPlusOne.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`
Palette controls:
- `Arrow Up/Down`: select option
- `Arrow Left/Right`: change selected value
- `Enter`: apply selected value
- `Esc`: close
## Anki Integration
### AnkiConnect ### AnkiConnect
Enable automatic Anki card creation and updates with media generation: Enable automatic Anki card creation and updates with media generation:
@@ -663,28 +223,13 @@ This example is intentionally compact. The option table below documents availabl
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | | `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | | `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
### Kiku/Lapis Integration **Kiku / Lapis Note Type Support:**
SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) workflows, with note-type-specific behavior built into Anki settings. SubMiner supports the [Lapis](https://github.com/donkuri/lapis) and [Kiku](https://kiku.youyoumu.my.id/) note types. Both `isLapis.enabled` and `isKiku.enabled` can be true; Kiku takes precedence for grouping behavior, while sentence-card model/field settings come from `isLapis`.
```jsonc When enabled, sentence cards automatically set `IsSentenceCard` to `"x"` and populate the `Expression` field. Audio cards set `IsAudioCard` to `"x"`.
"ankiConnect": {
"isLapis": {
"enabled": true,
"sentenceCardModel": "Japanese sentences"
},
"isKiku": {
"enabled": true,
"fieldGrouping": "manual",
"deleteDuplicateInAuto": true
}
}
```
- Enable `isLapis` to mine dedicated sentence cards. SubMiner sets `IsSentenceCard` to `"x"` and fills the sentence fields for the configured model. Kiku extends Lapis with **field grouping** — when a duplicate card is detected (same Word/Expression), SubMiner merges the two cards' content into one using Kiku's `data-group-id` HTML structure, organizing each mining instance into separate pages within the note.
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
### N+1 Word Highlighting ### N+1 Word Highlighting
@@ -736,27 +281,77 @@ To refresh roughly once per day, set:
<a :href="'/assets/kiku-integration.webm'" target="_blank" rel="noreferrer">Open demo in a new tab</a> <a :href="'/assets/kiku-integration.webm'" target="_blank" rel="noreferrer">Open demo in a new tab</a>
## External Integrations **Image Quality Notes:**
### Jimaku - `imageQuality` affects JPG and WebP only; PNG is lossless and ignores this setting
- JPG quality is mapped to FFmpeg's scale (2-31, lower = better)
- WebP quality uses FFmpeg's native 0-100 scale
Configure Jimaku API access and defaults: ### 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:
| Shortcut | Action |
| -------------- | ------------------------------------------------------------------------------------------------------------------ |
| `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+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+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+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+O` | Open runtime options palette (session-only live toggles) |
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
**Multi-line copy workflow:**
1. Press `Ctrl+Shift+C`
2. Press a number key (`1-9`) within 3 seconds
3. The specified number of most recent subtitle lines are copied
4. Press `Ctrl+V` to update the last added card with the copied lines
These shortcuts are only active when the overlay window is visible and automatically disabled when hidden.
### Session help modal
The session help modal is opened with `Y-H` by default (falls back to `Y-K` if needed) and shows the current session keybindings and color legend.
You can filter the modal quickly with `/`:
- Type any part of the action name or shortcut in the search bar.
- Search is case-insensitive and ignores spaces/punctuation (`+`, `-`, `_`, `/`) so `ctrl w`, `ctrl+w`, and `ctrl+s` all match.
- Results are filtered across active MPV shortcuts, configured overlay shortcuts, and color legend items.
While the modal is open:
- `Esc`: close the modal (or clear the filter when text is entered)
- `↑/↓`, `j/k`: move selection
- Mouse/trackpad: click to select and activate rows
The list is generated at runtime from:
- Your active mpv keybindings (`keybindings`).
- Your configured overlay shortcuts (`shortcuts`, including runtime-loaded config values).
- Current subtitle color settings from `subtitleStyle`.
When config hot-reload updates shortcut/keybinding/style values, close and reopen the help modal to refresh the displayed entries.
### Auto-Start Overlay
Control whether the overlay automatically becomes visible when it connects to mpv:
```json ```json
{ {
"jimaku": { "auto_start_overlay": false
"apiKey": "YOUR_API_KEY",
"apiKeyCommand": "cat ~/.jimaku_key",
"apiBaseUrl": "https://jimaku.cc",
"languagePreference": "ja",
"maxEntryResults": 10
}
} }
``` ```
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response. | Option | Values | Description |
| -------------------- | --------------- | ------------------------------------------------------ |
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) |
Set `openBrowser` to `false` to only print the URL without opening a browser. The mpv plugin controls startup overlay visibility via `auto_start_visible_overlay` in `subminer.conf`.
### Auto Subtitle Sync ### Auto Subtitle Sync
@@ -783,6 +378,35 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`. Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
Customize it there, or set it to `null` to disable. Customize it there, or set it to `null` to disable.
### Subtitle Position Edit
Subtitle positioning can be adjusted directly in the overlay:
- `Ctrl/Cmd+Shift+P` toggles position edit mode.
- Use arrow keys to move subtitle text.
- Press `Enter` or `Ctrl/Cmd+S` to save, or `Esc` to cancel.
- This edit-mode shortcut is fixed (not currently configurable in `shortcuts`/`keybindings`).
### Jimaku
Configure Jimaku API access and defaults:
```json
{
"jimaku": {
"apiKey": "YOUR_API_KEY",
"apiKeyCommand": "cat ~/.jimaku_key",
"apiBaseUrl": "https://jimaku.cc",
"languagePreference": "ja",
"maxEntryResults": 10
}
}
```
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
Set `openBrowser` to `false` to only print the URL without opening a browser.
### AniList ### AniList
AniList integration is opt-in and disabled by default. Enable it to allow SubMiner to update watched episode progress after playback. AniList integration is opt-in and disabled by default. Enable it to allow SubMiner to update watched episode progress after playback.
@@ -875,7 +499,6 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
| `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. Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup.
- 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.
Launcher subcommands: Launcher subcommands:
@@ -930,6 +553,308 @@ Troubleshooting:
- If images do not render, confirm asset keys exactly match uploaded Discord asset names. - If images do not render, confirm asset keys exactly match uploaded Discord asset names.
- If Discord is closed/not installed/disconnects, SubMiner continues running and quietly skips presence updates. - If Discord is closed/not installed/disconnects, SubMiner continues running and quietly skips presence updates.
### Keybindings
Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv:
See `config.example.jsonc` for detailed configuration options and more examples.
**Default keybindings:**
| Key | Command | Description |
| ----------------- | -------------------------- | ------------------------------------- |
| `Space` | `["cycle", "pause"]` | Toggle pause |
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
| `KeyQ` | `["quit"]` | Quit mpv |
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
**Custom keybindings example:**
```json
{
"keybindings": [
{ "key": "ArrowRight", "command": ["seek", 5] },
{ "key": "ArrowLeft", "command": ["seek", -5] },
{ "key": "Shift+ArrowRight", "command": ["seek", 30] },
{ "key": "KeyR", "command": ["script-binding", "immersive/auto-replay"] },
{ "key": "KeyA", "command": ["script-message", "ankiconnect-add-note"] }
]
}
```
**Key format:** Use `KeyboardEvent.code` values (`Space`, `ArrowRight`, `KeyR`, etc.) with optional modifiers (`Ctrl+`, `Alt+`, `Shift+`, `Meta+`).
**Disable a default binding:** Set command to `null`:
```json
{ "key": "Space", "command": null }
```
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
**See `config.example.jsonc`** for more keybinding examples and configuration options.
### Runtime Option Palette
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:
- `ankiConnect.behavior.autoUpdateNewCards` (`On` / `Off`)
- `ankiConnect.isKiku.fieldGrouping` (`auto` / `manual` / `disabled`)
Default shortcut: `Ctrl+Shift+O`
Palette controls:
- `Arrow Up/Down`: select option
- `Arrow Left/Right`: change selected value
- `Enter`: apply selected value
- `Esc`: close
### Secondary Subtitles
Display a second subtitle track (e.g., English alongside Japanese) in the overlay:
See `config.example.jsonc` for detailed configuration options.
```json
{
"secondarySub": {
"secondarySubLanguages": ["eng", "en"],
"autoLoadSecondarySub": true,
"defaultMode": "hover"
}
}
```
| Option | Values | Description |
| ----------------------- | ---------------------------------- | ------------------------------------------------------ |
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) |
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
**Display modes:**
- **hidden** — Secondary subtitles not shown
- **visible** — Always visible at top of overlay
- **hover** — Only visible when hovering over the subtitle area (default)
**See `config.example.jsonc`** for additional secondary subtitle configuration options.
### Shortcuts Configuration
Customize or disable the overlay keyboard shortcuts:
See `config.example.jsonc` for detailed configuration options.
```json
{
"shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
"copySubtitle": "CommandOrControl+C",
"copySubtitleMultiple": "CommandOrControl+Shift+C",
"updateLastCardFromClipboard": "CommandOrControl+V",
"triggerFieldGrouping": "CommandOrControl+G",
"triggerSubsync": "Ctrl+Alt+S",
"mineSentence": "CommandOrControl+S",
"mineSentenceMultiple": "CommandOrControl+Shift+S",
"markAudioCard": "CommandOrControl+Shift+A",
"openRuntimeOptions": "CommandOrControl+Shift+O",
"openJimaku": "Ctrl+Shift+J",
"multiCopyTimeoutMs": 3000
}
}
```
| Option | Values | Description |
| ------------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `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"`) |
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
| `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`) |
| `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"`) |
| `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`) |
| `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"`) |
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
Set any shortcut to `null` to disable it.
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
### Subtitle Position
Set the initial vertical subtitle position (measured from the bottom of the screen):
```json
{
"subtitlePosition": {
"yPercent": 10
}
}
```
| Option | Values | Description |
| ---------- | ---------------- | ---------------------------------------------------------------------- |
| `yPercent` | number (0 - 100) | Distance from the bottom as a percent of screen height (default: `10`) |
### Subtitle Style
Customize the appearance of primary and secondary subtitles:
See `config.example.jsonc` for detailed configuration options.
```json
{
"subtitleStyle": {
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP",
"fontSize": 35,
"fontColor": "#cad3f5",
"fontWeight": "600",
"lineHeight": 1.35,
"letterSpacing": "-0.01em",
"wordSpacing": 0,
"fontKerning": "normal",
"textRendering": "geometricPrecision",
"textShadow": "0 3px 10px rgba(0,0,0,0.69)",
"fontStyle": "normal",
"backgroundColor": "rgb(30, 32, 48, 0.88)",
"backdropFilter": "blur(6px)",
"secondary": {
"fontFamily": "Manrope, Inter",
"fontSize": 24,
"fontColor": "#cad3f5",
"backgroundColor": "transparent"
}
}
}
```
| Option | Values | Description |
| ---------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------- |
| `fontFamily` | string | CSS font-family value (default: `"M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP"`) |
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
| `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: `"rgb(30, 32, 48, 0.88)"`) |
| `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. |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
| `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 |
| `nPlusOneColor` | string | Existing n+1 highlight color (default: `#c6a0f6`) |
| `knownWordColor` | string | Existing known-word highlight color (default: `#a6da95`) |
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
| `secondary` | object | Override any of the above for secondary subtitles (optional) |
JLPT underlining is powered by offline term-meta bank files at runtime. See [`docs/jlpt-vocab-bundle.md`](jlpt-vocab-bundle.md) for required files, source/version refresh steps, and deterministic fallback behavior.
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:
- Set `frequencyDictionary.sourcePath` to 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).
- In both cases, only terms with a valid `frequencyRank` are used; everything else falls back to no highlighting.
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.
Secondary subtitle defaults: `fontFamily: "Manrope, Inter"`, `fontSize: 24`, `fontColor: "#cad3f5"`, `backgroundColor: "transparent"`. Any property not set in `secondary` falls back to the CSS defaults.
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
`jlptColors` keys are:
| Key | Default | Description |
| ---- | --------- | ----------------------- |
| `N1` | `#ed8796` | JLPT N1 underline color |
| `N2` | `#f5a97f` | JLPT N2 underline color |
| `N3` | `#f9e2af` | JLPT N3 underline color |
| `N4` | `#a6e3a1` | JLPT N4 underline color |
| `N5` | `#8aadf4` | JLPT N5 underline color |
### Texthooker
Control whether the browser opens automatically when texthooker starts:
See `config.example.jsonc` for detailed configuration options.
```json
{
"texthooker": {
"openBrowser": true
}
}
```
### WebSocket Server
The overlay includes a built-in WebSocket server that broadcasts subtitle text to connected clients (such as texthooker-ui) for external processing.
By default, the server uses "auto" mode: it starts automatically unless [mpv_websocket](https://github.com/kuroahna/mpv_websocket) is detected at `~/.config/mpv/mpv_websocket`. If you have mpv_websocket installed, the built-in server is skipped to avoid conflicts.
See `config.example.jsonc` for detailed configuration options.
```json
{
"websocket": {
"enabled": "auto",
"port": 6677
}
}
```
| Option | Values | Description |
| --------- | ------------------------- | -------------------------------------------------------- |
| `enabled` | `true`, `false`, `"auto"` | `"auto"` (default) disables if mpv_websocket is detected |
| `port` | number | WebSocket server port (default: 6677) |
### Startup Warmups
Control which startup warmups run in the background versus deferring to first real usage:
```json
{
"startupWarmups": {
"lowPowerMode": false,
"mecab": true,
"yomitanExtension": true,
"subtitleDictionaries": true,
"jellyfinRemoteSession": true
}
}
```
| Option | Values | Description |
| ------------------------ | --------------- | ------------------------------------------------------------------------------------------------ |
| `lowPowerMode` | `true`, `false` | Defer all warmups except Yomitan extension |
| `mecab` | `true`, `false` | Warm up MeCab tokenizer at startup |
| `yomitanExtension` | `true`, `false` | Warm up Yomitan extension at startup |
| `subtitleDictionaries` | `true`, `false` | Warm up JLPT + frequency dictionaries at startup |
| `jellyfinRemoteSession` | `true`, `false` | Warm up Jellyfin remote session at startup (still requires Jellyfin remote auto-connect settings) |
Defaults warm everything (`true` for all toggles, `lowPowerMode: false`). Setting a warmup toggle to `false` defers that work until first usage.
### Immersion Tracking ### Immersion Tracking
Enable or disable local immersion analytics stored in SQLite for mined subtitles and media sessions: Enable or disable local immersion analytics stored in SQLite for mined subtitles and media sessions:

View File

@@ -1,72 +0,0 @@
# Feature Demos
Short recordings of SubMiner's key features and integrations from real playback sessions.
<script setup>
const v = '20260301-1';
</script>
## Anki Card Mining & Enrichment
Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner automatically attaches the sentence, a timing-accurate audio clip, a screenshot, and a translation.
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`">
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
</a>
</video>
::: info VIDEO COMING SOON
:::
## Subtitle Download & Sync
Search and download subtitles from Jimaku, then automatically synchronize them with alass or ffsubsync — all from within SubMiner.
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/subtitle-sync-poster.jpg?v=${v}`">
<source :src="`/assets/demos/subtitle-sync.webm?v=${v}`" type="video/webm" />
<source :src="`/assets/demos/subtitle-sync.mp4?v=${v}`" type="video/mp4" />
</video> -->
::: info VIDEO COMING SOON
:::
## Jellyfin Integration
Browse your Jellyfin library, cast to devices, and launch playback directly from SubMiner. Watch progress syncs back to your Jellyfin server.
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/jellyfin-poster.jpg?v=${v}`">
<source :src="`/assets/demos/jellyfin.webm?v=${v}`" type="video/webm" />
<source :src="`/assets/demos/jellyfin.mp4?v=${v}`" type="video/mp4" />
</video> -->
::: info VIDEO COMING SOON
:::
## Texthooker
Open subtitles in an external texthooker page for use with browser-based tools and extensions alongside the overlay.
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/texthooker-poster.jpg?v=${v}`">
<source :src="`/assets/demos/texthooker.webm?v=${v}`" type="video/webm" />
<source :src="`/assets/demos/texthooker.mp4?v=${v}`" type="video/mp4" />
</video> -->
::: info VIDEO COMING SOON
:::
<style>
video {
width: 100%;
border-radius: 12px;
border: 1px solid var(--vp-c-divider);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
margin: 0.75rem 0 2.5rem;
}
h2 {
margin-top: 2.5rem !important;
}
</style>

View File

@@ -6,14 +6,11 @@ SubMiner stores immersion analytics in local SQLite (`immersion.sqlite`) by defa
- Write path is asynchronous and queue-backed. - Write path is asynchronous and queue-backed.
- Hot paths (subtitle parsing/render/token flows) enqueue telemetry/events and never await SQLite writes. - Hot paths (subtitle parsing/render/token flows) enqueue telemetry/events and never await SQLite writes.
- Background line processing also upserts to `imm_words` and `imm_kanji`.
- Queue overflow policy is deterministic: drop oldest queued writes, keep newest. - Queue overflow policy is deterministic: drop oldest queued writes, keep newest.
- Flush policy defaults to `25` writes or `500ms` max delay. - Flush policy defaults to `25` writes or `500ms` max delay.
- SQLite pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=2500`. - SQLite pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=2500`.
- Rollups now run incrementally from the last processed telemetry sample; startup performs a one-time bootstrap rebuild-equivalent pass.
- If retention pruning removes telemetry/session rows, maintenance triggers a full rollup rebuild to resync historical aggregates.
## Schema (v3) ## Schema (v1)
Schema versioning table: Schema versioning table:
@@ -21,21 +18,15 @@ Schema versioning table:
Core entities: Core entities:
- `imm_videos`: video key/title/source metadata + optional media metadata fields, `CREATED_DATE`/`LAST_UPDATE_DATE` - `imm_videos`: video key/title/source metadata + optional media metadata fields
- `imm_sessions`: session UUID, video reference, timing/status fields, `CREATED_DATE`/`LAST_UPDATE_DATE` - `imm_sessions`: session UUID, video reference, timing/status fields
- `imm_session_telemetry`: high-frequency session aggregates over time, `CREATED_DATE`/`LAST_UPDATE_DATE` - `imm_session_telemetry`: high-frequency session aggregates over time
- `imm_session_events`: event stream with compact numeric event types, `CREATED_DATE`/`LAST_UPDATE_DATE` - `imm_session_events`: event stream with compact numeric event types
Rollups: Rollups:
- `imm_daily_rollups`: includes `CREATED_DATE`/`LAST_UPDATE_DATE` - `imm_daily_rollups`
- `imm_monthly_rollups`: includes `CREATED_DATE`/`LAST_UPDATE_DATE` - `imm_monthly_rollups`
Vocabulary:
- `imm_words(id, headword, word, reading, first_seen, last_seen, frequency)`
- `imm_kanji(id, kanji, first_seen, last_seen, frequency)`
- `first_seen`/`last_seen` store Unix timestamps and are upserted with line ingestion
Primary index coverage: Primary index coverage:
@@ -156,3 +147,4 @@ FROM imm_monthly_rollups
ORDER BY rollup_month DESC, video_id DESC ORDER BY rollup_month DESC, video_id DESC
LIMIT ?; LIMIT ?;
``` ```

View File

@@ -6,19 +6,9 @@ const docsIndexContents = readFileSync(docsIndexPath, 'utf8');
test('docs demo media uses shared cache-busting asset version token', () => { test('docs demo media uses shared cache-busting asset version token', () => {
expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/); expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/);
expect(docsIndexContents).toContain( expect(docsIndexContents).toContain(':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"');
':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"', expect(docsIndexContents).toContain('<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />');
); expect(docsIndexContents).toContain('<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />');
expect(docsIndexContents).toContain( expect(docsIndexContents).toContain('<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">');
'<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />', expect(docsIndexContents).toContain('<img :src="`/assets/minecard.gif?v=${demoAssetVersion}`" alt="SubMiner demo GIF fallback" style="width: 100%; height: auto;" />');
);
expect(docsIndexContents).toContain(
'<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />',
);
expect(docsIndexContents).toContain(
'<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">',
);
expect(docsIndexContents).toContain(
'<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />',
);
}); });

View File

@@ -95,7 +95,7 @@ const demoAssetVersion = '20260223-2';
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" /> <source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" /> <source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer"> <a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" /> <img :src="`/assets/minecard.gif?v=${demoAssetVersion}`" alt="SubMiner demo GIF fallback" style="width: 100%; height: auto;" />
</a> </a>
</video> </video>
</section> </section>

View File

@@ -194,10 +194,7 @@ See [MPV Plugin](/mpv-plugin) for the full configuration reference, script messa
After installing, confirm SubMiner is working: After installing, confirm SubMiner is working:
```bash ```bash
# Play a file (default plugin config auto-starts visible overlay and waits for annotation readiness) # Start the overlay (connects to mpv IPC)
subminer video.mkv
# Optional explicit overlay start for setups with plugin auto_start=no
subminer --start video.mkv subminer --start video.mkv
# Useful launch modes for troubleshooting # Useful launch modes for troubleshooting

View File

@@ -17,7 +17,7 @@ subminer -r -d ~/Anime # recursive search
fzf shows video files in a fuzzy-searchable list. If `chafa` is installed, you get thumbnail previews in the right pane. Thumbnails are sourced from the freedesktop thumbnail cache first, then generated on the fly with `ffmpegthumbnailer` or `ffmpeg` as fallback. fzf shows video files in a fuzzy-searchable list. If `chafa` is installed, you get thumbnail previews in the right pane. Thumbnails are sourced from the freedesktop thumbnail cache first, then generated on the fly with `ffmpegthumbnailer` or `ffmpeg` as fallback.
| Optional tool | Purpose | | Optional tool | Purpose |
| ------------------- | --------------------------------- | | --------------------- | -------------------------------- |
| `chafa` | Render thumbnails in the terminal | | `chafa` | Render thumbnails in the terminal |
| `ffmpegthumbnailer` | Generate thumbnails on the fly | | `ffmpegthumbnailer` | Generate thumbnails on the fly |
@@ -53,9 +53,8 @@ SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
## Common Commands ## Common Commands
```bash ```bash
subminer video.mkv # play a specific file (default plugin config auto-starts visible overlay) subminer video.mkv # play a specific file
subminer --start video.mkv # optional explicit overlay start when plugin auto_start=no subminer --start video.mkv # play + explicitly start overlay
subminer -S video.mkv # same as above via --start-overlay
subminer https://youtu.be/... # YouTube playback (requires yt-dlp) subminer https://youtu.be/... # YouTube playback (requires yt-dlp)
subminer ytsearch:"jp news" # YouTube search subminer ytsearch:"jp news" # YouTube search
``` ```
@@ -63,7 +62,7 @@ subminer ytsearch:"jp news" # YouTube search
## Subcommands ## Subcommands
| Subcommand | Purpose | | Subcommand | Purpose |
| -------------------------- | ---------------------------------------------------------- | | ------------------------- | ---------------------------------------------- |
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) | | `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) |
| `subminer yt` / `youtube` | YouTube shorthand (`-o`, `-m`) | | `subminer yt` / `youtube` | YouTube shorthand (`-o`, `-m`) |
| `subminer doctor` | Dependency + config + socket diagnostics | | `subminer doctor` | Dependency + config + socket diagnostics |
@@ -80,22 +79,20 @@ Use `subminer <subcommand> -h` for command-specific help.
## Options ## Options
| Flag | Description | | Flag | Description |
| ----------------------- | --------------------------------------------------- | | -------------------- | -------------------------------------------- |
| `-d, --directory` | Video search directory (default: cwd) | | `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively | | `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf | | `-R, --rofi` | Use rofi instead of fzf |
| `--start` | Explicitly start overlay after mpv launches | | `-S, --start` | Start overlay after mpv launches |
| `-S, --start-overlay` | Explicitly start overlay after mpv launches | | `-T, --no-texthooker`| Disable texthooker server |
| `-T, --no-texthooker` | Disable texthooker server |
| `-p, --profile` | mpv profile name (default: `subminer`) | | `-p, --profile` | mpv profile name (default: `subminer`) |
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) | | `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) | | `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) | | `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
## Logging ## Logging
- Default log level is `info` - Default log level is `info`
- `--background` mode defaults to `warn` unless `--log-level` is explicitly set - `--background` mode defaults to `warn` unless `--log-level` is explicitly set
- `--dev` / `--debug` control app behavior, not logging verbosity — use `--log-level` for that - `--dev` / `--debug` control app behavior, not logging verbosity — use `--log-level` for that

View File

@@ -33,7 +33,6 @@ SubMiner uses one overlay window with modal surfaces.
The visible overlay renders subtitles as tokenized, clickable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports: The visible overlay renders subtitles as tokenized, clickable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports:
- Word-level click targets for Yomitan lookup - Word-level click targets for Yomitan lookup
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
- Right-click to pause/resume - Right-click to pause/resume
- Right-click + drag to reposition subtitles - Right-click + drag to reposition subtitles
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options - Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
@@ -101,7 +100,7 @@ If you prefer a hands-on approach (animecards-style), you can copy the current s
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card. This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
| Shortcut | Action | Config key | | Shortcut | Action | Config key |
| -------------------------- | ------------------------------- | --------------------------------------- | | --------------------------- | ----------------------------------------- | ------------------------------------- |
| `Ctrl/Cmd+C` | Copy current subtitle | `shortcuts.copySubtitle` | | `Ctrl/Cmd+C` | Copy current subtitle | `shortcuts.copySubtitle` |
| `Ctrl/Cmd+Shift+C` + digit | Copy multiple recent lines | `shortcuts.copySubtitleMultiple` | | `Ctrl/Cmd+Shift+C` + digit | Copy multiple recent lines | `shortcuts.copySubtitleMultiple` |
| `Ctrl/Cmd+V` | Update last card from clipboard | `shortcuts.updateLastCardFromClipboard` | | `Ctrl/Cmd+V` | Update last card from clipboard | `shortcuts.updateLastCardFromClipboard` |

View File

@@ -30,7 +30,7 @@ input-ipc-server=/tmp/subminer-socket
All keybindings use a `y` chord prefix — press `y`, then the second key: All keybindings use a `y` chord prefix — press `y`, then the second key:
| Chord | Action | | Chord | Action |
| ----- | ---------------------- | | ----- | ------------------------ |
| `y-y` | Open menu | | `y-y` | Open menu |
| `y-s` | Start overlay | | `y-s` | Start overlay |
| `y-S` | Stop overlay | | `y-S` | Stop overlay |
@@ -78,15 +78,11 @@ backend=auto
# Start the overlay automatically when a file is loaded. # Start the overlay automatically when a file is loaded.
# Runs only when mpv input-ipc-server matches socket_path. # Runs only when mpv input-ipc-server matches socket_path.
auto_start=yes auto_start=no
# Show the visible overlay on auto-start. # Show the visible overlay on auto-start.
# Runs only when mpv input-ipc-server matches socket_path. # Runs only when mpv input-ipc-server matches socket_path.
auto_start_visible_overlay=yes auto_start_visible_overlay=no
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
# Requires auto_start=yes and auto_start_visible_overlay=yes.
auto_start_pause_until_ready=yes
# Show OSD messages for overlay status changes. # Show OSD messages for overlay status changes.
osd_messages=yes osd_messages=yes
@@ -121,15 +117,14 @@ aniskip_button_duration=3
### Option Reference ### Option Reference
| Option | Default | Values | Description | | Option | Default | Values | Description |
| ---------------------------- | ----------------------------- | ------------------------------------------ | ---------------------------------------------------------------------- | | ------------------------------ | ---------------------- | ------------------------------------------ | -------------------------------- |
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary | | `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path | | `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server | | `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
| `texthooker_port` | `5174` | 165535 | Texthooker server port | | `texthooker_port` | `5174` | 165535 | Texthooker server port |
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend | | `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
| `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` | | `auto_start` | `no` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
| `auto_start_visible_overlay` | `yes` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` | | `auto_start_visible_overlay` | `no` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
| `auto_start_pause_until_ready` | `yes` | `yes` / `no` | Pause mpv on visible auto-start; resume when SubMiner signals tokenization-ready |
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages | | `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity | | `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection | | `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
@@ -186,7 +181,6 @@ script-message subminer-menu
script-message subminer-options script-message subminer-options
script-message subminer-restart script-message subminer-restart
script-message subminer-status script-message subminer-status
script-message subminer-autoplay-ready
script-message subminer-aniskip-refresh script-message subminer-aniskip-refresh
script-message subminer-skip-intro script-message subminer-skip-intro
``` ```
@@ -217,8 +211,6 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
## Lifecycle ## Lifecycle
- **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).
- **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts).
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server. - **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first. - **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.

View File

@@ -13,12 +13,10 @@
### Task 1: Add Regression Tests For Main Overlay Secondary Rendering ### Task 1: Add Regression Tests For Main Overlay Secondary Rendering
**Files:** **Files:**
- Modify: `src/renderer/subtitle-render.test.ts` - Modify: `src/renderer/subtitle-render.test.ts`
- Modify: `src/renderer/error-recovery.test.ts` - Modify: `src/renderer/error-recovery.test.ts`
**Step 1: Write failing tests** **Step 1: Write failing tests**
- Assert stylesheet no longer hides secondary subtitles in `layer-visible`. - Assert stylesheet no longer hides secondary subtitles in `layer-visible`.
- Assert renderer platform resolution ignores legacy `secondary` overlay layer. - Assert renderer platform resolution ignores legacy `secondary` overlay layer.
@@ -30,14 +28,12 @@ Expected: FAIL on secondary subtitle hide rule + legacy secondary layer handling
### Task 2: Remove Secondary-Window CSS/Routing Assumptions ### Task 2: Remove Secondary-Window CSS/Routing Assumptions
**Files:** **Files:**
- Modify: `src/renderer/style.css` - Modify: `src/renderer/style.css`
- Modify: `src/renderer/utils/platform.ts` - Modify: `src/renderer/utils/platform.ts`
- Modify: `src/renderer/error-recovery.ts` - Modify: `src/renderer/error-recovery.ts`
- Modify: `src/types.ts` - Modify: `src/types.ts`
**Step 1: Implement minimal changes** **Step 1: Implement minimal changes**
- Remove legacy forced hide on `#secondarySubContainer`. - Remove legacy forced hide on `#secondarySubContainer`.
- Remove obsolete layer-specific secondary-subtitle CSS blocks. - Remove obsolete layer-specific secondary-subtitle CSS blocks.
- Drop legacy `secondary` overlay-layer parsing path from renderer platform resolver. - Drop legacy `secondary` overlay-layer parsing path from renderer platform resolver.
@@ -51,7 +47,6 @@ Expected: PASS.
### Task 3: Validate Wider Related Surface ### Task 3: Validate Wider Related Surface
**Files:** **Files:**
- No additional code changes required. - No additional code changes required.
**Step 1: Run broader related tests** **Step 1: Run broader related tests**

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 MiB

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 MiB

View File

@@ -5,6 +5,7 @@
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
*/ */
{ {
// ========================================== // ==========================================
// Overlay Auto-Start // Overlay Auto-Start
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles. // When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
@@ -16,7 +17,7 @@
// Control whether browser opens automatically for texthooker. // Control whether browser opens automatically for texthooker.
// ========================================== // ==========================================
"texthooker": { "texthooker": {
"openBrowser": true, // Open browser setting. Values: true | false "openBrowser": true // Open browser setting. Values: true | false
}, // Control whether browser opens automatically for texthooker. }, // Control whether browser opens automatically for texthooker.
// ========================================== // ==========================================
@@ -26,7 +27,7 @@
// ========================================== // ==========================================
"websocket": { "websocket": {
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false "enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
"port": 6677, // Built-in subtitle websocket server port. "port": 6677 // Built-in subtitle websocket server port.
}, // Built-in WebSocket server broadcasts subtitle text to connected clients. }, // Built-in WebSocket server broadcasts subtitle text to connected clients.
// ========================================== // ==========================================
@@ -35,7 +36,7 @@
// Set to debug for full runtime diagnostics. // Set to debug for full runtime diagnostics.
// ========================================== // ==========================================
"logging": { "logging": {
"level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
}, // Controls logging verbosity. }, // Controls logging verbosity.
// ========================================== // ==========================================
@@ -56,7 +57,7 @@
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting. "openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================
@@ -76,7 +77,7 @@
"secondarySub": { "secondarySub": {
"secondarySubLanguages": [], // Secondary sub languages setting. "secondarySubLanguages": [], // Secondary sub languages setting.
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false "autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
"defaultMode": "hover", // Default mode setting. "defaultMode": "hover" // Default mode setting.
}, // Dual subtitle track options. }, // Dual subtitle track options.
// ========================================== // ==========================================
@@ -87,7 +88,7 @@
"defaultMode": "auto", // Subsync default mode. Values: auto | manual "defaultMode": "auto", // Subsync default mode. Values: auto | manual
"alass_path": "", // Alass path setting. "alass_path": "", // Alass path setting.
"ffsubsync_path": "", // Ffsubsync path setting. "ffsubsync_path": "", // Ffsubsync path setting.
"ffmpeg_path": "", // Ffmpeg path setting. "ffmpeg_path": "" // Ffmpeg path setting.
}, // Subsync engine and executable paths. }, // Subsync engine and executable paths.
// ========================================== // ==========================================
@@ -95,7 +96,7 @@
// Initial vertical subtitle position from the bottom. // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
"subtitlePosition": { "subtitlePosition": {
"yPercent": 10, // Y percent setting. "yPercent": 10 // Y percent setting.
}, // Initial vertical subtitle position from the bottom. }, // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
@@ -106,7 +107,6 @@
"subtitleStyle": { "subtitleStyle": {
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. 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 "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
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv. "hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv. "hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting. "fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
@@ -129,19 +129,24 @@
"N2": "#f5a97f", // N2 setting. "N2": "#f5a97f", // N2 setting.
"N3": "#f9e2af", // N3 setting. "N3": "#f9e2af", // N3 setting.
"N4": "#a6e3a1", // N4 setting. "N4": "#a6e3a1", // N4 setting.
"N5": "#8aadf4", // N5 setting. "N5": "#8aadf4" // N5 setting.
}, // Jlpt colors setting. }, // Jlpt colors setting.
"frequencyDictionary": { "frequencyDictionary": {
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, SubMiner searches installed/default frequency-dictionary locations. "sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, SubMiner searches installed/default frequency-dictionary locations.
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000). "topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
"matchMode": "headword", // Frequency lookup text selection mode. Values: headword | surface
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX). "bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#a6e3a1",
"#8aadf4"
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting. }, // Frequency dictionary setting.
"secondary": { "secondary": {
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting. "fontFamily": "Manrope, Inter", // Font family setting.
"fontSize": 24, // Font size setting. "fontSize": 24, // Font size setting.
"fontColor": "#cad3f5", // Font color setting. "fontColor": "#cad3f5", // Font color setting.
"lineHeight": 1.35, // Line height setting. "lineHeight": 1.35, // Line height setting.
@@ -153,8 +158,8 @@
"backgroundColor": "transparent", // Background color setting. "backgroundColor": "transparent", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting. "backdropFilter": "blur(6px)", // Backdrop filter setting.
"fontWeight": "normal", // Font weight setting. "fontWeight": "normal", // Font weight setting.
"fontStyle": "normal", // Font style setting. "fontStyle": "normal" // Font style setting.
}, // Secondary setting. } // Secondary setting.
}, // Primary and secondary subtitle styling. }, // Primary and secondary subtitle styling.
// ========================================== // ==========================================
@@ -171,15 +176,17 @@
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false "enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy. "host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
"port": 8766, // Bind port for local AnkiConnect proxy. "port": 8766, // Bind port for local AnkiConnect proxy.
"upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. "upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
}, // Proxy setting. }, // Proxy setting.
"tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. "tags": [
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"fields": { "fields": {
"audio": "ExpressionAudio", // Audio setting. "audio": "ExpressionAudio", // Audio setting.
"image": "Picture", // Image setting. "image": "Picture", // Image setting.
"sentence": "Sentence", // Sentence setting. "sentence": "Sentence", // Sentence setting.
"miscInfo": "MiscInfo", // Misc info setting. "miscInfo": "MiscInfo", // Misc info setting.
"translation": "SelectionText", // Translation setting. "translation": "SelectionText" // Translation setting.
}, // Fields setting. }, // Fields setting.
"ai": { "ai": {
"enabled": false, // Enabled setting. Values: true | false "enabled": false, // Enabled setting. Values: true | false
@@ -188,7 +195,7 @@
"model": "openai/gpt-4o-mini", // Model setting. "model": "openai/gpt-4o-mini", // Model setting.
"baseUrl": "https://openrouter.ai/api", // Base url setting. "baseUrl": "https://openrouter.ai/api", // Base url setting.
"targetLanguage": "English", // Target language setting. "targetLanguage": "English", // Target language setting.
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting. "systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting.
}, // Ai setting. }, // Ai setting.
"media": { "media": {
"generateAudio": true, // Generate audio setting. Values: true | false "generateAudio": true, // Generate audio setting. Values: true | false
@@ -201,7 +208,7 @@
"animatedCrf": 35, // Animated crf setting. "animatedCrf": 35, // Animated crf setting.
"audioPadding": 0.5, // Audio padding setting. "audioPadding": 0.5, // Audio padding setting.
"fallbackDuration": 3, // Fallback duration setting. "fallbackDuration": 3, // Fallback duration setting.
"maxMediaDuration": 30, // Max media duration setting. "maxMediaDuration": 30 // Max media duration setting.
}, // Media setting. }, // Media setting.
"behavior": { "behavior": {
"overwriteAudio": true, // Overwrite audio setting. Values: true | false "overwriteAudio": true, // Overwrite audio setting. Values: true | false
@@ -209,7 +216,7 @@
"mediaInsertMode": "append", // Media insert mode setting. "mediaInsertMode": "append", // Media insert mode setting.
"highlightWord": true, // Highlight word setting. Values: true | false "highlightWord": true, // Highlight word setting. Values: true | false
"notificationType": "osd", // Notification type setting. "notificationType": "osd", // Notification type setting.
"autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting. }, // Behavior setting.
"nPlusOne": { "nPlusOne": {
"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
@@ -218,20 +225,20 @@
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names. "decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight. "nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
"knownWord": "#a6da95", // Color used for legacy known-word highlights. "knownWord": "#a6da95" // Color used for legacy known-word highlights.
}, // N plus one setting. }, // N plus one setting.
"metadata": { "metadata": {
"pattern": "[SubMiner] %f (%t)", // Pattern setting. "pattern": "[SubMiner] %f (%t)" // Pattern setting.
}, // Metadata setting. }, // Metadata setting.
"isLapis": { "isLapis": {
"enabled": false, // Enabled setting. Values: true | false "enabled": false, // Enabled setting. Values: true | false
"sentenceCardModel": "Japanese sentences", // Sentence card model setting. "sentenceCardModel": "Japanese sentences" // Sentence card model setting.
}, // Is lapis setting. }, // Is lapis setting.
"isKiku": { "isKiku": {
"enabled": false, // Enabled setting. Values: true | false "enabled": false, // Enabled setting. Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false "deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
}, // Is kiku setting. } // Is kiku setting.
}, // Automatic Anki updates and media generation options. }, // Automatic Anki updates and media generation options.
// ========================================== // ==========================================
@@ -241,7 +248,7 @@
"jimaku": { "jimaku": {
"apiBaseUrl": "https://jimaku.cc", // Api base url setting. "apiBaseUrl": "https://jimaku.cc", // Api base url setting.
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
"maxEntryResults": 10, // Maximum Jimaku search results returned. "maxEntryResults": 10 // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults. }, // Jimaku API configuration and defaults.
// ========================================== // ==========================================
@@ -252,7 +259,10 @@
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off "mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine. "whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
"whisperModel": "", // Path to whisper model used for fallback transcription. "whisperModel": "", // Path to whisper model used for fallback transcription.
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher. "primarySubLanguages": [
"ja",
"jpn"
] // Comma-separated primary subtitle language priority used by the launcher.
}, // Defaults for subminer YouTube subtitle extraction/transcription mode. }, // Defaults for subminer YouTube subtitle extraction/transcription mode.
// ========================================== // ==========================================
@@ -261,7 +271,7 @@
// ========================================== // ==========================================
"anilist": { "anilist": {
"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.
}, // Anilist API credentials and update behavior. }, // Anilist API credentials and update behavior.
// ========================================== // ==========================================
@@ -285,8 +295,16 @@
"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
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions. "directPlayContainers": [
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable. "mkv",
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
}, // Optional Jellyfin integration for auth, browsing, and playback launch. }, // Optional Jellyfin integration for auth, browsing, and playback launch.
// ========================================== // ==========================================
@@ -297,7 +315,7 @@
"discordPresence": { "discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false "enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"updateIntervalMs": 3000, // Minimum interval between presence payload updates. "updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750, // Debounce delay used to collapse bursty presence updates. "debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session. }, // Optional Discord Rich Presence activity card updates for current playback/study session.
// ========================================== // ==========================================
@@ -319,7 +337,7 @@
"telemetryDays": 30, // Telemetry retention window in days. "telemetryDays": 30, // Telemetry retention window in days.
"dailyRollupsDays": 365, // Daily rollup retention window in days. "dailyRollupsDays": 365, // Daily rollup retention window in days.
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days. "monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
"vacuumIntervalDays": 7, // Minimum days between VACUUM runs. "vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
}, // Retention setting. } // Retention setting.
}, // Enable/disable immersion tracking. } // Enable/disable immersion tracking.
} }

View File

@@ -7,7 +7,7 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi
These work system-wide regardless of which window has focus. These work system-wide regardless of which window has focus.
| Shortcut | Action | Configurable | | Shortcut | Action | Configurable |
| ------------- | ---------------------- | -------------------------------------- | | ------------- | ------------------------ | ---------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` | | `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` |
| `Alt+Shift+Y` | Open Yomitan settings | Fixed (not configurable) | | `Alt+Shift+Y` | Open Yomitan settings | Fixed (not configurable) |
@@ -38,8 +38,6 @@ These control playback and subtitle display. They require overlay window focus.
| Shortcut | Action | | Shortcut | Action |
| -------------------- | -------------------------------------------------- | | -------------------- | -------------------------------------------------- |
| `Space` | Toggle mpv pause | | `Space` | Toggle mpv pause |
| `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track |
| `ArrowRight` | Seek forward 5 seconds | | `ArrowRight` | Seek forward 5 seconds |
| `ArrowLeft` | Seek backward 5 seconds | | `ArrowLeft` | Seek backward 5 seconds |
| `ArrowUp` | Seek forward 60 seconds | | `ArrowUp` | Seek forward 60 seconds |
@@ -56,8 +54,6 @@ These control playback and subtitle display. They require overlay window focus.
These keybindings can be overridden or disabled via the `keybindings` config array. These keybindings can be overridden or disabled via the `keybindings` config array.
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
## Subtitle & Feature Shortcuts ## Subtitle & Feature Shortcuts
| Shortcut | Action | Config key | | Shortcut | Action | Config key |
@@ -67,12 +63,24 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | | `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
## Subtitle Position Edit Mode
Enter edit mode to fine-tune subtitle alignment.
| Shortcut | Action |
| --------------------- | -------------------------------- |
| `Ctrl/Cmd+Shift+P` | Toggle position edit mode |
| `ArrowKeys` or `hjkl` | Nudge position by 1 px |
| `Shift+Arrow` | Nudge position by 4 px |
| `Enter` or `Ctrl+S` | Save position and exit edit mode |
| `Esc` | Cancel and discard changes |
## MPV Plugin Chords ## MPV Plugin Chords
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second. When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
| Chord | Action | | Chord | Action |
| ----- | ------------------------ | | ----- | --------------------------------------- |
| `y-y` | Open SubMiner menu (OSD) | | `y-y` | Open SubMiner menu (OSD) |
| `y-s` | Start overlay | | `y-s` | Start overlay |
| `y-S` | Stop overlay | | `y-S` | Stop overlay |

View File

@@ -159,7 +159,7 @@ SubMiner positions the overlay by tracking the mpv window. If tracking fails:
- Sway: Ensure `swaymsg` is available. - Sway: Ensure `swaymsg` is available.
- X11: Ensure `xdotool` and `xwininfo` are installed. - X11: Ensure `xdotool` and `xwininfo` are installed.
If the overlay position is slightly off, right-click and drag on subtitle text to fine-tune the overlay subtitle offset. If the overlay position is slightly off, use subtitle position edit mode (`Ctrl/Cmd+Shift+P`) to fine-tune the offset with arrow keys, then save with `Enter` or `Ctrl+S`.
## Yomitan ## Yomitan
@@ -279,5 +279,5 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati
### macOS ### macOS
- **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility. - **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility.
- **Font rendering**: macOS uses a 0.87x font compensation factor for subtitle alignment between mpv and the overlay. If text alignment looks off, adjust subtitle offset by right-click dragging subtitle text. - **Font rendering**: macOS uses a 0.87x font compensation factor for subtitle alignment between mpv and the overlay. If text alignment looks off, adjust subtitle offset in position edit mode.
- **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app` - **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app`

View File

@@ -1,19 +1,15 @@
# Usage # Usage
> [!IMPORTANT]
> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
> See [Yomitan setup](#yomitan-setup) for details.
There are two ways to use SubMiner — the `subminer` wrapper script or the mpv plugin: There are two ways to use SubMiner — the `subminer` wrapper script or the mpv plugin:
| Approach | Best For | | Approach | Best For |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. With default plugin settings, overlay auto-starts visible and playback resumes after annotation readiness. | | **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. Overlay start is explicit (`--start`, `-S`, or `y-s`). |
| **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control overlay visibility. Requires `--input-ipc-server=/tmp/subminer-socket`. | | **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control overlay visibility. Requires `--input-ipc-server=/tmp/subminer-socket`. |
You can use both together—install the plugin for on-demand control, but use `subminer` when you want the streamlined workflow. You can use both together—install the plugin for on-demand control, but use `subminer` when you want the streamlined workflow.
`subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer video.mkv`. `subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer --start video.mkv`.
## Live Config Reload ## Live Config Reload
@@ -38,9 +34,8 @@ subminer # Current directory (uses fzf)
subminer -R # Use rofi instead of fzf subminer -R # Use rofi instead of fzf
subminer -d ~/Videos # Specific directory subminer -d ~/Videos # Specific directory
subminer -r -d ~/Anime # Recursive search subminer -r -d ~/Anime # Recursive search
subminer video.mkv # Play specific file (default plugin config auto-starts visible overlay) subminer video.mkv # Play specific file
subminer --start video.mkv # Optional explicit overlay start (use when plugin auto_start=no) subminer --start video.mkv # Play + explicitly start overlay
subminer -S video.mkv # Same as above via --start-overlay
subminer https://youtu.be/... # Play a YouTube URL subminer https://youtu.be/... # Play a YouTube URL
subminer ytsearch:"jp news" # Play first YouTube search result subminer ytsearch:"jp news" # Play first YouTube search result
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
@@ -151,14 +146,6 @@ secondary-sub-visibility=no
`secondary-slang` is not an mpv option; use `slang` with `sid=auto` / `secondary-sid=auto` instead. `secondary-slang` is not an mpv option; use `slang` with `sid=auto` / `secondary-sid=auto` instead.
### Yomitan setup
SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --settings` or `SubMiner.AppImage --settings`) and import at least one dictionary in the bundled Yomitan instance.
If you also use Yomitan in a browser, configure that browser profile separately; it does not inherit dictionaries or settings from the bundled instance.
### YouTube Playback ### YouTube Playback
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets, and forwards them to mpv. `subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets, and forwards them to mpv.
@@ -181,7 +168,7 @@ Notes:
### Global Shortcuts ### Global Shortcuts
| Keybind | Action | | Keybind | Action |
| ------------- | ---------------------- | | ------------- | ------------------------ |
| `Alt+Shift+O` | Toggle visible overlay | | `Alt+Shift+O` | Toggle visible overlay |
| `Alt+Shift+Y` | Open Yomitan settings | | `Alt+Shift+Y` | Open Yomitan settings |
@@ -204,12 +191,14 @@ Notes:
| `Ctrl+W` | Quit mpv | | `Ctrl+W` | Quit mpv |
| `Right-click` | Toggle MPV pause (outside subtitle area) | | `Right-click` | Toggle MPV pause (outside subtitle area) |
| `Right-click + drag` | Move subtitle position (on subtitle) | | `Right-click + drag` | Move subtitle position (on subtitle) |
| `Ctrl/Cmd+Shift+P` | Toggle subtitle position edit mode |
| `Arrow keys` | Move subtitles while edit mode is active |
| `Enter` / `Ctrl+S` | Save subtitle position in edit mode |
| `Esc` | Cancel subtitle position edit mode |
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist | | `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist |
These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization. These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization.
By default, hovering over subtitle text pauses mpv playback and leaving the subtitle area resumes playback. Set `subtitleStyle.autoPauseVideoOnHover` to `false` to disable this behavior.
### Drag-and-drop Queueing ### Drag-and-drop Queueing
- Drag and drop one or more video files onto the overlay to replace current playback (`loadfile ... replace` for first file, then append remainder). - Drag and drop one or more video files onto the overlay to replace current playback (`loadfile ... replace` for first file, then append remainder).

View File

@@ -62,7 +62,7 @@ test('inferAniSkipMetadataForFile falls back to anime directory title when filen
test('buildSubminerScriptOpts includes aniskip metadata fields', () => { test('buildSubminerScriptOpts includes aniskip metadata fields', () => {
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', { const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
title: "Frieren: Beyond Journey's End", title: 'Frieren: Beyond Journey\'s End',
season: 1, season: 1,
episode: 5, episode: 5,
source: 'guessit', source: 'guessit',

View File

@@ -28,11 +28,7 @@ function toPositiveInt(value: unknown): number | null {
} }
function detectEpisodeFromName(baseName: string): number | null { function detectEpisodeFromName(baseName: string): number | null {
const patterns = [ const patterns = [/[Ss]\d+[Ee](\d{1,3})/, /(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/, /[-\s](\d{1,3})$/];
/[Ss]\d+[Ee](\d{1,3})/,
/(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/,
/[-\s](\d{1,3})$/,
];
for (const pattern of patterns) { for (const pattern of patterns) {
const match = baseName.match(pattern); const match = baseName.match(pattern);
if (!match || !match[1]) continue; if (!match || !match[1]) continue;
@@ -175,11 +171,7 @@ export function inferAniSkipMetadataForFile(
} }
function sanitizeScriptOptValue(value: string): string { function sanitizeScriptOptValue(value: string): string {
return value return value.replace(/,/g, ' ').replace(/[\r\n]/g, ' ').replace(/\s+/g, ' ').trim();
.replace(/,/g, ' ')
.replace(/[\r\n]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
} }
export function buildSubminerScriptOpts( export function buildSubminerScriptOpts(

View File

@@ -33,12 +33,6 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
scriptPath: '/tmp/subminer', scriptPath: '/tmp/subminer',
scriptName: 'subminer', scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock', mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {
socketPath: '/tmp/subminer.sock',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
},
appPath: '/tmp/subminer.app', appPath: '/tmp/subminer.app',
launcherJellyfinConfig: {}, launcherJellyfinConfig: {},
processAdapter: adapter, processAdapter: adapter,

View File

@@ -1,4 +1,4 @@
import type { Args, LauncherJellyfinConfig, PluginRuntimeConfig } from '../types.js'; import type { Args, LauncherJellyfinConfig } from '../types.js';
import type { ProcessAdapter } from '../process-adapter.js'; import type { ProcessAdapter } from '../process-adapter.js';
export interface LauncherCommandContext { export interface LauncherCommandContext {
@@ -6,7 +6,6 @@ export interface LauncherCommandContext {
scriptPath: string; scriptPath: string;
scriptName: string; scriptName: string;
mpvSocketPath: string; mpvSocketPath: string;
pluginRuntimeConfig: PluginRuntimeConfig;
appPath: string | null; appPath: string | null;
launcherJellyfinConfig: LauncherJellyfinConfig; launcherJellyfinConfig: LauncherJellyfinConfig;
processAdapter: ProcessAdapter; processAdapter: ProcessAdapter;

View File

@@ -86,7 +86,7 @@ function registerCleanup(context: LauncherCommandContext): void {
} }
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> { export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context; const { args, appPath, scriptPath, mpvSocketPath, processAdapter } = context;
if (!appPath) { if (!appPath) {
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.'); fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
} }
@@ -137,19 +137,6 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
log('info', args.logLevel, 'YouTube subtitle mode: off'); log('info', args.logLevel, 'YouTube subtitle mode: off');
} }
const shouldPauseUntilOverlayReady =
pluginRuntimeConfig.autoStart &&
pluginRuntimeConfig.autoStartVisibleOverlay &&
pluginRuntimeConfig.autoStartPauseUntilReady;
if (shouldPauseUntilOverlayReady) {
log(
'info',
args.logLevel,
'Configured to pause mpv until overlay and tokenization are ready',
);
}
startMpv( startMpv(
selectedTarget.target, selectedTarget.target,
selectedTarget.kind, selectedTarget.kind,
@@ -157,7 +144,6 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
mpvSocketPath, mpvSocketPath,
appPath, appPath,
preloadedSubtitles, preloadedSubtitles,
{ startPaused: shouldPauseUntilOverlayReady },
); );
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') { if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
@@ -181,7 +167,6 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
} }
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000); const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay; const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
if (shouldStartOverlay) { if (shouldStartOverlay) {
if (ready) { if (ready) {
@@ -194,16 +179,6 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
); );
} }
await startOverlay(appPath, args, mpvSocketPath); await startOverlay(appPath, args, mpvSocketPath);
} else if (pluginAutoStartEnabled) {
if (ready) {
log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
} else {
log(
'info',
args.logLevel,
'MPV IPC socket not ready yet, relying on mpv plugin auto-start',
);
}
} else if (ready) { } else if (ready) {
log( log(
'info', 'info',

View File

@@ -51,27 +51,10 @@ test('parseLauncherJellyfinConfig omits legacy token and user id fields', () =>
assert.equal('userId' in parsed, false); assert.equal('userId' in parsed, false);
}); });
test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => { test('parsePluginRuntimeConfigContent reads socket_path and ignores inline comments', () => {
const parsed = parsePluginRuntimeConfigContent(` const parsed = parsePluginRuntimeConfigContent(`
# comment # comment
socket_path = /tmp/custom.sock # trailing comment socket_path = /tmp/custom.sock # trailing comment
auto_start = yes
auto_start_visible_overlay = true
auto_start_pause_until_ready = 1
`); `);
assert.equal(parsed.socketPath, '/tmp/custom.sock'); assert.equal(parsed.socketPath, '/tmp/custom.sock');
assert.equal(parsed.autoStart, true);
assert.equal(parsed.autoStartVisibleOverlay, true);
assert.equal(parsed.autoStartPauseUntilReady, true);
});
test('parsePluginRuntimeConfigContent falls back to disabled startup gate options', () => {
const parsed = parsePluginRuntimeConfigContent(`
auto_start = maybe
auto_start_visible_overlay = no
auto_start_pause_until_ready = off
`);
assert.equal(parsed.autoStart, false);
assert.equal(parsed.autoStartVisibleOverlay, false);
assert.equal(parsed.autoStartPauseUntilReady, false);
}); });

View File

@@ -4,9 +4,11 @@ import { execFileSync } from 'node:child_process';
import path from 'node:path'; import path from 'node:path';
test('launcher root help lists subcommands', () => { test('launcher root help lists subcommands', () => {
const output = execFileSync('bun', ['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'], { const output = execFileSync(
encoding: 'utf8', 'bun',
}); ['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'],
{ encoding: 'utf8' },
);
assert.match(output, /Commands:/); assert.match(output, /Commands:/);
assert.match(output, /jellyfin\|jf/); assert.match(output, /jellyfin\|jf/);

View File

@@ -182,8 +182,7 @@ export function parseCliPrograms(
server: typeof options.server === 'string' ? options.server : undefined, server: typeof options.server === 'string' ? options.server : undefined,
username: typeof options.username === 'string' ? options.username : undefined, username: typeof options.username === 'string' ? options.username : undefined,
password: typeof options.password === 'string' ? options.password : undefined, password: typeof options.password === 'string' ? options.password : undefined,
passwordStore: passwordStore: typeof options.passwordStore === 'string' ? options.passwordStore : undefined,
typeof options.passwordStore === 'string' ? options.passwordStore : undefined,
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
}; };
}); });

View File

@@ -15,64 +15,22 @@ export function getPluginConfigCandidates(): string[] {
); );
} }
export function parsePluginRuntimeConfigContent( export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeConfig {
content: string, const runtimeConfig: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
logLevel: LogLevel = 'warn',
): PluginRuntimeConfig {
const runtimeConfig: PluginRuntimeConfig = {
socketPath: DEFAULT_SOCKET_PATH,
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
};
const parseBooleanValue = (key: string, value: string): boolean => {
const normalized = value.trim().toLowerCase();
if (['yes', 'true', '1', 'on'].includes(normalized)) return true;
if (['no', 'false', '0', 'off'].includes(normalized)) return false;
log('warn', logLevel, `Invalid boolean value for ${key}: "${value}". Using false.`);
return false;
};
for (const line of content.split(/\r?\n/)) { for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim(); const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith('#')) continue; if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
const keyValueMatch = trimmed.match(/^([a-z0-9_-]+)\s*=\s*(.+)$/i); const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i);
if (!keyValueMatch) continue; if (!socketMatch) continue;
const key = (keyValueMatch[1] || '').toLowerCase(); const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || '';
const value = (keyValueMatch[2] || '').split('#', 1)[0]?.trim() || ''; if (value) runtimeConfig.socketPath = value;
if (!value) continue;
if (key === 'socket_path') {
runtimeConfig.socketPath = value;
continue;
}
if (key === 'auto_start') {
runtimeConfig.autoStart = parseBooleanValue('auto_start', value);
continue;
}
if (key === 'auto_start_visible_overlay') {
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue('auto_start_visible_overlay', value);
continue;
}
if (key === 'auto_start_pause_until_ready') {
runtimeConfig.autoStartPauseUntilReady = parseBooleanValue(
'auto_start_pause_until_ready',
value,
);
}
} }
return runtimeConfig; return runtimeConfig;
} }
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
const candidates = getPluginConfigCandidates(); const candidates = getPluginConfigCandidates();
const defaults: PluginRuntimeConfig = { const defaults: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
socketPath: DEFAULT_SOCKET_PATH,
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
};
for (const configPath of candidates) { for (const configPath of candidates) {
if (!fs.existsSync(configPath)) continue; if (!fs.existsSync(configPath)) continue;
@@ -81,7 +39,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
log( log(
'debug', 'debug',
logLevel, logLevel,
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}`, `Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}`,
); );
return parsed; return parsed;
} catch { } catch {
@@ -93,7 +51,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
log( log(
'debug', 'debug',
logLevel, logLevel,
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath}, auto_start=${defaults.autoStart}, auto_start_visible_overlay=${defaults.autoStartVisibleOverlay}, auto_start_pause_until_ready=${defaults.autoStartPauseUntilReady})`, `No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath})`,
); );
return defaults; return defaults;
} }

View File

@@ -22,14 +22,10 @@ function withTempDir<T>(fn: (dir: string) => T): T {
} }
function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult { function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult {
const result = spawnSync( const result = spawnSync(process.execPath, ['run', path.join(process.cwd(), 'launcher/main.ts'), ...argv], {
process.execPath,
['run', path.join(process.cwd(), 'launcher/main.ts'), ...argv],
{
env, env,
encoding: 'utf8', encoding: 'utf8',
}, });
);
return { return {
status: result.status, status: result.status,
stdout: result.stdout || '', stdout: result.stdout || '',
@@ -229,7 +225,10 @@ test('jellyfin setup forwards password-store to app command', () => {
SUBMINER_APPIMAGE_PATH: appPath, SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath, SUBMINER_TEST_CAPTURE: capturePath,
}; };
const result = runLauncher(['jf', 'setup', '--password-store', 'gnome-libsecret'], env); const result = runLauncher(
['jf', 'setup', '--password-store', 'gnome-libsecret'],
env,
);
assert.equal(result.status, 0); assert.equal(result.status, 0);
assert.equal( assert.equal(

View File

@@ -19,15 +19,14 @@ import { runPlaybackCommand } from './commands/playback-command.js';
function createCommandContext( function createCommandContext(
args: ReturnType<typeof parseArgs>, args: ReturnType<typeof parseArgs>,
scriptPath: string, scriptPath: string,
pluginRuntimeConfig: ReturnType<typeof readPluginRuntimeConfig>, mpvSocketPath: string,
appPath: string | null, appPath: string | null,
): LauncherCommandContext { ): LauncherCommandContext {
return { return {
args, args,
scriptPath, scriptPath,
scriptName: path.basename(scriptPath), scriptName: path.basename(scriptPath),
mpvSocketPath: pluginRuntimeConfig.socketPath, mpvSocketPath,
pluginRuntimeConfig,
appPath, appPath,
launcherJellyfinConfig: loadLauncherJellyfinConfig(), launcherJellyfinConfig: loadLauncherJellyfinConfig(),
processAdapter: nodeProcessAdapter, processAdapter: nodeProcessAdapter,
@@ -56,7 +55,7 @@ async function main(): Promise<void> {
log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`); log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
const context = createCommandContext(args, scriptPath, pluginRuntimeConfig, appPath); const context = createCommandContext(args, scriptPath, pluginRuntimeConfig.socketPath, appPath);
if (runDoctorCommand(context)) { if (runDoctorCommand(context)) {
return; return;
@@ -72,7 +71,6 @@ async function main(): Promise<void> {
const resolvedAppPath = ensureAppPath(context); const resolvedAppPath = ensureAppPath(context);
state.appPath = resolvedAppPath; state.appPath = resolvedAppPath;
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
const appContext: LauncherCommandContext = { const appContext: LauncherCommandContext = {
...context, ...context,
appPath: resolvedAppPath, appPath: resolvedAppPath,

View File

@@ -426,7 +426,6 @@ export function startMpv(
socketPath: string, socketPath: string,
appPath: string, appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string }, preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
options?: { startPaused?: boolean },
): void { ): void {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) { if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`); fail(`Video file not found: ${target}`);
@@ -476,10 +475,8 @@ export function startMpv(
if (preloadedSubtitles?.secondaryPath) { if (preloadedSubtitles?.secondaryPath) {
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`); mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
} }
if (options?.startPaused) { const aniSkipMetadata =
mpvArgs.push('--pause=yes'); targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
}
const aniSkipMetadata = targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata); const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
if (aniSkipMetadata) { if (aniSkipMetadata) {
log( log(

View File

@@ -31,7 +31,11 @@ test('parseArgs maps jellyfin play action and log-level override', () => {
}); });
test('parseArgs forwards jellyfin password-store option', () => { test('parseArgs forwards jellyfin password-store option', () => {
const parsed = parseArgs(['jf', 'setup', '--password-store', 'gnome-libsecret'], 'subminer', {}); const parsed = parseArgs(
['jf', 'setup', '--password-store', 'gnome-libsecret'],
'subminer',
{},
);
assert.equal(parsed.jellyfin, true); assert.equal(parsed.jellyfin, true);
assert.equal(parsed.passwordStore, 'gnome-libsecret'); assert.equal(parsed.passwordStore, 'gnome-libsecret');

View File

@@ -319,43 +319,3 @@ test(
}); });
}, },
); );
test(
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
{ timeout: 20000 },
async () => {
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
fs.writeFileSync(
path.join(smokeCase.xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
[
`socket_path=${smokeCase.socketPath}`,
'auto_start=yes',
'auto_start_visible_overlay=yes',
'auto_start_pause_until_ready=yes',
'',
].join('\n'),
);
const env = makeTestEnv(smokeCase);
const result = runLauncher(
smokeCase,
[smokeCase.videoPath, '--log-level', 'debug'],
env,
'autoplay-ready-gate',
);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const mpvError = mpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
const mpvFirstArgs = mpvEntries[0]?.argv;
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.equal(Array.isArray(mpvFirstArgs), true);
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
});
},
);

View File

@@ -129,9 +129,6 @@ export interface LauncherJellyfinConfig {
export interface PluginRuntimeConfig { export interface PluginRuntimeConfig {
socketPath: string; socketPath: string;
autoStart: boolean;
autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean;
} }
export interface CommandExecOptions { export interface CommandExecOptions {

View File

@@ -1,6 +1,6 @@
{ {
"name": "subminer", "name": "subminer",
"version": "0.2.0", "version": "0.1.2",
"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",
@@ -12,7 +12,6 @@
"build": "tsc && bun run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && cp -r src/renderer/fonts dist/renderer/ && bash scripts/build-macos-helper.sh", "build": "tsc && bun run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && cp -r src/renderer/fonts dist/renderer/ && bash scripts/build-macos-helper.sh",
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap", "build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
"docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev docs --host 0.0.0.0 --port 5173 --strictPort", "docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev docs --host 0.0.0.0 --port 5173 --strictPort",
"docs:watch": "bunx concurrently -n docs,backlog \"bun run docs:dev\" \"backlog browser\"",
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs", "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs",
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort",
"format": "prettier --write .", "format": "prettier --write .",
@@ -23,8 +22,8 @@
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua", "test:plugin:src": "lua scripts/test-plugin-start-gate.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/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.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/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.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/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/mining.test.ts src/core/services/anki-jimaku.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/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.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/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/mining.test.ts src/core/services/anki-jimaku.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/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
"test:core:dist": "bun test 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/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/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/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:dist": "bun test 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/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/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/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/subsync/utils.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: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:dist": "echo \"Subtitle tests are currently not configured\"", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",

View File

@@ -28,10 +28,6 @@ auto_start=yes
# Runs only when mpv input-ipc-server matches socket_path. # Runs only when mpv input-ipc-server matches socket_path.
auto_start_visible_overlay=yes auto_start_visible_overlay=yes
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
# Requires auto_start=yes and auto_start_visible_overlay=yes.
auto_start_pause_until_ready=yes
# Show OSD messages for overlay status # Show OSD messages for overlay status
osd_messages=yes osd_messages=yes

View File

@@ -249,22 +249,36 @@ function M.create(ctx)
raw_close_idx = #raw_ass + 1 raw_close_idx = #raw_ass + 1
end end
local before = raw_ass:sub(1, raw_open_idx - 1)
local hovered = raw_ass:sub(raw_open_idx, raw_close_idx - 1)
local after = raw_ass:sub(raw_close_idx)
local hover_suffix = string.format("\\1c&H%s&", hover_color)
-- Keep hover foreground stable even when inline ASS override tags (\1c/\c/\r) appear inside token.
hovered = hovered:gsub("{([^}]*)}", function(inner)
if inner:find("\\1c&H", 1, true) or inner:find("\\c&H", 1, true) or inner:find("\\r", 1, true) then
return "{" .. inner .. hover_suffix .. "}"
end
return "{" .. inner .. "}"
end)
local open_tag = string.format("{\\1c&H%s&}", hover_color) local open_tag = string.format("{\\1c&H%s&}", hover_color)
local close_tag = string.format("{\\1c&H%s&}", base_color) local close_tag = string.format("{\\1c&H%s&}", base_color)
return before .. open_tag .. hovered .. close_tag .. after local changes = {
{ idx = raw_open_idx, tag = open_tag },
{ idx = raw_close_idx, tag = close_tag },
}
table.sort(changes, function(a, b)
return a.idx < b.idx
end)
local output = {}
local cursor = 1
for _, change in ipairs(changes) do
if change.idx > #raw_ass + 1 then
change.idx = #raw_ass + 1
end
if change.idx < 1 then
change.idx = 1
end
if change.idx > cursor then
output[#output + 1] = raw_ass:sub(cursor, change.idx - 1)
end
output[#output + 1] = change.tag
cursor = change.idx
end
if cursor <= #raw_ass then
output[#output + 1] = raw_ass:sub(cursor)
end
return table.concat(output)
end end
local function build_hover_subtitle_content(payload) local function build_hover_subtitle_content(payload)

View File

@@ -31,7 +31,6 @@ function M.create(ctx)
local function on_file_loaded() local function on_file_loaded()
aniskip.clear_aniskip_state() aniskip.clear_aniskip_state()
process.disarm_auto_play_ready_gate()
local should_auto_start = resolve_auto_start_enabled() local should_auto_start = resolve_auto_start_enabled()
if should_auto_start then if should_auto_start then
@@ -60,7 +59,6 @@ function M.create(ctx)
local function on_shutdown() local function on_shutdown()
aniskip.clear_aniskip_state() aniskip.clear_aniskip_state()
hover.clear_hover_overlay() hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate()
if state.overlay_running or state.texthooker_running then if state.overlay_running or state.texthooker_running then
subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process") subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process")
show_osd("Shutting down...") show_osd("Shutting down...")
@@ -75,7 +73,6 @@ function M.create(ctx)
hover.clear_hover_overlay() hover.clear_hover_overlay()
end) end)
mp.register_event("end-file", function() mp.register_event("end-file", function()
process.disarm_auto_play_ready_gate()
hover.clear_hover_overlay() hover.clear_hover_overlay()
end) end)
mp.register_event("shutdown", function() mp.register_event("shutdown", function()

View File

@@ -45,14 +45,7 @@ function M.create(ctx)
local function show_osd(message) local function show_osd(message)
if opts.osd_messages then if opts.osd_messages then
local payload = "SubMiner: " .. message mp.osd_message("SubMiner: " .. message, 3)
local sent = false
if type(mp.osd_message) == "function" then
sent = pcall(mp.osd_message, payload, 3)
end
if not sent and type(mp.commandv) == "function" then
pcall(mp.commandv, "show-text", payload, "3000")
end
end end
end end

View File

@@ -29,9 +29,6 @@ function M.create(ctx)
mp.register_script_message("subminer-status", function() mp.register_script_message("subminer-status", function()
process.check_status() process.check_status()
end) end)
mp.register_script_message("subminer-autoplay-ready", function()
process.notify_auto_play_ready()
end)
mp.register_script_message("subminer-aniskip-refresh", function() mp.register_script_message("subminer-aniskip-refresh", function()
aniskip.fetch_aniskip_for_current_media("script-message") aniskip.fetch_aniskip_for_current_media("script-message")
end) end)

View File

@@ -8,8 +8,7 @@ function M.load(options_lib, default_socket_path)
texthooker_port = 5174, texthooker_port = 5174,
backend = "auto", backend = "auto",
auto_start = true, auto_start = true,
auto_start_visible_overlay = true, auto_start_visible_overlay = false,
auto_start_pause_until_ready = true,
osd_messages = true, osd_messages = true,
log_level = "info", log_level = "info",
aniskip_enabled = true, aniskip_enabled = true,

View File

@@ -2,7 +2,6 @@ local M = {}
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_START_MAX_ATTEMPTS = 6 local OVERLAY_START_MAX_ATTEMPTS = 6
local AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
@@ -23,14 +22,6 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_visible_overlay, false) return options_helper.coerce_bool(raw_visible_overlay, false)
end end
local function resolve_pause_until_ready()
local raw_pause_until_ready = opts.auto_start_pause_until_ready
if raw_pause_until_ready == nil then
raw_pause_until_ready = opts["auto-start-pause-until-ready"]
end
return options_helper.coerce_bool(raw_pause_until_ready, false)
end
local function normalize_socket_path(path) local function normalize_socket_path(path)
if type(path) ~= "string" then if type(path) ~= "string" then
return nil return nil
@@ -62,54 +53,6 @@ function M.create(ctx)
return selected return selected
end end
local function clear_auto_play_ready_timeout()
local timeout = state.auto_play_ready_timeout
if timeout and timeout.kill then
timeout:kill()
end
state.auto_play_ready_timeout = nil
end
local function disarm_auto_play_ready_gate()
clear_auto_play_ready_timeout()
state.auto_play_ready_gate_armed = false
end
local function release_auto_play_ready_gate(reason)
if not state.auto_play_ready_gate_armed then
return
end
disarm_auto_play_ready_gate()
mp.set_property_native("pause", false)
show_osd("Subtitle annotations loaded")
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
end
local function arm_auto_play_ready_gate()
if state.auto_play_ready_gate_armed then
clear_auto_play_ready_timeout()
end
state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true)
show_osd("Loading subtitle annotations...")
subminer_log("info", "process", "Pausing playback until SubMiner overlay/tokenization readiness signal")
state.auto_play_ready_timeout = mp.add_timeout(AUTO_PLAY_READY_TIMEOUT_SECONDS, function()
if not state.auto_play_ready_gate_armed then
return
end
subminer_log(
"warn",
"process",
"Startup readiness signal timed out; resuming playback to avoid stalled pause"
)
release_auto_play_ready_gate("timeout")
end)
end
local function notify_auto_play_ready()
release_auto_play_ready_gate("tokenization-ready")
end
local function build_command_args(action, overrides) local function build_command_args(action, overrides)
overrides = overrides or {} overrides = overrides or {}
local args = { state.binary_path } local args = { state.binary_path }
@@ -132,17 +75,16 @@ function M.create(ctx)
table.insert(args, "--socket") table.insert(args, "--socket")
table.insert(args, socket_path) table.insert(args, socket_path)
-- Keep auto-start --start requests idempotent for second-instance handling.
-- Visibility is applied as a separate control command after startup.
if overrides.auto_start_trigger ~= true then
local should_show_visible = resolve_visible_overlay_startup() local should_show_visible = resolve_visible_overlay_startup()
if should_show_visible and overrides.auto_start_trigger == true then
should_show_visible = has_matching_mpv_ipc_socket(socket_path)
end
if should_show_visible then if should_show_visible then
table.insert(args, "--show-visible-overlay") table.insert(args, "--show-visible-overlay")
else else
table.insert(args, "--hide-visible-overlay") table.insert(args, "--hide-visible-overlay")
end end
end end
end
return args return args
end end
@@ -240,8 +182,6 @@ function M.create(ctx)
end end
local function start_overlay(overrides) local function start_overlay(overrides)
overrides = overrides or {}
if not binary.ensure_binary_available() then if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found") subminer_log("error", "binary", "SubMiner binary not found")
show_osd("Error: binary not found") show_osd("Error: binary not found")
@@ -249,31 +189,16 @@ function M.create(ctx)
end end
if state.overlay_running then if state.overlay_running then
if overrides.auto_start_trigger == true then
subminer_log("debug", "process", "Auto-start ignored because overlay is already running")
return
end
subminer_log("info", "process", "Overlay already running") subminer_log("info", "process", "Overlay already running")
show_osd("Already running") show_osd("Already running")
return return
end end
overrides = overrides or {}
local texthooker_enabled = overrides.texthooker_enabled local texthooker_enabled = overrides.texthooker_enabled
if texthooker_enabled == nil then if texthooker_enabled == nil then
texthooker_enabled = opts.texthooker_enabled texthooker_enabled = opts.texthooker_enabled
end end
local socket_path = overrides.socket_path or opts.socket_path
local should_pause_until_ready = (
overrides.auto_start_trigger == true
and resolve_visible_overlay_startup()
and resolve_pause_until_ready()
and has_matching_mpv_ipc_socket(socket_path)
)
if should_pause_until_ready then
arm_auto_play_ready_gate()
else
disarm_auto_play_ready_gate()
end
local function launch_overlay_with_retry(attempt) local function launch_overlay_with_retry(attempt)
local args = build_command_args("start", overrides) local args = build_command_args("start", overrides)
@@ -311,19 +236,9 @@ function M.create(ctx)
state.overlay_running = false state.overlay_running = false
subminer_log("error", "process", "Overlay start failed after retries: " .. reason) subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
show_osd("Overlay start failed") show_osd("Overlay start failed")
release_auto_play_ready_gate("overlay-start-failed")
return return
end end
if overrides.auto_start_trigger == true then
local visibility_action = resolve_visible_overlay_startup()
and "show-visible-overlay"
or "hide-visible-overlay"
run_control_command_async(visibility_action, {
log_level = overrides.log_level,
})
end
end) end)
end end
@@ -362,7 +277,6 @@ function M.create(ctx)
state.overlay_running = false state.overlay_running = false
state.texthooker_running = false state.texthooker_running = false
disarm_auto_play_ready_gate()
show_osd("Stopped") show_osd("Stopped")
end end
@@ -412,7 +326,6 @@ function M.create(ctx)
run_control_command_async("stop", nil, function() run_control_command_async("stop", nil, function()
state.overlay_running = false state.overlay_running = false
state.texthooker_running = false state.texthooker_running = false
disarm_auto_play_ready_gate()
ensure_texthooker_running(function() ensure_texthooker_running(function()
local start_args = build_command_args("start") local start_args = build_command_args("start")
@@ -471,8 +384,6 @@ function M.create(ctx)
restart_overlay = restart_overlay, restart_overlay = restart_overlay,
check_status = check_status, check_status = check_status,
check_binary_available = check_binary_available, check_binary_available = check_binary_available,
notify_auto_play_ready = notify_auto_play_ready,
disarm_auto_play_ready_gate = disarm_auto_play_ready_gate,
} }
end end

View File

@@ -27,8 +27,6 @@ function M.new()
found = false, found = false,
prompt_shown = false, prompt_shown = false,
}, },
auto_play_ready_gate_armed = false,
auto_play_ready_timeout = nil,
} }
end end

View File

@@ -11,12 +11,11 @@ Description:
Generates two browser-friendly files next to the input file: Generates two browser-friendly files next to the input file:
- <name>.mp4 (H.264 + AAC, prefers NVIDIA GPU if available) - <name>.mp4 (H.264 + AAC, prefers NVIDIA GPU if available)
- <name>.webm (AV1/VP9 + Opus, prefers NVIDIA GPU if available) - <name>.webm (AV1/VP9 + Opus, prefers NVIDIA GPU if available)
- <name>.gif (palette-optimised, 15 fps)
- <name>-poster.jpg (single frame for video poster fallback) - <name>-poster.jpg (single frame for video poster fallback)
- <name>.webp (animated, only when --webp is provided)
Options: Options:
-f, --force Overwrite existing output files -f, --force Overwrite existing output files
-w, --webp Generate animated WebP preview
Encoding profile: Encoding profile:
- Crop: 1920x1080 at x=760 y=200 - Crop: 1920x1080 at x=760 y=200
@@ -26,7 +25,6 @@ USAGE
} }
force=0 force=0
generate_webp=0
input="" input=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -38,9 +36,6 @@ while [[ $# -gt 0 ]]; do
-f | --force) -f | --force)
force=1 force=1
;; ;;
-w | --webp)
generate_webp=1
;;
-*) -*)
echo "Error: unknown option: $1" >&2 echo "Error: unknown option: $1" >&2
usage usage
@@ -79,7 +74,7 @@ base="${filename%.*}"
mp4_out="$dir/$base.mp4" mp4_out="$dir/$base.mp4"
webm_out="$dir/$base.webm" webm_out="$dir/$base.webm"
webp_out="$dir/$base.webp" gif_out="$dir/$base.gif"
poster_out="$dir/$base-poster.jpg" poster_out="$dir/$base-poster.jpg"
overwrite_flag="-n" overwrite_flag="-n"
@@ -88,11 +83,7 @@ if [[ "$force" -eq 1 ]]; then
fi fi
if [[ "$force" -eq 0 ]]; then if [[ "$force" -eq 0 ]]; then
outputs=("$mp4_out" "$webm_out" "$poster_out") for output in "$mp4_out" "$webm_out" "$gif_out" "$poster_out"; do
if [[ "$generate_webp" -eq 1 ]]; then
outputs+=("$webp_out")
fi
for output in "${outputs[@]}"; do
if [[ -e "$output" ]]; then if [[ -e "$output" ]]; then
echo "Error: output exists: $output (use --force to overwrite)" >&2 echo "Error: output exists: $output (use --force to overwrite)" >&2
exit 1 exit 1
@@ -107,6 +98,7 @@ has_encoder() {
crop_vf="crop=1920:1080:760:205" crop_vf="crop=1920:1080:760:205"
webm_vf="${crop_vf},fps=30" webm_vf="${crop_vf},fps=30"
gif_vf="${crop_vf},fps=15,scale=960:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3"
echo "Generating MP4: $mp4_out" echo "Generating MP4: $mp4_out"
if has_encoder "h264_nvenc"; then if has_encoder "h264_nvenc"; then
@@ -167,20 +159,10 @@ else
"$webm_out" "$webm_out"
fi fi
if [[ "$generate_webp" -eq 1 ]]; then echo "Generating GIF: $gif_out"
if ! has_encoder "libwebp"; then ffmpeg "$overwrite_flag" -i "$input" \
echo "Error: encoder not found: libwebp" >&2 -vf "$gif_vf" \
exit 1 "$gif_out"
fi
echo "Generating animated WebP: $webp_out"
ffmpeg "$overwrite_flag" -i "$input" \
-vf "${crop_vf},fps=24,scale=960:-1:flags=lanczos" \
-c:v libwebp \
-q:v 80 \
-loop 0 \
-an \
"$webp_out"
fi
echo "Generating poster: $poster_out" echo "Generating poster: $poster_out"
ffmpeg "$overwrite_flag" -ss 00:00:05 -i "$input" \ ffmpeg "$overwrite_flag" -ss 00:00:05 -i "$input" \
@@ -192,7 +174,5 @@ ffmpeg "$overwrite_flag" -ss 00:00:05 -i "$input" \
echo "Done." echo "Done."
echo "MP4: $mp4_out" echo "MP4: $mp4_out"
echo "WebM: $webm_out" echo "WebM: $webm_out"
if [[ "$generate_webp" -eq 1 ]]; then echo "GIF: $gif_out"
echo "WebP: $webp_out"
fi
echo "Poster: $poster_out" echo "Poster: $poster_out"

View File

@@ -8,7 +8,6 @@ local function run_plugin_scenario(config)
events = {}, events = {},
osd = {}, osd = {},
logs = {}, logs = {},
property_sets = {},
} }
local function make_mp_stub() local function make_mp_stub()
@@ -117,12 +116,7 @@ local function run_plugin_scenario(config)
return 0 return 0
end end
function mp.commandv(...) end function mp.commandv(...) end
function mp.set_property_native(name, value) function mp.set_property_native(...) end
recorded.property_sets[#recorded.property_sets + 1] = {
name = name,
value = value,
}
end
function mp.get_script_name() function mp.get_script_name()
return "subminer" return "subminer"
end end
@@ -248,39 +242,6 @@ local function find_start_call(async_calls)
return nil return nil
end end
local function count_start_calls(async_calls)
local count = 0
for _, call in ipairs(async_calls) do
local args = call.args or {}
for _, value in ipairs(args) do
if value == "--start" then
count = count + 1
break
end
end
end
return count
end
local function find_control_call(async_calls, flag)
for _, call in ipairs(async_calls) do
local args = call.args or {}
local has_flag = false
local has_start = false
for _, value in ipairs(args) do
if value == flag then
has_flag = true
elseif value == "--start" then
has_start = true
end
end
if has_flag and not has_start then
return call
end
end
return nil
end
local function call_has_arg(call, target) local function call_has_arg(call, target)
local args = (call and call.args) or {} local args = (call and call.args) or {}
for _, value in ipairs(args) do for _, value in ipairs(args) do
@@ -324,34 +285,6 @@ local function has_async_curl_for(async_calls, needle)
return false return false
end end
local function has_property_set(property_sets, name, value)
for _, call in ipairs(property_sets) do
if call.name == name and call.value == value then
return true
end
end
return false
end
local function has_osd_message(messages, target)
for _, message in ipairs(messages) do
if message == target then
return true
end
end
return false
end
local function count_osd_message(messages, target)
local count = 0
for _, message in ipairs(messages) do
if message == target then
count = count + 1
end
end
return count
end
local function fire_event(recorded, name) local function fire_event(recorded, name)
local listeners = recorded.events[name] or {} local listeners = recorded.events[name] or {}
for _, listener in ipairs(listeners) do for _, listener in ipairs(listeners) do
@@ -440,7 +373,6 @@ do
binary_path = binary_path, binary_path = binary_path,
auto_start = "yes", auto_start = "yes",
auto_start_visible_overlay = "yes", auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "no",
socket_path = "/tmp/subminer-socket", socket_path = "/tmp/subminer-socket",
}, },
input_ipc_server = "/tmp/subminer-socket", input_ipc_server = "/tmp/subminer-socket",
@@ -454,86 +386,12 @@ do
local start_call = find_start_call(recorded.async_calls) local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command") assert_true(start_call ~= nil, "auto-start should issue --start command")
assert_true( assert_true(
not call_has_arg(start_call, "--show-visible-overlay"), call_has_arg(start_call, "--show-visible-overlay"),
"auto-start should keep --start command free of --show-visible-overlay" "auto-start with visible overlay enabled should pass --show-visible-overlay"
) )
assert_true( assert_true(
not call_has_arg(start_call, "--hide-visible-overlay"), not call_has_arg(start_call, "--hide-visible-overlay"),
"auto-start should keep --start command free of --hide-visible-overlay" "auto-start with visible overlay enabled should not pass --hide-visible-overlay"
)
assert_true(
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil,
"auto-start with visible overlay enabled should issue a separate --show-visible-overlay command"
)
assert_true(
not has_property_set(recorded.property_sets, "pause", true),
"auto-start visible overlay should not force pause without explicit pause-until-ready option"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for duplicate auto-start scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
fire_event(recorded, "file-loaded")
assert_true(
count_start_calls(recorded.async_calls) == 1,
"duplicate file-loaded events should not issue duplicate --start commands while overlay is already running"
)
assert_true(
count_osd_message(recorded.osd, "SubMiner: Already running") == 0,
"duplicate auto-start events should not show Already running OSD"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for pause-until-ready scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(
has_property_set(recorded.property_sets, "pause", true),
"pause-until-ready auto-start should pause mpv before overlay ready"
)
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
has_property_set(recorded.property_sets, "pause", false),
"autoplay-ready script message should resume mpv playback"
)
assert_true(
has_osd_message(recorded.osd, "SubMiner: Loading subtitle annotations..."),
"pause-until-ready auto-start should show loading OSD message"
)
assert_true(
has_osd_message(recorded.osd, "SubMiner: Subtitle annotations loaded"),
"autoplay-ready should show loaded OSD message"
) )
end end
@@ -557,16 +415,12 @@ do
local start_call = find_start_call(recorded.async_calls) local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command") assert_true(start_call ~= nil, "auto-start should issue --start command")
assert_true( assert_true(
not call_has_arg(start_call, "--hide-visible-overlay"), call_has_arg(start_call, "--hide-visible-overlay"),
"auto-start should keep --start command free of --hide-visible-overlay" "auto-start with visible overlay disabled should pass --hide-visible-overlay"
) )
assert_true( assert_true(
not call_has_arg(start_call, "--show-visible-overlay"), not call_has_arg(start_call, "--show-visible-overlay"),
"auto-start should keep --start command free of --show-visible-overlay" "auto-start with visible overlay disabled should not pass --show-visible-overlay"
)
assert_true(
find_control_call(recorded.async_calls, "--hide-visible-overlay") ~= nil,
"auto-start with visible overlay disabled should issue a separate --hide-visible-overlay command"
) )
end end
@@ -592,10 +446,6 @@ do
start_call == nil, start_call == nil,
"auto-start should be skipped when mpv input-ipc-server does not match configured socket_path" "auto-start should be skipped when mpv input-ipc-server does not match configured socket_path"
) )
assert_true(
not has_property_set(recorded.property_sets, "pause", true),
"pause-until-ready gate should not arm when socket_path does not match"
)
end end
print("plugin start gate regression tests: OK") print("plugin start gate regression tests: OK")

View File

@@ -239,8 +239,7 @@ export class AnkiIntegration {
} }
private createProxyServer(): AnkiConnectProxyServer { private createProxyServer(): AnkiConnectProxyServer {
const { AnkiConnectProxyServer } = const { AnkiConnectProxyServer } = require('./anki-integration/anki-connect-proxy') as typeof import('./anki-integration/anki-connect-proxy');
require('./anki-integration/anki-connect-proxy') as typeof import('./anki-integration/anki-connect-proxy');
return new AnkiConnectProxyServer({ return new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false, shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
processNewCard: (noteId: number) => this.processNewCard(noteId), processNewCard: (noteId: number) => this.processNewCard(noteId),

View File

@@ -27,11 +27,9 @@ test('proxy enqueues addNote result for enrichment', async () => {
logError: () => undefined, logError: () => undefined,
}); });
( (proxy as unknown as {
proxy as unknown as {
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void; maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
} }).maybeEnqueueFromRequest(
).maybeEnqueueFromRequest(
{ action: 'addNote' }, { action: 'addNote' },
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'), Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
); );
@@ -52,11 +50,9 @@ test('proxy enqueues addNote bare numeric response for enrichment', async () =>
logError: () => undefined, logError: () => undefined,
}); });
( (proxy as unknown as {
proxy as unknown as {
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void; maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
} }).maybeEnqueueFromRequest({ action: 'addNote' }, Buffer.from('42', 'utf8'));
).maybeEnqueueFromRequest({ action: 'addNote' }, Buffer.from('42', 'utf8'));
await waitForCondition(() => processed.length === 1); await waitForCondition(() => processed.length === 1);
assert.deepEqual(processed, [42]); assert.deepEqual(processed, [42]);
@@ -75,11 +71,9 @@ test('proxy de-duplicates addNotes IDs within the same response', async () => {
logError: () => undefined, logError: () => undefined,
}); });
( (proxy as unknown as {
proxy as unknown as {
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void; maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
} }).maybeEnqueueFromRequest(
).maybeEnqueueFromRequest(
{ action: 'addNotes' }, { action: 'addNotes' },
Buffer.from(JSON.stringify({ result: [101, 102, 101, null], error: null }), 'utf8'), Buffer.from(JSON.stringify({ result: [101, 102, 101, null], error: null }), 'utf8'),
); );
@@ -100,15 +94,17 @@ test('proxy enqueues note IDs from multi action addNote/addNotes results', async
logError: () => undefined, logError: () => undefined,
}); });
( (proxy as unknown as {
proxy as unknown as {
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void; maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
} }).maybeEnqueueFromRequest(
).maybeEnqueueFromRequest(
{ {
action: 'multi', action: 'multi',
params: { params: {
actions: [{ action: 'version' }, { action: 'addNote' }, { action: 'addNotes' }], actions: [
{ action: 'version' },
{ action: 'addNote' },
{ action: 'addNotes' },
],
}, },
}, },
Buffer.from(JSON.stringify({ result: [6, 777, [888, 777, null]], error: null }), 'utf8'), Buffer.from(JSON.stringify({ result: [6, 777, [888, 777, null]], error: null }), 'utf8'),
@@ -130,11 +126,9 @@ test('proxy enqueues note IDs from bare multi action results', async () => {
logError: () => undefined, logError: () => undefined,
}); });
( (proxy as unknown as {
proxy as unknown as {
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void; maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
} }).maybeEnqueueFromRequest(
).maybeEnqueueFromRequest(
{ {
action: 'multi', action: 'multi',
params: { params: {
@@ -160,15 +154,17 @@ test('proxy enqueues note IDs from multi action envelope results', async () => {
logError: () => undefined, logError: () => undefined,
}); });
( (proxy as unknown as {
proxy as unknown as {
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void; maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
} }).maybeEnqueueFromRequest(
).maybeEnqueueFromRequest(
{ {
action: 'multi', action: 'multi',
params: { params: {
actions: [{ action: 'version' }, { action: 'addNote' }, { action: 'addNotes' }], actions: [
{ action: 'version' },
{ action: 'addNote' },
{ action: 'addNotes' },
],
}, },
}, },
Buffer.from( Buffer.from(
@@ -200,11 +196,9 @@ test('proxy skips auto-enrichment when auto-update is disabled', async () => {
logError: () => undefined, logError: () => undefined,
}); });
( (proxy as unknown as {
proxy as unknown as {
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void; maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
} }).maybeEnqueueFromRequest(
).maybeEnqueueFromRequest(
{ action: 'addNote' }, { action: 'addNote' },
Buffer.from(JSON.stringify({ result: 303, error: null }), 'utf8'), Buffer.from(JSON.stringify({ result: 303, error: null }), 'utf8'),
); );
@@ -225,11 +219,9 @@ test('proxy ignores addNote when upstream response reports error', async () => {
logError: () => undefined, logError: () => undefined,
}); });
( (proxy as unknown as {
proxy as unknown as {
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void; maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
} }).maybeEnqueueFromRequest(
).maybeEnqueueFromRequest(
{ action: 'addNote' }, { action: 'addNote' },
Buffer.from(JSON.stringify({ result: 123, error: 'duplicate' }), 'utf8'), Buffer.from(JSON.stringify({ result: 123, error: 'duplicate' }), 'utf8'),
); );
@@ -256,11 +248,9 @@ test('proxy does not fallback-enqueue latest note for multi requests without add
logError: () => undefined, logError: () => undefined,
}); });
( (proxy as unknown as {
proxy as unknown as {
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void; maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
} }).maybeEnqueueFromRequest(
).maybeEnqueueFromRequest(
{ {
action: 'multi', action: 'multi',
params: { params: {
@@ -275,38 +265,6 @@ test('proxy does not fallback-enqueue latest note for multi requests without add
assert.deepEqual(processed, []); assert.deepEqual(processed, []);
}); });
test('proxy fallback-enqueues latest note for addNote responses without note IDs and escapes deck quotes', async () => {
const processed: number[] = [];
const findNotesQueries: string[] = [];
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true,
processNewCard: async (noteId) => {
processed.push(noteId);
},
getDeck: () => 'My "Japanese" Deck',
findNotes: async (query) => {
findNotesQueries.push(query);
return [500, 501];
},
logInfo: () => undefined,
logWarn: () => undefined,
logError: () => undefined,
});
(
proxy as unknown as {
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
}
).maybeEnqueueFromRequest(
{ action: 'addNote' },
Buffer.from(JSON.stringify({ result: 0, error: null }), 'utf8'),
);
await waitForCondition(() => processed.length === 1);
assert.deepEqual(findNotesQueries, ['"deck:My \\"Japanese\\" Deck" added:1']);
assert.deepEqual(processed, [501]);
});
test('proxy detects self-referential loop configuration', () => { test('proxy detects self-referential loop configuration', () => {
const proxy = new AnkiConnectProxyServer({ const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true, shouldAutoUpdateNewCards: () => true,
@@ -316,15 +274,13 @@ test('proxy detects self-referential loop configuration', () => {
logError: () => undefined, logError: () => undefined,
}); });
const result = ( const result = (proxy as unknown as {
proxy as unknown as {
isSelfReferentialProxy: (options: { isSelfReferentialProxy: (options: {
host: string; host: string;
port: number; port: number;
upstreamUrl: string; upstreamUrl: string;
}) => boolean; }) => boolean;
} }).isSelfReferentialProxy({
).isSelfReferentialProxy({
host: '127.0.0.1', host: '127.0.0.1',
port: 8766, port: 8766,
upstreamUrl: 'http://localhost:8766', upstreamUrl: 'http://localhost:8766',

View File

@@ -175,9 +175,7 @@ export class AnkiConnectProxyServer {
} }
const action = const action =
typeof requestJson.action === 'string' typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? '');
? requestJson.action
: String(requestJson.action ?? '');
if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') { if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') {
return; return;
} }
@@ -235,8 +233,7 @@ export class AnkiConnectProxyServer {
try { try {
const deck = this.deps.getDeck ? this.deps.getDeck() : undefined; const deck = this.deps.getDeck ? this.deps.getDeck() : undefined;
const escapedDeck = deck ? deck.replace(/"/g, '\\"') : undefined; const query = deck ? `"deck:${deck}" added:1` : 'added:1';
const query = escapedDeck ? `"deck:${escapedDeck}" added:1` : 'added:1';
const noteIds = await findNotes(query, { maxRetries: 0 }); const noteIds = await findNotes(query, { maxRetries: 0 });
if (!noteIds || noteIds.length === 0) { if (!noteIds || noteIds.length === 0) {
return; return;

View File

@@ -89,11 +89,7 @@ test('findDuplicateNote checks both source expression/word values when both fiel
if (query.includes('昨日は雨だった。')) { if (query.includes('昨日は雨だった。')) {
return []; return [];
} }
if ( if (query.includes('"Word:雨"') || query.includes('"word:雨"') || query.includes('"Expression:雨"')) {
query.includes('"Word:雨"') ||
query.includes('"word:雨"') ||
query.includes('"Expression:雨"')
) {
return [200]; return [200];
} }
return []; return [];

View File

@@ -32,7 +32,9 @@ export async function findDuplicateNote(
); );
const deckValue = deps.getDeck(); const deckValue = deps.getDeck();
const queryPrefixes = deckValue ? [`"deck:${escapeAnkiSearchValue(deckValue)}" `, ''] : ['']; const queryPrefixes = deckValue
? [`"deck:${escapeAnkiSearchValue(deckValue)}" `, '']
: [''];
try { try {
const noteIds = new Set<number>(); const noteIds = new Set<number>();

View File

@@ -112,7 +112,12 @@ export class FieldGroupingWorkflow {
const keepNoteId = choice.keepNoteId; const keepNoteId = choice.keepNoteId;
const deleteNoteId = choice.deleteNoteId; const deleteNoteId = choice.deleteNoteId;
await this.performMerge(keepNoteId, deleteNoteId, expression, choice.deleteDuplicate); await this.performMerge(
keepNoteId,
deleteNoteId,
expression,
choice.deleteDuplicate,
);
return true; return true;
} catch (error) { } catch (error) {
this.deps.logError('Field grouping manual merge failed:', (error as Error).message); this.deps.logError('Field grouping manual merge failed:', (error as Error).message);

View File

@@ -51,10 +51,18 @@ function createWorkflowHarness() {
return out; return out;
}, },
findDuplicateNote: async (_expression, _excludeNoteId, _noteInfo) => null, findDuplicateNote: async (_expression, _excludeNoteId, _noteInfo) => null,
handleFieldGroupingAuto: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) => handleFieldGroupingAuto: async (
undefined, _originalNoteId,
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) => _newNoteId,
false, _newNoteInfo,
_expression,
) => undefined,
handleFieldGroupingManual: async (
_originalNoteId,
_newNoteId,
_newNoteInfo,
_expression,
) => false,
processSentence: (text: string, _noteFields: Record<string, string>) => text, processSentence: (text: string, _noteFields: Record<string, string>) => text,
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => { resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
if (!preferred) return null; if (!preferred) return null;

View File

@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { hasExplicitCommand, parseArgs, shouldRunSettingsOnlyStartup, shouldStartApp } from './args'; import { hasExplicitCommand, parseArgs, shouldStartApp } from './args';
test('parseArgs parses booleans and value flags', () => { test('parseArgs parses booleans and value flags', () => {
const args = parseArgs([ const args = parseArgs([
@@ -60,28 +60,6 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(refreshKnownWords), true); assert.equal(hasExplicitCommand(refreshKnownWords), true);
assert.equal(shouldStartApp(refreshKnownWords), false); assert.equal(shouldStartApp(refreshKnownWords), false);
const settings = parseArgs(['--settings']);
assert.equal(settings.settings, true);
assert.equal(hasExplicitCommand(settings), true);
assert.equal(shouldStartApp(settings), true);
assert.equal(shouldRunSettingsOnlyStartup(settings), true);
const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']);
assert.equal(settingsWithOverlay.settings, true);
assert.equal(settingsWithOverlay.toggleVisibleOverlay, true);
assert.equal(shouldRunSettingsOnlyStartup(settingsWithOverlay), false);
const yomitanAlias = parseArgs(['--yomitan']);
assert.equal(yomitanAlias.settings, true);
assert.equal(hasExplicitCommand(yomitanAlias), true);
assert.equal(shouldStartApp(yomitanAlias), true);
const help = parseArgs(['--help']);
assert.equal(help.help, true);
assert.equal(hasExplicitCommand(help), true);
assert.equal(shouldStartApp(help), false);
assert.equal(shouldRunSettingsOnlyStartup(help), false);
const anilistStatus = parseArgs(['--anilist-status']); const anilistStatus = parseArgs(['--anilist-status']);
assert.equal(anilistStatus.anilistStatus, true); assert.equal(anilistStatus.anilistStatus, true);
assert.equal(hasExplicitCommand(anilistStatus), true); assert.equal(hasExplicitCommand(anilistStatus), true);

View File

@@ -295,7 +295,6 @@ export function shouldStartApp(args: CliArgs): boolean {
args.start || args.start ||
args.toggle || args.toggle ||
args.toggleVisibleOverlay || args.toggleVisibleOverlay ||
args.settings ||
args.copySubtitle || args.copySubtitle ||
args.copySubtitleMultiple || args.copySubtitleMultiple ||
args.mineSentence || args.mineSentence ||
@@ -315,50 +314,6 @@ export function shouldStartApp(args: CliArgs): boolean {
return false; return false;
} }
export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
return (
args.settings &&
!args.background &&
!args.start &&
!args.stop &&
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.show &&
!args.hide &&
!args.showVisibleOverlay &&
!args.hideVisibleOverlay &&
!args.copySubtitle &&
!args.copySubtitleMultiple &&
!args.mineSentence &&
!args.mineSentenceMultiple &&
!args.updateLastCardFromClipboard &&
!args.refreshKnownWords &&
!args.toggleSecondarySub &&
!args.triggerFieldGrouping &&
!args.triggerSubsync &&
!args.markAudioCard &&
!args.openRuntimeOptions &&
!args.anilistStatus &&
!args.anilistLogout &&
!args.anilistSetup &&
!args.anilistRetryQueue &&
!args.jellyfin &&
!args.jellyfinLogin &&
!args.jellyfinLogout &&
!args.jellyfinLibraries &&
!args.jellyfinItems &&
!args.jellyfinSubtitles &&
!args.jellyfinPlay &&
!args.jellyfinRemoteAnnounce &&
!args.texthooker &&
!args.help &&
!args.autoStartOverlay &&
!args.generateConfig &&
!args.backupOverwrite &&
!args.debug
);
}
export function commandNeedsOverlayRuntime(args: CliArgs): boolean { export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
return ( return (
args.toggle || args.toggle ||

View File

@@ -32,7 +32,6 @@ test('loads defaults when config is missing', () => {
assert.equal(config.discordPresence.updateIntervalMs, 3_000); assert.equal(config.discordPresence.updateIntervalMs, 3_000);
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)'); assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
assert.equal(config.subtitleStyle.preserveLineBreaks, false); assert.equal(config.subtitleStyle.preserveLineBreaks, false);
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6'); assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)'); assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
assert.equal( assert.equal(
@@ -47,7 +46,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision'); assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
assert.equal(config.subtitleStyle.textShadow, '0 3px 10px rgba(0,0,0,0.69)'); assert.equal(config.subtitleStyle.textShadow, '0 3px 10px rgba(0,0,0,0.69)');
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)'); assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
assert.equal(config.subtitleStyle.secondary.fontFamily, 'Inter, Noto Sans, Helvetica Neue, sans-serif'); assert.equal(config.subtitleStyle.secondary.fontFamily, 'Manrope, Inter');
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5'); assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
assert.equal(config.immersionTracking.enabled, true); assert.equal(config.immersionTracking.enabled, true);
assert.equal(config.immersionTracking.dbPath, ''); assert.equal(config.immersionTracking.dbPath, '');
@@ -119,44 +118,6 @@ test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () =
); );
}); });
test('parses subtitleStyle.autoPauseVideoOnHover and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"autoPauseVideoOnHover": true
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.autoPauseVideoOnHover, true);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"autoPauseVideoOnHover": "yes"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.autoPauseVideoOnHover,
DEFAULT_CONFIG.subtitleStyle.autoPauseVideoOnHover,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.autoPauseVideoOnHover'),
);
});
test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => { test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
const validDir = makeTempDir(); const validDir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
@@ -787,9 +748,6 @@ test('runtime options registry is centralized', () => {
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id); const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
assert.deepEqual(ids, [ assert.deepEqual(ids, [
'anki.autoUpdateNewCards', 'anki.autoUpdateNewCards',
'subtitle.annotation.nPlusOne',
'subtitle.annotation.jlpt',
'subtitle.annotation.frequency',
'anki.nPlusOneMatchMode', 'anki.nPlusOneMatchMode',
'anki.kikuFieldGrouping', 'anki.kikuFieldGrouping',
]); ]);

View File

@@ -4,7 +4,6 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
subtitleStyle: { subtitleStyle: {
enableJlpt: false, enableJlpt: false,
preserveLineBreaks: false, preserveLineBreaks: false,
autoPauseVideoOnHover: true,
hoverTokenColor: '#f4dbd6', hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)', hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP', fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
@@ -34,12 +33,11 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
sourcePath: '', sourcePath: '',
topX: 1000, topX: 1000,
mode: 'single', mode: 'single',
matchMode: 'headword',
singleColor: '#f5a97f', singleColor: '#f5a97f',
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'], bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'],
}, },
secondary: { secondary: {
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif', fontFamily: 'Manrope, Inter',
fontSize: 24, fontSize: 24,
fontColor: '#cad3f5', fontColor: '#cad3f5',
lineHeight: 1.35, lineHeight: 1.35,

View File

@@ -5,7 +5,6 @@ import {
CONFIG_OPTION_REGISTRY, CONFIG_OPTION_REGISTRY,
CONFIG_TEMPLATE_SECTIONS, CONFIG_TEMPLATE_SECTIONS,
DEFAULT_CONFIG, DEFAULT_CONFIG,
DEFAULT_KEYBINDINGS,
RUNTIME_OPTION_REGISTRY, RUNTIME_OPTION_REGISTRY,
} from '../definitions'; } from '../definitions';
import { buildCoreConfigOptionRegistry } from './options-core'; import { buildCoreConfigOptionRegistry } from './options-core';
@@ -60,11 +59,3 @@ test('domain registry builders each contribute entries to composed registry', ()
assert.ok(entries.some((entry) => composedPaths.has(entry.path))); assert.ok(entries.some((entry) => composedPaths.has(entry.path)));
} }
}); });
test('default keybindings include primary and secondary subtitle track cycling on J keys', () => {
const keybindingMap = new Map(
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
);
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
});

View File

@@ -5,8 +5,6 @@ export function buildIntegrationConfigOptionRegistry(
defaultConfig: ResolvedConfig, defaultConfig: ResolvedConfig,
runtimeOptionRegistry: RuntimeOptionRegistryEntry[], runtimeOptionRegistry: RuntimeOptionRegistryEntry[],
): ConfigOptionRegistryEntry[] { ): ConfigOptionRegistryEntry[] {
const runtimeOptionById = new Map(runtimeOptionRegistry.map((entry) => [entry.id, entry]));
return [ return [
{ {
path: 'ankiConnect.enabled', path: 'ankiConnect.enabled',
@@ -56,7 +54,7 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'boolean', kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.behavior.autoUpdateNewCards, defaultValue: defaultConfig.ankiConnect.behavior.autoUpdateNewCards,
description: 'Automatically update newly added cards.', description: 'Automatically update newly added cards.',
runtime: runtimeOptionById.get('anki.autoUpdateNewCards'), runtime: runtimeOptionRegistry[0],
}, },
{ {
path: 'ankiConnect.nPlusOne.matchMode', path: 'ankiConnect.nPlusOne.matchMode',
@@ -107,7 +105,7 @@ export function buildIntegrationConfigOptionRegistry(
enumValues: ['auto', 'manual', 'disabled'], enumValues: ['auto', 'manual', 'disabled'],
defaultValue: defaultConfig.ankiConnect.isKiku.fieldGrouping, defaultValue: defaultConfig.ankiConnect.isKiku.fieldGrouping,
description: 'Kiku duplicate-card field grouping mode.', description: 'Kiku duplicate-card field grouping mode.',
runtime: runtimeOptionById.get('anki.kikuFieldGrouping'), runtime: runtimeOptionRegistry[1],
}, },
{ {
path: 'jimaku.languagePreference', path: 'jimaku.languagePreference',

View File

@@ -21,13 +21,6 @@ export function buildSubtitleConfigOptionRegistry(
'Preserve line breaks in visible overlay subtitle rendering. ' + 'Preserve line breaks in visible overlay subtitle rendering. ' +
'When false, line breaks are flattened to spaces for a single-line flow.', 'When false, line breaks are flattened to spaces for a single-line flow.',
}, },
{
path: 'subtitleStyle.autoPauseVideoOnHover',
kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.autoPauseVideoOnHover,
description:
'Automatically pause mpv playback while hovering subtitle text, then resume on leave.',
},
{ {
path: 'subtitleStyle.hoverTokenColor', path: 'subtitleStyle.hoverTokenColor',
kind: 'string', kind: 'string',
@@ -68,14 +61,6 @@ export function buildSubtitleConfigOptionRegistry(
description: description:
'single: use one color for all matching tokens. banded: use color ramp by frequency band.', 'single: use one color for all matching tokens. banded: use color ramp by frequency band.',
}, },
{
path: 'subtitleStyle.frequencyDictionary.matchMode',
kind: 'enum',
enumValues: ['headword', 'surface'],
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.matchMode,
description:
'headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text.',
},
{ {
path: 'subtitleStyle.frequencyDictionary.singleColor', path: 'subtitleStyle.frequencyDictionary.singleColor',
kind: 'string', kind: 'string',

View File

@@ -19,42 +19,6 @@ export function buildRuntimeOptionRegistry(
behavior: { autoUpdateNewCards: value === true }, behavior: { autoUpdateNewCards: value === true },
}), }),
}, },
{
id: 'subtitle.annotation.nPlusOne',
path: 'ankiConnect.nPlusOne.highlightEnabled',
label: 'N+1 Annotation',
scope: 'subtitle',
valueType: 'boolean',
allowedValues: [true, false],
defaultValue: defaultConfig.ankiConnect.nPlusOne.highlightEnabled,
requiresRestart: false,
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
toAnkiPatch: () => ({}),
},
{
id: 'subtitle.annotation.jlpt',
path: 'subtitleStyle.enableJlpt',
label: 'JLPT Annotation',
scope: 'subtitle',
valueType: 'boolean',
allowedValues: [true, false],
defaultValue: defaultConfig.subtitleStyle.enableJlpt,
requiresRestart: false,
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
toAnkiPatch: () => ({}),
},
{
id: 'subtitle.annotation.frequency',
path: 'subtitleStyle.frequencyDictionary.enabled',
label: 'Frequency Annotation',
scope: 'subtitle',
valueType: 'boolean',
allowedValues: [true, false],
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.enabled,
requiresRestart: false,
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
toAnkiPatch: () => ({}),
},
{ {
id: 'anki.nPlusOneMatchMode', id: 'anki.nPlusOneMatchMode',
path: 'ankiConnect.nPlusOne.matchMode', path: 'ankiConnect.nPlusOne.matchMode',

View File

@@ -48,8 +48,6 @@ export const SPECIAL_COMMANDS = {
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
{ key: 'Space', command: ['cycle', 'pause'] }, { key: 'Space', command: ['cycle', 'pause'] },
{ key: 'KeyJ', command: ['cycle', 'sid'] },
{ key: 'Shift+KeyJ', command: ['cycle', 'secondary-sid'] },
{ key: 'ArrowRight', command: ['seek', 5] }, { key: 'ArrowRight', command: ['seek', 5] },
{ key: 'ArrowLeft', command: ['seek', -5] }, { key: 'ArrowLeft', command: ['seek', -5] },
{ key: 'ArrowUp', command: ['seek', 60] }, { key: 'ArrowUp', command: ['seek', 60] },

View File

@@ -99,24 +99,12 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
if (isObject(src.subtitleStyle)) { if (isObject(src.subtitleStyle)) {
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt; const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks; const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
const fallbackSubtitleStyleAutoPauseVideoOnHover =
resolved.subtitleStyle.autoPauseVideoOnHover;
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor; const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
const fallbackSubtitleStyleHoverTokenBackgroundColor = const fallbackSubtitleStyleHoverTokenBackgroundColor =
resolved.subtitleStyle.hoverTokenBackgroundColor; resolved.subtitleStyle.hoverTokenBackgroundColor;
const fallbackFrequencyDictionary = {
...resolved.subtitleStyle.frequencyDictionary,
};
resolved.subtitleStyle = { resolved.subtitleStyle = {
...resolved.subtitleStyle, ...resolved.subtitleStyle,
...(src.subtitleStyle as ResolvedConfig['subtitleStyle']), ...(src.subtitleStyle as ResolvedConfig['subtitleStyle']),
frequencyDictionary: {
...resolved.subtitleStyle.frequencyDictionary,
...(isObject((src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary)
? ((src.subtitleStyle as { frequencyDictionary?: unknown })
.frequencyDictionary as ResolvedConfig['subtitleStyle']['frequencyDictionary'])
: {}),
},
secondary: { secondary: {
...resolved.subtitleStyle.secondary, ...resolved.subtitleStyle.secondary,
...(isObject(src.subtitleStyle.secondary) ...(isObject(src.subtitleStyle.secondary)
@@ -155,27 +143,7 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
); );
} }
const autoPauseVideoOnHover = asBoolean( const hoverTokenColor = asColor((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor);
(src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover,
);
if (autoPauseVideoOnHover !== undefined) {
resolved.subtitleStyle.autoPauseVideoOnHover = autoPauseVideoOnHover;
} else if (
(src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover !==
undefined
) {
resolved.subtitleStyle.autoPauseVideoOnHover = fallbackSubtitleStyleAutoPauseVideoOnHover;
warn(
'subtitleStyle.autoPauseVideoOnHover',
(src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover,
resolved.subtitleStyle.autoPauseVideoOnHover,
'Expected boolean.',
);
}
const hoverTokenColor = asColor(
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
);
if (hoverTokenColor !== undefined) { if (hoverTokenColor !== undefined) {
resolved.subtitleStyle.hoverTokenColor = hoverTokenColor; resolved.subtitleStyle.hoverTokenColor = hoverTokenColor;
} else if ((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor !== undefined) { } else if ((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor !== undefined) {
@@ -197,8 +165,7 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor !== (src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor !==
undefined undefined
) { ) {
resolved.subtitleStyle.hoverTokenBackgroundColor = resolved.subtitleStyle.hoverTokenBackgroundColor = fallbackSubtitleStyleHoverTokenBackgroundColor;
fallbackSubtitleStyleHoverTokenBackgroundColor;
warn( warn(
'subtitleStyle.hoverTokenBackgroundColor', 'subtitleStyle.hoverTokenBackgroundColor',
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor, (src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
@@ -219,7 +186,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
if (frequencyEnabled !== undefined) { if (frequencyEnabled !== undefined) {
resolved.subtitleStyle.frequencyDictionary.enabled = frequencyEnabled; resolved.subtitleStyle.frequencyDictionary.enabled = frequencyEnabled;
} else if ((frequencyDictionary as { enabled?: unknown }).enabled !== undefined) { } else if ((frequencyDictionary as { enabled?: unknown }).enabled !== undefined) {
resolved.subtitleStyle.frequencyDictionary.enabled = fallbackFrequencyDictionary.enabled;
warn( warn(
'subtitleStyle.frequencyDictionary.enabled', 'subtitleStyle.frequencyDictionary.enabled',
(frequencyDictionary as { enabled?: unknown }).enabled, (frequencyDictionary as { enabled?: unknown }).enabled,
@@ -232,8 +198,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
if (sourcePath !== undefined) { if (sourcePath !== undefined) {
resolved.subtitleStyle.frequencyDictionary.sourcePath = sourcePath; resolved.subtitleStyle.frequencyDictionary.sourcePath = sourcePath;
} else if ((frequencyDictionary as { sourcePath?: unknown }).sourcePath !== undefined) { } else if ((frequencyDictionary as { sourcePath?: unknown }).sourcePath !== undefined) {
resolved.subtitleStyle.frequencyDictionary.sourcePath =
fallbackFrequencyDictionary.sourcePath;
warn( warn(
'subtitleStyle.frequencyDictionary.sourcePath', 'subtitleStyle.frequencyDictionary.sourcePath',
(frequencyDictionary as { sourcePath?: unknown }).sourcePath, (frequencyDictionary as { sourcePath?: unknown }).sourcePath,
@@ -246,7 +210,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
if (topX !== undefined && Number.isInteger(topX) && topX > 0) { if (topX !== undefined && Number.isInteger(topX) && topX > 0) {
resolved.subtitleStyle.frequencyDictionary.topX = Math.floor(topX); resolved.subtitleStyle.frequencyDictionary.topX = Math.floor(topX);
} else if ((frequencyDictionary as { topX?: unknown }).topX !== undefined) { } else if ((frequencyDictionary as { topX?: unknown }).topX !== undefined) {
resolved.subtitleStyle.frequencyDictionary.topX = fallbackFrequencyDictionary.topX;
warn( warn(
'subtitleStyle.frequencyDictionary.topX', 'subtitleStyle.frequencyDictionary.topX',
(frequencyDictionary as { topX?: unknown }).topX, (frequencyDictionary as { topX?: unknown }).topX,
@@ -259,7 +222,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
if (frequencyMode === 'single' || frequencyMode === 'banded') { if (frequencyMode === 'single' || frequencyMode === 'banded') {
resolved.subtitleStyle.frequencyDictionary.mode = frequencyMode; resolved.subtitleStyle.frequencyDictionary.mode = frequencyMode;
} else if (frequencyMode !== undefined) { } else if (frequencyMode !== undefined) {
resolved.subtitleStyle.frequencyDictionary.mode = fallbackFrequencyDictionary.mode;
warn( warn(
'subtitleStyle.frequencyDictionary.mode', 'subtitleStyle.frequencyDictionary.mode',
frequencyDictionary.mode, frequencyDictionary.mode,
@@ -268,25 +230,10 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
); );
} }
const frequencyMatchMode = (frequencyDictionary as { matchMode?: unknown }).matchMode;
if (frequencyMatchMode === 'headword' || frequencyMatchMode === 'surface') {
resolved.subtitleStyle.frequencyDictionary.matchMode = frequencyMatchMode;
} else if (frequencyMatchMode !== undefined) {
resolved.subtitleStyle.frequencyDictionary.matchMode = fallbackFrequencyDictionary.matchMode;
warn(
'subtitleStyle.frequencyDictionary.matchMode',
frequencyMatchMode,
resolved.subtitleStyle.frequencyDictionary.matchMode,
"Expected 'headword' or 'surface'.",
);
}
const singleColor = asColor((frequencyDictionary as { singleColor?: unknown }).singleColor); const singleColor = asColor((frequencyDictionary as { singleColor?: unknown }).singleColor);
if (singleColor !== undefined) { if (singleColor !== undefined) {
resolved.subtitleStyle.frequencyDictionary.singleColor = singleColor; resolved.subtitleStyle.frequencyDictionary.singleColor = singleColor;
} else if ((frequencyDictionary as { singleColor?: unknown }).singleColor !== undefined) { } else if ((frequencyDictionary as { singleColor?: unknown }).singleColor !== undefined) {
resolved.subtitleStyle.frequencyDictionary.singleColor =
fallbackFrequencyDictionary.singleColor;
warn( warn(
'subtitleStyle.frequencyDictionary.singleColor', 'subtitleStyle.frequencyDictionary.singleColor',
(frequencyDictionary as { singleColor?: unknown }).singleColor, (frequencyDictionary as { singleColor?: unknown }).singleColor,
@@ -301,8 +248,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
if (bandedColors !== undefined) { if (bandedColors !== undefined) {
resolved.subtitleStyle.frequencyDictionary.bandedColors = bandedColors; resolved.subtitleStyle.frequencyDictionary.bandedColors = bandedColors;
} else if ((frequencyDictionary as { bandedColors?: unknown }).bandedColors !== undefined) { } else if ((frequencyDictionary as { bandedColors?: unknown }).bandedColors !== undefined) {
resolved.subtitleStyle.frequencyDictionary.bandedColors =
fallbackFrequencyDictionary.bandedColors;
warn( warn(
'subtitleStyle.frequencyDictionary.bandedColors', 'subtitleStyle.frequencyDictionary.bandedColors',
(frequencyDictionary as { bandedColors?: unknown }).bandedColors, (frequencyDictionary as { bandedColors?: unknown }).bandedColors,

View File

@@ -27,51 +27,3 @@ test('subtitleStyle preserveLineBreaks falls back while merge is preserved', ()
), ),
); );
}); });
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
const { context, warnings } = createResolveContext({
subtitleStyle: {
autoPauseVideoOnHover: 'invalid' as unknown as boolean,
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnHover, true);
assert.ok(
warnings.some(
(warning) =>
warning.path === 'subtitleStyle.autoPauseVideoOnHover' &&
warning.message === 'Expected boolean.',
),
);
});
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
const valid = createResolveContext({
subtitleStyle: {
frequencyDictionary: {
matchMode: 'surface',
},
},
});
applySubtitleDomainConfig(valid.context);
assert.equal(valid.context.resolved.subtitleStyle.frequencyDictionary.matchMode, 'surface');
const invalid = createResolveContext({
subtitleStyle: {
frequencyDictionary: {
matchMode: 'reading' as unknown as 'headword' | 'surface',
},
},
});
applySubtitleDomainConfig(invalid.context);
assert.equal(invalid.context.resolved.subtitleStyle.frequencyDictionary.matchMode, 'headword');
assert.ok(
invalid.warnings.some(
(warning) =>
warning.path === 'subtitleStyle.frequencyDictionary.matchMode' &&
warning.message === "Expected 'headword' or 'surface'.",
),
);
});

View File

@@ -25,8 +25,7 @@ function humanizeKey(key: string): string {
function buildInlineOptionComment(path: string, value: unknown): string { function buildInlineOptionComment(path: string, value: unknown): string {
const registryEntry = OPTION_REGISTRY_BY_PATH.get(path); const registryEntry = OPTION_REGISTRY_BY_PATH.get(path);
const baseDescription = const baseDescription = registryEntry?.description ?? TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY.get(path);
registryEntry?.description ?? TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY.get(path);
const description = const description =
baseDescription && baseDescription.trim().length > 0 baseDescription && baseDescription.trim().length > 0
? normalizeCommentText(baseDescription) ? normalizeCommentText(baseDescription)

View File

@@ -132,15 +132,16 @@ export function createAnilistTokenStore(
} }
return decrypted; return decrypted;
} }
if (typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0) { if (
typeof parsed.plaintextToken === 'string' &&
parsed.plaintextToken.trim().length > 0
) {
if (storage.isEncryptionAvailable()) { if (storage.isEncryptionAvailable()) {
if (!isSafeStorageUsable()) { if (!isSafeStorageUsable()) {
return null; return null;
} }
const plaintext = parsed.plaintextToken.trim(); const plaintext = parsed.plaintextToken.trim();
notifyUser( notifyUser('AniList token plaintext fallback payload found. Migrating to encrypted storage.');
'AniList token plaintext fallback payload found. Migrating to encrypted storage.',
);
this.saveToken(plaintext); this.saveToken(plaintext);
return plaintext; return plaintext;
} }

View File

@@ -66,55 +66,6 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
); );
}); });
test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns true', async () => {
const { deps, calls } = makeDeps({
shouldSkipHeavyStartup: () => true,
reloadConfig: () => calls.push('reloadConfig'),
getResolvedConfig: () => {
calls.push('getResolvedConfig');
return {
websocket: { enabled: 'auto' },
secondarySub: {},
};
},
getConfigWarnings: () => {
calls.push('getConfigWarnings');
return [];
},
setLogLevel: (level, source) => calls.push(`setLogLevel:${level}:${source}`),
initRuntimeOptionsManager: () => calls.push('initRuntimeOptionsManager'),
startBackgroundWarmups: () => calls.push('startBackgroundWarmups'),
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
resolveKeybindings: () => calls.push('resolveKeybindings'),
createMpvClient: () => calls.push('createMpvClient'),
logConfigWarning: () => calls.push('logConfigWarning'),
startJellyfinRemoteSession: async () => {
calls.push('startJellyfinRemoteSession');
},
createImmersionTracker: () => calls.push('createImmersionTracker'),
handleInitialArgs: () => calls.push('handleInitialArgs'),
});
await runAppReadyRuntime(deps);
assert.equal(calls.includes('reloadConfig'), false);
assert.equal(calls.includes('getResolvedConfig'), false);
assert.equal(calls.includes('getConfigWarnings'), false);
assert.equal(calls.includes('setLogLevel:warn:config'), false);
assert.equal(calls.includes('startBackgroundWarmups'), false);
assert.equal(calls.includes('loadSubtitlePosition'), false);
assert.equal(calls.includes('resolveKeybindings'), false);
assert.equal(calls.includes('createMpvClient'), false);
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
assert.equal(calls.includes('createImmersionTracker'), false);
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
assert.equal(calls.includes('logConfigWarning'), false);
assert.equal(calls.includes('handleInitialArgs'), true);
assert.equal(calls.includes('loadYomitanExtension'), true);
assert.equal(calls[0], 'loadYomitanExtension');
assert.equal(calls[calls.length - 1], 'handleInitialArgs');
});
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => { test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
const { deps, calls } = makeDeps({ const { deps, calls } = makeDeps({
startJellyfinRemoteSession: undefined, startJellyfinRemoteSession: undefined,
@@ -180,30 +131,12 @@ test('runAppReadyRuntime does not await background warmups', async () => {
}); });
await runAppReadyRuntime(deps); await runAppReadyRuntime(deps);
assert.ok(calls.includes('startBackgroundWarmups')); assert.deepEqual(calls.slice(0, 2), ['handleInitialArgs', 'startBackgroundWarmups']);
assert.ok(calls.includes('handleInitialArgs'));
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
assert.equal(calls.includes('warmupDone'), false); assert.equal(calls.includes('warmupDone'), false);
assert.ok(releaseWarmup); assert.ok(releaseWarmup);
releaseWarmup(); releaseWarmup();
}); });
test('runAppReadyRuntime starts background warmups before core runtime services', async () => {
const calls: string[] = [];
const { deps } = makeDeps({
startBackgroundWarmups: () => {
calls.push('startBackgroundWarmups');
},
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
createMpvClient: () => calls.push('createMpvClient'),
});
await runAppReadyRuntime(deps);
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('loadSubtitlePosition'));
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('createMpvClient'));
});
test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => { test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => {
const capturedErrors: string[][] = []; const capturedErrors: string[][] = [];
const { deps, calls } = makeDeps({ const { deps, calls } = makeDeps({

View File

@@ -220,18 +220,6 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
); );
}); });
test('handleCliCommand applies cli log level for second-instance commands', () => {
const { deps, calls } = createDeps({
setLogLevel: (level) => {
calls.push(`setLogLevel:${level}`);
},
});
handleCliCommand(makeArgs({ start: true, logLevel: 'debug' }), 'second-instance', deps);
assert.ok(calls.includes('setLogLevel:debug'));
});
test('handleCliCommand runs texthooker flow with browser open', () => { test('handleCliCommand runs texthooker flow with browser open', () => {
const { deps, calls } = createDeps(); const { deps, calls } = createDeps();
const args = makeArgs({ texthooker: true }); const args = makeArgs({ texthooker: true });

View File

@@ -1,7 +1,6 @@
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args'; import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
export interface CliCommandServiceDeps { export interface CliCommandServiceDeps {
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
getMpvSocketPath: () => string; getMpvSocketPath: () => string;
setMpvSocketPath: (socketPath: string) => void; setMpvSocketPath: (socketPath: string) => void;
setMpvClientSocketPath: (socketPath: string) => void; setMpvClientSocketPath: (socketPath: string) => void;
@@ -128,7 +127,6 @@ interface AppCliRuntime {
} }
export interface CliCommandDepsRuntimeOptions { export interface CliCommandDepsRuntimeOptions {
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
mpv: MpvCliRuntime; mpv: MpvCliRuntime;
texthooker: TexthookerCliRuntime; texthooker: TexthookerCliRuntime;
overlay: OverlayCliRuntime; overlay: OverlayCliRuntime;
@@ -151,7 +149,6 @@ export function createCliCommandDepsRuntime(
options: CliCommandDepsRuntimeOptions, options: CliCommandDepsRuntimeOptions,
): CliCommandServiceDeps { ): CliCommandServiceDeps {
return { return {
setLogLevel: options.setLogLevel,
getMpvSocketPath: options.mpv.getSocketPath, getMpvSocketPath: options.mpv.getSocketPath,
setMpvSocketPath: options.mpv.setSocketPath, setMpvSocketPath: options.mpv.setSocketPath,
setMpvClientSocketPath: (socketPath) => { setMpvClientSocketPath: (socketPath) => {
@@ -235,10 +232,6 @@ export function handleCliCommand(
source: CliCommandSource = 'initial', source: CliCommandSource = 'initial',
deps: CliCommandServiceDeps, deps: CliCommandServiceDeps,
): void { ): void {
if (args.logLevel) {
deps.setLogLevel?.(args.logLevel);
}
const hasNonStartAction = const hasNonStartAction =
args.stop || args.stop ||
args.toggle || args.toggle ||
@@ -283,7 +276,8 @@ export function handleCliCommand(
return; return;
} }
const shouldStart = args.start || args.toggle || args.toggleVisibleOverlay; const shouldStart =
args.start || args.toggle || args.toggleVisibleOverlay;
const needsOverlayRuntime = commandNeedsOverlayRuntime(args); const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start; const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;

View File

@@ -71,8 +71,7 @@ test('createFrequencyDictionaryLookup aggregates duplicate-term logs into a sing
assert.equal(lookup('猫'), 100); assert.equal(lookup('猫'), 100);
assert.equal( assert.equal(
logs.filter((entry) => entry.includes('Frequency dictionary ignored 2 duplicate term entries')) logs.filter((entry) => entry.includes('Frequency dictionary ignored 2 duplicate term entries')).length,
.length,
1, 1,
); );
assert.equal( assert.equal(
@@ -81,7 +80,7 @@ test('createFrequencyDictionaryLookup aggregates duplicate-term logs into a sing
); );
}); });
test('createFrequencyDictionaryLookup prefers frequency.displayValue over value when both exist', async () => { test('createFrequencyDictionaryLookup prefers frequency.value over displayValue', async () => {
const logs: string[] = []; const logs: string[] = [];
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-frequency-dict-')); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-frequency-dict-'));
const bankPath = path.join(tempDir, 'term_meta_bank_1.json'); const bankPath = path.join(tempDir, 'term_meta_bank_1.json');
@@ -89,7 +88,6 @@ test('createFrequencyDictionaryLookup prefers frequency.displayValue over value
bankPath, bankPath,
JSON.stringify([ JSON.stringify([
['猫', 1, { frequency: { value: 1234, displayValue: 1200 } }], ['猫', 1, { frequency: { value: 1234, displayValue: 1200 } }],
['鍛える', 2, { frequency: { value: 46961, displayValue: 2847 } }],
['犬', 2, { frequency: { displayValue: 88 } }], ['犬', 2, { frequency: { displayValue: 88 } }],
]), ]),
); );
@@ -101,31 +99,10 @@ test('createFrequencyDictionaryLookup prefers frequency.displayValue over value
}, },
}); });
assert.equal(lookup('猫'), 1200); assert.equal(lookup('猫'), 1234);
assert.equal(lookup('鍛える'), 2847);
assert.equal(lookup('犬'), 88); assert.equal(lookup('犬'), 88);
assert.equal( assert.equal(
logs.some((entry) => entry.includes('Frequency dictionary loaded from')), logs.some((entry) => entry.includes('Frequency dictionary loaded from')),
true, true,
); );
}); });
test('createFrequencyDictionaryLookup parses composite displayValue by primary rank', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-frequency-dict-'));
const bankPath = path.join(tempDir, 'term_meta_bank_1.json');
fs.writeFileSync(
bankPath,
JSON.stringify([
['鍛える', 1, { frequency: { displayValue: '3272,52377' } }],
['高み', 2, { frequency: { displayValue: '9933,108961' } }],
]),
);
const lookup = await createFrequencyDictionaryLookup({
searchPaths: [tempDir],
log: () => undefined,
});
assert.equal(lookup('鍛える'), 3272);
assert.equal(lookup('高み'), 9933);
});

View File

@@ -18,32 +18,6 @@ function normalizeFrequencyTerm(value: string): string {
return value.trim().toLowerCase(); return value.trim().toLowerCase();
} }
function parsePositiveFrequencyString(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const numericPrefix = trimmed.match(/^\d[\d,]*/)?.[0];
if (!numericPrefix) {
return null;
}
const chunks = numericPrefix.split(',');
const normalizedNumber =
chunks.length <= 1
? (chunks[0] ?? '')
: chunks.slice(1).every((chunk) => /^\d{3}$/.test(chunk))
? chunks.join('')
: (chunks[0] ?? '');
const parsed = Number.parseInt(normalizedNumber, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return parsed;
}
function parsePositiveFrequencyNumber(value: unknown): number | null { function parsePositiveFrequencyNumber(value: unknown): number | null {
if (typeof value === 'number') { if (typeof value === 'number') {
if (!Number.isFinite(value) || value <= 0) return null; if (!Number.isFinite(value) || value <= 0) return null;
@@ -51,7 +25,10 @@ function parsePositiveFrequencyNumber(value: unknown): number | null {
} }
if (typeof value === 'string') { if (typeof value === 'string') {
return parsePositiveFrequencyString(value); const normalized = value.trim().replace(/,/g, '');
const parsed = Number.parseInt(normalized, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
} }
return null; return null;
@@ -61,14 +38,14 @@ function extractFrequencyDisplayValue(meta: unknown): number | null {
if (!meta || typeof meta !== 'object') return null; if (!meta || typeof meta !== 'object') return null;
const frequency = (meta as { frequency?: unknown }).frequency; const frequency = (meta as { frequency?: unknown }).frequency;
if (!frequency || typeof frequency !== 'object') return null; if (!frequency || typeof frequency !== 'object') return null;
const displayValue = (frequency as { displayValue?: unknown }).displayValue; const rawValue = (frequency as { value?: unknown }).value;
const parsedDisplayValue = parsePositiveFrequencyNumber(displayValue); const parsedValue = parsePositiveFrequencyNumber(rawValue);
if (parsedDisplayValue !== null) { if (parsedValue !== null) {
return parsedDisplayValue; return parsedValue;
} }
const rawValue = (frequency as { value?: unknown }).value; const displayValue = (frequency as { displayValue?: unknown }).displayValue;
return parsePositiveFrequencyNumber(rawValue); return parsePositiveFrequencyNumber(displayValue);
} }
function asFrequencyDictionaryEntry(entry: unknown): FrequencyDictionaryEntry | null { function asFrequencyDictionaryEntry(entry: unknown): FrequencyDictionaryEntry | null {

View File

@@ -74,8 +74,8 @@ test('seam: enqueueWrite drops oldest entries once capacity is exceeded', () =>
const result = enqueueWrite(queue, incoming, 2); const result = enqueueWrite(queue, incoming, 2);
assert.equal(result.dropped, 1); assert.equal(result.dropped, 1);
assert.equal(queue.length, 2); assert.equal(queue.length, 2);
assert.equal((queue[0] as Extract<QueuedWrite, { kind: 'event' }>).eventType, 2); assert.equal(queue[0]!.eventType, 2);
assert.equal((queue[1] as Extract<QueuedWrite, { kind: 'event' }>).eventType, 3); assert.equal(queue[1]!.eventType, 3);
}); });
test('seam: toMonthKey uses UTC calendar month', () => { test('seam: toMonthKey uses UTC calendar month', () => {
@@ -286,8 +286,8 @@ testIfSqlite('monthly rollups are grouped by calendar month', async () => {
canonical_title, canonical_title,
source_type, source_type,
duration_ms, duration_ms,
CREATED_DATE, created_at_ms,
LAST_UPDATE_DATE updated_at_ms
) VALUES ( ) VALUES (
1, 1,
'local:/tmp/video.mkv', 'local:/tmp/video.mkv',
@@ -306,8 +306,8 @@ testIfSqlite('monthly rollups are grouped by calendar month', async () => {
video_id, video_id,
started_at_ms, started_at_ms,
status, status,
CREATED_DATE, created_at_ms,
LAST_UPDATE_DATE, updated_at_ms,
ended_at_ms ended_at_ms
) VALUES ( ) VALUES (
1, 1,
@@ -363,8 +363,8 @@ testIfSqlite('monthly rollups are grouped by calendar month', async () => {
video_id, video_id,
started_at_ms, started_at_ms,
status, status,
CREATED_DATE, created_at_ms,
LAST_UPDATE_DATE, updated_at_ms,
ended_at_ms ended_at_ms
) VALUES ( ) VALUES (
2, 2,
@@ -479,8 +479,8 @@ testIfSqlite('flushSingle reuses cached prepared statements', async () => {
canonical_title, canonical_title,
source_type, source_type,
duration_ms, duration_ms,
CREATED_DATE, created_at_ms,
LAST_UPDATE_DATE updated_at_ms
) VALUES ( ) VALUES (
1, 1,
'local:/tmp/prepared.mkv', 'local:/tmp/prepared.mkv',
@@ -499,8 +499,8 @@ testIfSqlite('flushSingle reuses cached prepared statements', async () => {
video_id, video_id,
started_at_ms, started_at_ms,
status, status,
CREATED_DATE, created_at_ms,
LAST_UPDATE_DATE, updated_at_ms,
ended_at_ms ended_at_ms
) VALUES ( ) VALUES (
1, 1,

View File

@@ -25,7 +25,6 @@ import {
import { import {
buildVideoKey, buildVideoKey,
calculateTextMetrics, calculateTextMetrics,
extractLineVocabulary,
deriveCanonicalTitle, deriveCanonicalTitle,
isRemoteSource, isRemoteSource,
normalizeMediaPath, normalizeMediaPath,
@@ -269,41 +268,18 @@ export class ImmersionTrackerService {
if (!this.sessionState || !text.trim()) return; if (!this.sessionState || !text.trim()) return;
const cleaned = normalizeText(text); const cleaned = normalizeText(text);
if (!cleaned) return; if (!cleaned) return;
const nowMs = Date.now();
const nowSec = nowMs / 1000;
const metrics = calculateTextMetrics(cleaned); const metrics = calculateTextMetrics(cleaned);
const extractedVocabulary = extractLineVocabulary(cleaned);
this.sessionState.currentLineIndex += 1; this.sessionState.currentLineIndex += 1;
this.sessionState.linesSeen += 1; this.sessionState.linesSeen += 1;
this.sessionState.wordsSeen += metrics.words; this.sessionState.wordsSeen += metrics.words;
this.sessionState.tokensSeen += metrics.tokens; this.sessionState.tokensSeen += metrics.tokens;
this.sessionState.pendingTelemetry = true; this.sessionState.pendingTelemetry = true;
for (const { headword, word, reading } of extractedVocabulary.words) {
this.recordWrite({
kind: 'word',
headword,
word,
reading,
firstSeen: nowSec,
lastSeen: nowSec,
});
}
for (const kanji of extractedVocabulary.kanji) {
this.recordWrite({
kind: 'kanji',
kanji,
firstSeen: nowSec,
lastSeen: nowSec,
});
}
this.recordWrite({ this.recordWrite({
kind: 'event', kind: 'event',
sessionId: this.sessionState.sessionId, sessionId: this.sessionState.sessionId,
sampleMs: nowMs, sampleMs: Date.now(),
lineIndex: this.sessionState.currentLineIndex, lineIndex: this.sessionState.currentLineIndex,
segmentStartMs: secToMs(startSec), segmentStartMs: secToMs(startSec),
segmentEndMs: secToMs(endSec), segmentEndMs: secToMs(endSec),
@@ -586,15 +562,13 @@ export class ImmersionTrackerService {
this.flushTelemetry(true); this.flushTelemetry(true);
this.flushNow(); this.flushNow();
const nowMs = Date.now(); const nowMs = Date.now();
const retentionResult = pruneRetention(this.db, nowMs, { pruneRetention(this.db, nowMs, {
eventsRetentionMs: this.eventsRetentionMs, eventsRetentionMs: this.eventsRetentionMs,
telemetryRetentionMs: this.telemetryRetentionMs, telemetryRetentionMs: this.telemetryRetentionMs,
dailyRollupRetentionMs: this.dailyRollupRetentionMs, dailyRollupRetentionMs: this.dailyRollupRetentionMs,
monthlyRollupRetentionMs: this.monthlyRollupRetentionMs, monthlyRollupRetentionMs: this.monthlyRollupRetentionMs,
}); });
const shouldRebuildRollups = this.runRollupMaintenance();
retentionResult.deletedTelemetryRows > 0 || retentionResult.deletedEndedSessions > 0;
this.runRollupMaintenance(shouldRebuildRollups);
if (nowMs - this.lastVacuumMs >= this.vacuumIntervalMs && !this.writeLock.locked) { if (nowMs - this.lastVacuumMs >= this.vacuumIntervalMs && !this.writeLock.locked) {
this.db.exec('VACUUM'); this.db.exec('VACUUM');
@@ -608,8 +582,8 @@ export class ImmersionTrackerService {
} }
} }
private runRollupMaintenance(forceRebuild = false): void { private runRollupMaintenance(): void {
runRollupMaintenance(this.db, forceRebuild); runRollupMaintenance(this.db);
} }
private startSession(videoId: number, startedAtMs?: number): void { private startSession(videoId: number, startedAtMs?: number): void {

View File

@@ -1,31 +1,5 @@
import type { DatabaseSync } from 'node:sqlite'; import type { DatabaseSync } from 'node:sqlite';
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
const DAILY_MS = 86_400_000;
const ZERO_ID = 0;
interface RollupStateRow {
state_value: number;
}
interface RollupGroupRow {
rollup_day: number;
rollup_month: number;
video_id: number;
}
interface RollupTelemetryResult {
maxSampleMs: number | null;
}
interface RetentionResult {
deletedSessionEvents: number;
deletedTelemetryRows: number;
deletedDailyRows: number;
deletedMonthlyRows: number;
deletedEndedSessions: number;
}
export function toMonthKey(timestampMs: number): number { export function toMonthKey(timestampMs: number): number {
const monthDate = new Date(timestampMs); const monthDate = new Date(timestampMs);
return monthDate.getUTCFullYear() * 100 + monthDate.getUTCMonth() + 1; return monthDate.getUTCFullYear() * 100 + monthDate.getUTCMonth() + 1;
@@ -40,68 +14,29 @@ export function pruneRetention(
dailyRollupRetentionMs: number; dailyRollupRetentionMs: number;
monthlyRollupRetentionMs: number; monthlyRollupRetentionMs: number;
}, },
): RetentionResult { ): void {
const eventCutoff = nowMs - policy.eventsRetentionMs; const eventCutoff = nowMs - policy.eventsRetentionMs;
const telemetryCutoff = nowMs - policy.telemetryRetentionMs; const telemetryCutoff = nowMs - policy.telemetryRetentionMs;
const dayCutoff = nowMs - policy.dailyRollupRetentionMs; const dailyCutoff = nowMs - policy.dailyRollupRetentionMs;
const monthCutoff = nowMs - policy.monthlyRollupRetentionMs; const monthlyCutoff = nowMs - policy.monthlyRollupRetentionMs;
const dayCutoff = Math.floor(dailyCutoff / 86_400_000);
const monthCutoff = toMonthKey(monthlyCutoff);
const deletedSessionEvents = (db db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff);
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`) db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff);
.run(eventCutoff) as { changes: number }).changes; db.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`).run(dayCutoff);
const deletedTelemetryRows = (db db.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`).run(monthCutoff);
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`) db.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`).run(
.run(telemetryCutoff) as { changes: number }).changes; telemetryCutoff,
const deletedDailyRows = (db );
.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`)
.run(Math.floor(dayCutoff / DAILY_MS)) as { changes: number }).changes;
const deletedMonthlyRows = (db
.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`)
.run(toMonthKey(monthCutoff)) as { changes: number }).changes;
const deletedEndedSessions = (db
.prepare(
`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`,
)
.run(telemetryCutoff) as { changes: number }).changes;
return {
deletedSessionEvents,
deletedTelemetryRows,
deletedDailyRows,
deletedMonthlyRows,
deletedEndedSessions,
};
} }
function getLastRollupSampleMs(db: DatabaseSync): number { export function runRollupMaintenance(db: DatabaseSync): void {
const row = db db.exec(`
.prepare(`SELECT state_value FROM imm_rollup_state WHERE state_key = ? LIMIT 1`) INSERT OR REPLACE INTO imm_daily_rollups (
.get(ROLLUP_STATE_KEY) as unknown as RollupStateRow | null;
return row ? Number(row.state_value) : ZERO_ID;
}
function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number): void {
db.prepare(
`INSERT INTO imm_rollup_state (state_key, state_value)
VALUES (?, ?)
ON CONFLICT(state_key) DO UPDATE SET state_value = excluded.state_value`,
).run(ROLLUP_STATE_KEY, sampleMs);
}
function upsertDailyRollupsForGroups(
db: DatabaseSync,
groups: Array<{ rollupDay: number; videoId: number }>,
rollupNowMs: number,
): void {
if (groups.length === 0) {
return;
}
const upsertStmt = db.prepare(`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_words_seen, total_tokens_seen, total_cards, cards_per_hour, total_words_seen, total_tokens_seen, total_cards, cards_per_hour,
words_per_min, lookup_hit_rate, CREATED_DATE, LAST_UPDATE_DATE words_per_min, lookup_hit_rate
) )
SELECT SELECT
CAST(s.started_at_ms / 86400000 AS INTEGER) AS rollup_day, CAST(s.started_at_ms / 86400000 AS INTEGER) AS rollup_day,
@@ -126,46 +61,17 @@ function upsertDailyRollupsForGroups(
WHEN COALESCE(SUM(t.lookup_count), 0) > 0 WHEN COALESCE(SUM(t.lookup_count), 0) > 0
THEN CAST(COALESCE(SUM(t.lookup_hits), 0) AS REAL) / CAST(SUM(t.lookup_count) AS REAL) THEN CAST(COALESCE(SUM(t.lookup_hits), 0) AS REAL) / CAST(SUM(t.lookup_count) AS REAL)
ELSE NULL ELSE NULL
END AS lookup_hit_rate, END AS lookup_hit_rate
? AS CREATED_DATE,
? AS LAST_UPDATE_DATE
FROM imm_sessions s FROM imm_sessions s
JOIN imm_session_telemetry t JOIN imm_session_telemetry t
ON t.session_id = s.session_id ON t.session_id = s.session_id
WHERE CAST(s.started_at_ms / 86400000 AS INTEGER) = ? AND s.video_id = ?
GROUP BY rollup_day, s.video_id GROUP BY rollup_day, s.video_id
ON CONFLICT (rollup_day, video_id) DO UPDATE SET
total_sessions = excluded.total_sessions,
total_active_min = excluded.total_active_min,
total_lines_seen = excluded.total_lines_seen,
total_words_seen = excluded.total_words_seen,
total_tokens_seen = excluded.total_tokens_seen,
total_cards = excluded.total_cards,
cards_per_hour = excluded.cards_per_hour,
words_per_min = excluded.words_per_min,
lookup_hit_rate = excluded.lookup_hit_rate,
CREATED_DATE = COALESCE(imm_daily_rollups.CREATED_DATE, excluded.CREATED_DATE),
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
`); `);
for (const { rollupDay, videoId } of groups) { db.exec(`
upsertStmt.run(rollupNowMs, rollupNowMs, rollupDay, videoId); INSERT OR REPLACE INTO imm_monthly_rollups (
}
}
function upsertMonthlyRollupsForGroups(
db: DatabaseSync,
groups: Array<{ rollupMonth: number; videoId: number }>,
rollupNowMs: number,
): void {
if (groups.length === 0) {
return;
}
const upsertStmt = db.prepare(`
INSERT INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen, rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_words_seen, total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE total_words_seen, total_tokens_seen, total_cards
) )
SELECT SELECT
CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch') AS INTEGER) AS rollup_month, CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch') AS INTEGER) AS rollup_month,
@@ -175,112 +81,10 @@ function upsertMonthlyRollupsForGroups(
COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen, COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen,
COALESCE(SUM(t.words_seen), 0) AS total_words_seen, COALESCE(SUM(t.words_seen), 0) AS total_words_seen,
COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen, COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen,
COALESCE(SUM(t.cards_mined), 0) AS total_cards, COALESCE(SUM(t.cards_mined), 0) AS total_cards
? AS CREATED_DATE,
? AS LAST_UPDATE_DATE
FROM imm_sessions s FROM imm_sessions s
JOIN imm_session_telemetry t JOIN imm_session_telemetry t
ON t.session_id = s.session_id ON t.session_id = s.session_id
WHERE CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch') AS INTEGER) = ? AND s.video_id = ?
GROUP BY rollup_month, s.video_id GROUP BY rollup_month, s.video_id
ON CONFLICT (rollup_month, video_id) DO UPDATE SET
total_sessions = excluded.total_sessions,
total_active_min = excluded.total_active_min,
total_lines_seen = excluded.total_lines_seen,
total_words_seen = excluded.total_words_seen,
total_tokens_seen = excluded.total_tokens_seen,
total_cards = excluded.total_cards,
CREATED_DATE = COALESCE(imm_monthly_rollups.CREATED_DATE, excluded.CREATED_DATE),
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
`); `);
for (const { rollupMonth, videoId } of groups) {
upsertStmt.run(rollupNowMs, rollupNowMs, rollupMonth, videoId);
}
}
function getAffectedRollupGroups(
db: DatabaseSync,
lastRollupSampleMs: number,
): Array<{ rollupDay: number; rollupMonth: number; videoId: number }> {
return (
db
.prepare(
`
SELECT DISTINCT
CAST(s.started_at_ms / 86400000 AS INTEGER) AS rollup_day,
CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch') AS INTEGER) AS rollup_month,
s.video_id AS video_id
FROM imm_session_telemetry t
JOIN imm_sessions s
ON s.session_id = t.session_id
WHERE t.sample_ms > ?
`,
)
.all(lastRollupSampleMs) as unknown as RollupGroupRow[]
).map((row) => ({
rollupDay: row.rollup_day,
rollupMonth: row.rollup_month,
videoId: row.video_id,
}));
}
function dedupeGroups<T extends { rollupDay?: number; rollupMonth?: number; videoId: number }>(
groups: Array<T>,
): Array<T> {
const seen = new Set<string>();
const result: Array<T> = [];
for (const group of groups) {
const key = `${group.rollupDay ?? group.rollupMonth}-${group.videoId}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(group);
}
return result;
}
export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): void {
const rollupNowMs = Date.now();
const lastRollupSampleMs = forceRebuild ? ZERO_ID : getLastRollupSampleMs(db);
const maxSampleRow = db
.prepare('SELECT MAX(sample_ms) AS maxSampleMs FROM imm_session_telemetry')
.get() as unknown as RollupTelemetryResult | null;
if (!maxSampleRow?.maxSampleMs) {
if (forceRebuild) {
setLastRollupSampleMs(db, ZERO_ID);
}
return;
}
const affectedGroups = getAffectedRollupGroups(db, lastRollupSampleMs);
if (!forceRebuild && affectedGroups.length === 0) {
return;
}
const dailyGroups = dedupeGroups(
affectedGroups.map((group) => ({
rollupDay: group.rollupDay,
videoId: group.videoId,
})),
);
const monthlyGroups = dedupeGroups(
affectedGroups.map((group) => ({
rollupMonth: group.rollupMonth,
videoId: group.videoId,
})),
);
db.exec('BEGIN IMMEDIATE');
try {
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
setLastRollupSampleMs(db, Number(maxSampleRow.maxSampleMs));
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
} }

View File

@@ -1,22 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { extractLineVocabulary, isKanji } from './reducer';
test('isKanji follows canonical CJK ranges', () => {
assert.ok(isKanji('日'));
assert.ok(isKanji('𠀀'));
assert.ok(!isKanji('あ'));
assert.ok(!isKanji('a'));
});
test('extractLineVocabulary returns words and unique kanji', () => {
const result = extractLineVocabulary('hello 你好 猫');
assert.equal(result.words.length, 3);
assert.deepEqual(
new Set(result.words.map((entry) => `${entry.headword}/${entry.word}`)),
new Set(['hello/hello', '你好/你好', '猫/猫']),
);
assert.equal(result.words.every((entry) => entry.reading === ''), true);
assert.deepEqual(new Set(result.kanji), new Set(['你', '好', '猫']));
});

View File

@@ -76,53 +76,6 @@ export function normalizeText(value: string | null | undefined): string {
return value.trim().replace(/\s+/g, ' '); return value.trim().replace(/\s+/g, ' ');
} }
export interface ExtractedLineVocabulary {
words: Array<{ headword: string; word: string; reading: string }>;
kanji: string[];
}
export function isKanji(char: string): boolean {
if (!char) return false;
const code = char.codePointAt(0);
if (code === undefined) return false;
return (
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0x3400 && code <= 0x4dbf) ||
(code >= 0x20000 && code <= 0x2a6df)
);
}
export function extractLineVocabulary(value: string): ExtractedLineVocabulary {
const cleaned = normalizeText(value);
if (!cleaned) return { words: [], kanji: [] };
const wordSet = new Set<string>();
const tokenPattern = /[A-Za-z0-9']+|[\u3040-\u30ff]+|[\u3400-\u4dbf\u4e00-\u9fff\u20000-\u2a6df]+/g;
const rawWords = cleaned.match(tokenPattern) ?? [];
for (const rawWord of rawWords) {
const normalizedWord = normalizeText(rawWord.toLowerCase());
if (!normalizedWord) continue;
wordSet.add(normalizedWord);
}
const kanji = new Set<string>();
for (const char of cleaned) {
if (isKanji(char)) {
kanji.add(char);
}
}
const words = Array.from(wordSet).map((word) => ({
headword: word,
word,
reading: '',
}));
return {
words,
kanji: Array.from(kanji),
};
}
export function buildVideoKey(mediaPath: string, sourceType: number): string { export function buildVideoKey(mediaPath: string, sourceType: number): string {
if (sourceType === SOURCE_TYPE_REMOTE) { if (sourceType === SOURCE_TYPE_REMOTE) {
return `remote:${mediaPath}`; return `remote:${mediaPath}`;

View File

@@ -10,24 +10,15 @@ export function startSessionRecord(
startedAtMs = Date.now(), startedAtMs = Date.now(),
): { sessionId: number; state: SessionState } { ): { sessionId: number; state: SessionState } {
const sessionUuid = crypto.randomUUID(); const sessionUuid = crypto.randomUUID();
const nowMs = Date.now();
const result = db const result = db
.prepare( .prepare(
` `
INSERT INTO imm_sessions ( INSERT INTO imm_sessions (
session_uuid, video_id, started_at_ms, status, session_uuid, video_id, started_at_ms, status, created_at_ms, updated_at_ms
CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?)
`, `,
) )
.run( .run(sessionUuid, videoId, startedAtMs, SESSION_STATUS_ACTIVE, startedAtMs, startedAtMs);
sessionUuid,
videoId,
startedAtMs,
SESSION_STATUS_ACTIVE,
startedAtMs,
nowMs,
);
const sessionId = Number(result.lastInsertRowid); const sessionId = Number(result.lastInsertRowid);
return { return {
sessionId, sessionId,
@@ -41,13 +32,6 @@ export function finalizeSessionRecord(
endedAtMs = Date.now(), endedAtMs = Date.now(),
): void { ): void {
db.prepare( db.prepare(
` 'UPDATE imm_sessions SET ended_at_ms = ?, status = ?, updated_at_ms = ? WHERE session_id = ?',
UPDATE imm_sessions
SET
ended_at_ms = ?,
status = ?,
LAST_UPDATE_DATE = ?
WHERE session_id = ?
`,
).run(endedAtMs, SESSION_STATUS_ENDED, Date.now(), sessionState.sessionId); ).run(endedAtMs, SESSION_STATUS_ENDED, Date.now(), sessionState.sessionId);
} }

View File

@@ -54,19 +54,6 @@ testIfSqlite('ensureSchema creates immersion core tables', () => {
assert.ok(tableNames.has('imm_session_events')); assert.ok(tableNames.has('imm_session_events'));
assert.ok(tableNames.has('imm_daily_rollups')); assert.ok(tableNames.has('imm_daily_rollups'));
assert.ok(tableNames.has('imm_monthly_rollups')); assert.ok(tableNames.has('imm_monthly_rollups'));
assert.ok(tableNames.has('imm_words'));
assert.ok(tableNames.has('imm_kanji'));
assert.ok(tableNames.has('imm_rollup_state'));
const rollupStateRow = db
.prepare(
'SELECT state_value FROM imm_rollup_state WHERE state_key = ?',
)
.get('last_rollup_sample_ms') as {
state_value: number;
} | null;
assert.ok(rollupStateRow);
assert.equal(rollupStateRow?.state_value, 0);
} finally { } finally {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
@@ -173,47 +160,3 @@ testIfSqlite('executeQueuedWrite inserts event and telemetry rows', () => {
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
} }
}); });
testIfSqlite('executeQueuedWrite inserts and upserts word and kanji rows', () => {
const dbPath = makeDbPath();
const db = new DatabaseSync!(dbPath);
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
stmts.wordUpsertStmt.run('猫', '猫', '', 10.0, 10.0);
stmts.wordUpsertStmt.run('猫', '猫', '', 5.0, 15.0);
stmts.kanjiUpsertStmt.run('日', 9.0, 9.0);
stmts.kanjiUpsertStmt.run('日', 8.0, 11.0);
const wordRow = db
.prepare('SELECT headword, frequency, first_seen, last_seen FROM imm_words WHERE headword = ?')
.get('猫') as {
headword: string;
frequency: number;
first_seen: number;
last_seen: number;
} | null;
const kanjiRow = db
.prepare('SELECT kanji, frequency, first_seen, last_seen FROM imm_kanji WHERE kanji = ?')
.get('日') as {
kanji: string;
frequency: number;
first_seen: number;
last_seen: number;
} | null;
assert.ok(wordRow);
assert.ok(kanjiRow);
assert.equal(wordRow?.frequency, 2);
assert.equal(kanjiRow?.frequency, 2);
assert.equal(wordRow?.first_seen, 5);
assert.equal(wordRow?.last_seen, 15);
assert.equal(kanjiRow?.first_seen, 8);
assert.equal(kanjiRow?.last_seen, 11);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});

View File

@@ -5,27 +5,6 @@ import type { QueuedWrite, VideoMetadata } from './types';
export interface TrackerPreparedStatements { export interface TrackerPreparedStatements {
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>; telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
eventInsertStmt: ReturnType<DatabaseSync['prepare']>; eventInsertStmt: ReturnType<DatabaseSync['prepare']>;
wordUpsertStmt: ReturnType<DatabaseSync['prepare']>;
kanjiUpsertStmt: ReturnType<DatabaseSync['prepare']>;
}
function hasColumn(db: DatabaseSync, tableName: string, columnName: string): boolean {
return db
.prepare(`PRAGMA table_info(${tableName})`)
.all()
.some((row) => (row as { name: string }).name === columnName);
}
function addColumnIfMissing(db: DatabaseSync, tableName: string, columnName: string): void {
if (!hasColumn(db, tableName, columnName)) {
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} INTEGER`);
}
}
function dropColumnIfExists(db: DatabaseSync, tableName: string, columnName: string): void {
if (hasColumn(db, tableName, columnName)) {
db.exec(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
}
} }
export function applyPragmas(db: DatabaseSync): void { export function applyPragmas(db: DatabaseSync): void {
@@ -42,17 +21,6 @@ export function ensureSchema(db: DatabaseSync): void {
applied_at_ms INTEGER NOT NULL applied_at_ms INTEGER NOT NULL
); );
`); `);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_rollup_state(
state_key TEXT PRIMARY KEY,
state_value INTEGER NOT NULL
);
`);
db.exec(`
INSERT INTO imm_rollup_state(state_key, state_value)
VALUES ('last_rollup_sample_ms', 0)
ON CONFLICT(state_key) DO NOTHING
`);
const currentVersion = db const currentVersion = db
.prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1') .prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1')
@@ -76,8 +44,7 @@ export function ensureSchema(db: DatabaseSync): void {
bitrate_kbps INTEGER, audio_codec_id INTEGER, bitrate_kbps INTEGER, audio_codec_id INTEGER,
hash_sha256 TEXT, screenshot_path TEXT, hash_sha256 TEXT, screenshot_path TEXT,
metadata_json TEXT, metadata_json TEXT,
CREATED_DATE INTEGER, created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL
LAST_UPDATE_DATE INTEGER
); );
`); `);
db.exec(` db.exec(`
@@ -89,8 +56,7 @@ export function ensureSchema(db: DatabaseSync): void {
status INTEGER NOT NULL, status INTEGER NOT NULL,
locale_id INTEGER, target_lang_id INTEGER, locale_id INTEGER, target_lang_id INTEGER,
difficulty_tier INTEGER, subtitle_mode INTEGER, difficulty_tier INTEGER, subtitle_mode INTEGER,
CREATED_DATE INTEGER, created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL,
LAST_UPDATE_DATE INTEGER,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) FOREIGN KEY(video_id) REFERENCES imm_videos(video_id)
); );
`); `);
@@ -112,8 +78,6 @@ export function ensureSchema(db: DatabaseSync): void {
seek_forward_count INTEGER NOT NULL DEFAULT 0, seek_forward_count INTEGER NOT NULL DEFAULT 0,
seek_backward_count INTEGER NOT NULL DEFAULT 0, seek_backward_count INTEGER NOT NULL DEFAULT 0,
media_buffer_events INTEGER NOT NULL DEFAULT 0, media_buffer_events INTEGER NOT NULL DEFAULT 0,
CREATED_DATE INTEGER,
LAST_UPDATE_DATE INTEGER,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
); );
`); `);
@@ -129,8 +93,6 @@ export function ensureSchema(db: DatabaseSync): void {
words_delta INTEGER NOT NULL DEFAULT 0, words_delta INTEGER NOT NULL DEFAULT 0,
cards_delta INTEGER NOT NULL DEFAULT 0, cards_delta INTEGER NOT NULL DEFAULT 0,
payload_json TEXT, payload_json TEXT,
CREATED_DATE INTEGER,
LAST_UPDATE_DATE INTEGER,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
); );
`); `);
@@ -147,8 +109,6 @@ export function ensureSchema(db: DatabaseSync): void {
cards_per_hour REAL, cards_per_hour REAL,
words_per_min REAL, words_per_min REAL,
lookup_hit_rate REAL, lookup_hit_rate REAL,
CREATED_DATE INTEGER,
LAST_UPDATE_DATE INTEGER,
PRIMARY KEY (rollup_day, video_id) PRIMARY KEY (rollup_day, video_id)
); );
`); `);
@@ -162,33 +122,9 @@ export function ensureSchema(db: DatabaseSync): void {
total_words_seen INTEGER NOT NULL DEFAULT 0, total_words_seen INTEGER NOT NULL DEFAULT 0,
total_tokens_seen INTEGER NOT NULL DEFAULT 0, total_tokens_seen INTEGER NOT NULL DEFAULT 0,
total_cards INTEGER NOT NULL DEFAULT 0, total_cards INTEGER NOT NULL DEFAULT 0,
CREATED_DATE INTEGER,
LAST_UPDATE_DATE INTEGER,
PRIMARY KEY (rollup_month, video_id) PRIMARY KEY (rollup_month, video_id)
); );
`); `);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_words(
id INTEGER PRIMARY KEY AUTOINCREMENT,
headword TEXT,
word TEXT,
reading TEXT,
first_seen REAL,
last_seen REAL,
frequency INTEGER,
UNIQUE(headword, word, reading)
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_kanji(
id INTEGER PRIMARY KEY AUTOINCREMENT,
kanji TEXT,
first_seen REAL,
last_seen REAL,
frequency INTEGER,
UNIQUE(kanji)
);
`);
db.exec(` db.exec(`
CREATE INDEX IF NOT EXISTS idx_sessions_video_started CREATE INDEX IF NOT EXISTS idx_sessions_video_started
@@ -218,86 +154,6 @@ export function ensureSchema(db: DatabaseSync): void {
CREATE INDEX IF NOT EXISTS idx_rollups_month_video CREATE INDEX IF NOT EXISTS idx_rollups_month_video
ON imm_monthly_rollups(rollup_month, video_id) ON imm_monthly_rollups(rollup_month, video_id)
`); `);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_words_headword_word_reading
ON imm_words(headword, word, reading)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_kanji_kanji
ON imm_kanji(kanji)
`);
if (currentVersion?.schema_version === 1) {
addColumnIfMissing(db, 'imm_videos', 'CREATED_DATE');
addColumnIfMissing(db, 'imm_videos', 'LAST_UPDATE_DATE');
addColumnIfMissing(db, 'imm_sessions', 'CREATED_DATE');
addColumnIfMissing(db, 'imm_sessions', 'LAST_UPDATE_DATE');
addColumnIfMissing(db, 'imm_session_telemetry', 'CREATED_DATE');
addColumnIfMissing(db, 'imm_session_telemetry', 'LAST_UPDATE_DATE');
addColumnIfMissing(db, 'imm_session_events', 'CREATED_DATE');
addColumnIfMissing(db, 'imm_session_events', 'LAST_UPDATE_DATE');
addColumnIfMissing(db, 'imm_daily_rollups', 'CREATED_DATE');
addColumnIfMissing(db, 'imm_daily_rollups', 'LAST_UPDATE_DATE');
addColumnIfMissing(db, 'imm_monthly_rollups', 'CREATED_DATE');
addColumnIfMissing(db, 'imm_monthly_rollups', 'LAST_UPDATE_DATE');
const nowMs = Date.now();
db.prepare(
`
UPDATE imm_videos
SET
CREATED_DATE = COALESCE(CREATED_DATE, created_at_ms),
LAST_UPDATE_DATE = COALESCE(LAST_UPDATE_DATE, created_at_ms)
`,
).run();
db.prepare(
`
UPDATE imm_sessions
SET
CREATED_DATE = COALESCE(CREATED_DATE, started_at_ms),
LAST_UPDATE_DATE = COALESCE(LAST_UPDATE_DATE, created_at_ms)
`,
).run();
db.prepare(
`
UPDATE imm_session_telemetry
SET
CREATED_DATE = COALESCE(CREATED_DATE, sample_ms),
LAST_UPDATE_DATE = COALESCE(LAST_UPDATE_DATE, sample_ms)
`,
).run();
db.prepare(
`
UPDATE imm_session_events
SET
CREATED_DATE = COALESCE(CREATED_DATE, ts_ms),
LAST_UPDATE_DATE = COALESCE(LAST_UPDATE_DATE, ts_ms)
`,
).run();
db.prepare(
`
UPDATE imm_daily_rollups
SET
CREATED_DATE = COALESCE(CREATED_DATE, ?),
LAST_UPDATE_DATE = COALESCE(LAST_UPDATE_DATE, ?)
`,
).run(nowMs, nowMs);
db.prepare(
`
UPDATE imm_monthly_rollups
SET
CREATED_DATE = COALESCE(CREATED_DATE, ?),
LAST_UPDATE_DATE = COALESCE(LAST_UPDATE_DATE, ?)
`,
).run(nowMs, nowMs);
}
if (currentVersion?.schema_version === 1 || currentVersion?.schema_version === 2) {
dropColumnIfExists(db, 'imm_videos', 'created_at_ms');
dropColumnIfExists(db, 'imm_videos', 'updated_at_ms');
dropColumnIfExists(db, 'imm_sessions', 'created_at_ms');
dropColumnIfExists(db, 'imm_sessions', 'updated_at_ms');
}
db.exec(` db.exec(`
INSERT INTO imm_schema_version(schema_version, applied_at_ms) INSERT INTO imm_schema_version(schema_version, applied_at_ms)
@@ -313,41 +169,19 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
session_id, sample_ms, total_watched_ms, active_watched_ms, session_id, sample_ms, total_watched_ms, active_watched_ms,
lines_seen, words_seen, tokens_seen, cards_mined, lookup_count, lines_seen, words_seen, tokens_seen, cards_mined, lookup_count,
lookup_hits, pause_count, pause_ms, seek_forward_count, lookup_hits, pause_count, pause_ms, seek_forward_count,
seek_backward_count, media_buffer_events, CREATED_DATE, LAST_UPDATE_DATE seek_backward_count, media_buffer_events
) VALUES ( ) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
) )
`), `),
eventInsertStmt: db.prepare(` eventInsertStmt: db.prepare(`
INSERT INTO imm_session_events ( INSERT INTO imm_session_events (
session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms, session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms,
words_delta, cards_delta, payload_json, CREATED_DATE, LAST_UPDATE_DATE words_delta, cards_delta, payload_json
) VALUES ( ) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ? ?, ?, ?, ?, ?, ?, ?, ?, ?
) )
`), `),
wordUpsertStmt: db.prepare(`
INSERT INTO imm_words (
headword, word, reading, first_seen, last_seen, frequency
) VALUES (
?, ?, ?, ?, ?, 1
)
ON CONFLICT(headword, word, reading) DO UPDATE SET
frequency = COALESCE(frequency, 0) + 1,
first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen),
last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen)
`),
kanjiUpsertStmt: db.prepare(`
INSERT INTO imm_kanji (
kanji, first_seen, last_seen, frequency
) VALUES (
?, ?, ?, 1
)
ON CONFLICT(kanji) DO UPDATE SET
frequency = COALESCE(frequency, 0) + 1,
first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen),
last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen)
`),
}; };
} }
@@ -369,25 +203,9 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
write.seekForwardCount!, write.seekForwardCount!,
write.seekBackwardCount!, write.seekBackwardCount!,
write.mediaBufferEvents!, write.mediaBufferEvents!,
Date.now(),
Date.now(),
); );
return; return;
} }
if (write.kind === 'word') {
stmts.wordUpsertStmt.run(
write.headword,
write.word,
write.reading,
write.firstSeen,
write.lastSeen,
);
return;
}
if (write.kind === 'kanji') {
stmts.kanjiUpsertStmt.run(write.kanji, write.firstSeen, write.lastSeen);
return;
}
stmts.eventInsertStmt.run( stmts.eventInsertStmt.run(
write.sessionId, write.sessionId,
@@ -399,8 +217,6 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
write.wordsDelta ?? 0, write.wordsDelta ?? 0,
write.cardsDelta ?? 0, write.cardsDelta ?? 0,
write.payloadJson ?? null, write.payloadJson ?? null,
Date.now(),
Date.now(),
); );
} }
@@ -419,18 +235,8 @@ export function getOrCreateVideoRecord(
.get(videoKey) as { video_id: number } | null; .get(videoKey) as { video_id: number } | null;
if (existing?.video_id) { if (existing?.video_id) {
db.prepare( db.prepare(
` 'UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?',
UPDATE imm_videos ).run(details.canonicalTitle || 'unknown', Date.now(), existing.video_id);
SET
canonical_title = ?,
LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
).run(
details.canonicalTitle || 'unknown',
Date.now(),
existing.video_id,
);
return existing.video_id; return existing.video_id;
} }
@@ -440,7 +246,7 @@ export function getOrCreateVideoRecord(
video_key, canonical_title, source_type, source_path, source_url, video_key, canonical_title, source_type, source_path, source_url,
duration_ms, file_size_bytes, codec_id, container_id, width_px, height_px, duration_ms, file_size_bytes, codec_id, container_id, width_px, height_px,
fps_x100, bitrate_kbps, audio_codec_id, hash_sha256, screenshot_path, fps_x100, bitrate_kbps, audio_codec_id, hash_sha256, screenshot_path,
metadata_json, CREATED_DATE, LAST_UPDATE_DATE metadata_json, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
const result = insert.run( const result = insert.run(
@@ -488,7 +294,7 @@ export function updateVideoMetadataRecord(
hash_sha256 = ?, hash_sha256 = ?,
screenshot_path = ?, screenshot_path = ?,
metadata_json = ?, metadata_json = ?,
LAST_UPDATE_DATE = ? updated_at_ms = ?
WHERE video_id = ? WHERE video_id = ?
`, `,
).run( ).run(
@@ -514,13 +320,9 @@ export function updateVideoTitleRecord(
videoId: number, videoId: number,
canonicalTitle: string, canonicalTitle: string,
): void { ): void {
db.prepare( db.prepare('UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?').run(
` canonicalTitle,
UPDATE imm_videos Date.now(),
SET videoId,
canonical_title = ?, );
LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
).run(canonicalTitle, Date.now(), videoId);
} }

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