Compare commits

..

39 Commits

Author SHA1 Message Date
2f07c3407a chore: bump version to 0.3.0 2026-03-05 20:21:03 -08:00
a5554ec530 docs: capture anki proxy notes and codebase health backlog 2026-03-05 20:14:58 -08:00
f9f2fe6e87 docs: update keyboard-driven yomitan workflow 2026-03-05 20:13:38 -08:00
8ca05859a9 fix: support repeated popup scroll keys 2026-03-05 19:20:55 -08:00
0cac446725 fix: preserve keyboard subtitle navigation state 2026-03-05 18:39:40 -08:00
23623ad1e1 docs(backlog): add keyboard-driven yomitan task record 2026-03-05 01:29:13 -08:00
b623c5e160 fix: improve yomitan keyboard navigation and payload handling 2026-03-05 01:28:54 -08:00
5436e0cd49 chore(docs): remove Plausible tracker integration 2026-03-04 23:04:11 -08:00
beeeee5ebd fix(core): recopy Yomitan extension when patched scripts drift 2026-03-04 23:04:11 -08:00
fdbf769760 feat(renderer): add keyboard-driven yomitan navigation and popup controls 2026-03-04 23:04:11 -08:00
0a36d1aa99 fix(anki): force Yomitan proxy server sync for card auto-enhancement 2026-03-04 23:04:11 -08:00
69ab87c25f feat(renderer): add optional yomitan popup auto-pause 2026-03-04 23:04:11 -08:00
9a30419a23 fix(tokenizer): tighten frequency highlighting exclusions 2026-03-04 23:04:11 -08:00
092c56f98f feat(launcher): migrate aniskip resolution to launcher script opts 2026-03-03 00:38:22 -08:00
10ef535f9a feat(subsync): add replace option and deterministic retimed naming 2026-03-03 00:26:31 -08:00
6c80bd5843 fix(docs): point plausible tracker to /api/event 2026-03-03 00:26:09 -08:00
f0bd0ba355 fix(release): publish via gh cli with clobber upload 2026-03-02 03:00:06 -08:00
be4db24861 make pretty 2026-03-02 02:45:51 -08:00
83d21c4b6d fix: narrow fallback frequency filter type predicate 2026-03-02 02:44:07 -08:00
e744fab067 fix: unblock autoplay on tokenization-ready and defer annotation loading 2026-03-02 02:43:09 -08:00
5167e3a494 docs: add plausible tracker config for docs site 2026-03-02 02:33:45 -08:00
aff4e91bbb fix(startup): async dictionary loading and unblock first tokenization
- move JLPT/frequency dictionary init off sync fs APIs and add cooperative yielding during entry processing

- decouple first tokenization from full warmup by gating only on Yomitan readiness while MeCab/dictionary warmups continue in parallel

- update mpv pause-until-ready OSD copy to tokenization-focused wording and refresh gate regression assertions
2026-03-02 01:48:17 -08:00
737101fe9e fix(tokenizer): lazy yomitan term-only frequency fallback 2026-03-02 01:45:37 -08:00
629fe97ef7 chore(tokenizer): align enrichment regression notes and test typing 2026-03-02 01:45:23 -08:00
fa97472bce perf(tokenizer): optimize mecab POS enrichment lookups 2026-03-02 01:39:44 -08:00
83f13df627 perf(tokenizer): skip known-word lookup in MeCab POS enrichment 2026-03-02 01:38:37 -08:00
cde231b1ff fix(tokenizer): avoid repeated yomitan anki sync checks on no-change 2026-03-02 01:36:22 -08:00
7161fc3513 fix: make tokenization warmup one-shot 2026-03-02 01:33:09 -08:00
9a91951656 perf(tokenizer): cut annotation latency with persistent mecab 2026-03-02 01:15:21 -08:00
11e9c721c6 feat(subtitles): add no-jump subtitle-delay shift commands 2026-03-02 01:12:26 -08:00
3c66ea6b30 fix(jellyfin): preserve discover resume position on remote play 2026-03-01 23:28:03 -08:00
79f37f3986 fix(subtitle): prioritize known and n+1 colors over frequency 2026-03-01 23:23:53 -08:00
f1b85b0751 fix(plugin): keep loading OSD visible during startup gate 2026-03-01 23:23:45 -08:00
1ab5d00de0 bump version 2026-03-01 20:12:59 -08:00
17a417e639 fix(subtitle): improve frequency highlight reliability 2026-03-01 20:12:42 -08:00
68e5a7fef3 fix: sanitize jellyfin misc info formatting 2026-03-01 20:05:19 -08:00
7023a3263f Jellyfin and Subsync Fixes (#13) 2026-03-01 16:13:16 -08:00
49434bf0cd fix release job 2026-03-01 02:50:51 -08:00
44c7761c7c Overlay 2.0 (#12) 2026-03-01 02:36:51 -08:00
330 changed files with 15465 additions and 1966 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.lua \ plugin/subminer \
plugin/subminer.conf \ plugin/subminer.conf \
assets/themes/subminer.rasi assets/themes/subminer.rasi
@@ -278,11 +278,13 @@ jobs:
echo "$CHANGES" >> $GITHUB_OUTPUT echo "$CHANGES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
- name: Create Release - name: Publish Release
uses: softprops/action-gh-release@v2 env:
with: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
name: ${{ steps.version.outputs.VERSION }} run: |
body: | set -euo pipefail
cat > release-body.md <<'EOF'
## Changes ## Changes
${{ steps.changelog.outputs.CHANGES }} ${{ steps.changelog.outputs.CHANGES }}
@@ -304,19 +306,42 @@ 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.lua` to `~/.config/mpv/scripts/` 3. Copy `plugin/subminer/` directory contents 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`
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi` - macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`. Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
files: | EOF
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
gh release edit "${{ steps.version.outputs.VERSION }}" \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release-body.md \
--prerelease false
else
gh release create "${{ steps.version.outputs.VERSION }}" \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release-body.md \
--prerelease false
fi
shopt -s nullglob
artifacts=(
release/*.AppImage release/*.AppImage
release/*.dmg release/*.dmg
release/*.zip release/*.zip
release/*.tar.gz release/*.tar.gz
release/SHA256SUMS.txt release/SHA256SUMS.txt
dist/launcher/subminer dist/launcher/subminer
draft: false )
prerelease: false
if [ "${#artifacts[@]}" -eq 0 ]; then
echo "No release artifacts found for upload."
exit 1
fi
for asset in "${artifacts[@]}"; do
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
done

View File

@@ -1,4 +1,3 @@
<!-- BACKLOG.MD MCP GUIDELINES START --> <!-- BACKLOG.MD MCP GUIDELINES START -->
<CRITICAL_INSTRUCTION> <CRITICAL_INSTRUCTION>
@@ -17,6 +16,7 @@ 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 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 docs-watch 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,6 +57,7 @@ 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" \
@@ -160,6 +161,9 @@ 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 (GIF preview)](./assets/minecard.gif)](./assets/minecard.mp4) [![SubMiner demo (Animated preview)](./assets/minecard.webp)](./assets/minecard.mp4)
</div> </div>
@@ -25,9 +25,11 @@
SubMiner is an Electron overlay that sits on top of mpv. It turns your video player into a full sentence-mining workstation: SubMiner is an Electron overlay that sits on top of mpv. It turns your video player into a full sentence-mining workstation:
- **Hover to look up** — Yomitan dictionary popups directly on subtitles - **Hover to look up** — Yomitan dictionary popups directly on subtitles
- **Keyboard-driven lookup mode** — Navigate token-by-token, keep lookup open across tokens, and control popup scrolling/audio/mining without leaving the overlay
- **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
@@ -65,18 +67,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 --start --yomitan subminer app --yomitan
``` ```
### 4. Mine ### 4. Mine
```bash ```bash
subminer app --start --background subminer app --start --background
subminer video.mkv # y-t toggles overlay visibility subminer video.mkv # default plugin config auto-starts visible overlay + resumes playback when ready
subminer --start video.mkv # optional explicit overlay start when plugin auto_start=no
``` ```
## Requirements ## Requirements

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 MiB

After

Width:  |  Height:  |  Size: 23 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

BIN
assets/minecard.webp Normal file

Binary file not shown.

After

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

@@ -0,0 +1,8 @@
---
id: m-0
title: 'Codebase Health Remediation'
---
## Description
Follow-up work from the March 6, 2026 codebase review: strengthen the runnable test gate, remove confirmed dead architecture, and continue decomposition of oversized runtime entrypoints.

View File

@@ -6,6 +6,7 @@ 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,20 +20,24 @@ 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 -->
@@ -40,5 +45,7 @@ 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

@@ -1,43 +0,0 @@
---
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,6 +6,7 @@ title: >-
status: Done status: Done
assignee: [] assignee: []
created_date: '2026-02-28 02:38' created_date: '2026-02-28 02:38'
updated_date: '2026-03-04 13:55'
labels: [] labels: []
dependencies: [] dependencies: []
references: references:
@@ -18,14 +19,17 @@ 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.
@@ -34,6 +38,7 @@ 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 -->
@@ -41,5 +46,13 @@ 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.
Follow-up fix (2026-03-04):
- Updated bundled Yomitan server-sync behavior to target `profileCurrent` instead of hardcoded `profiles[0]`.
- Added proxy-mode force override so bundled Yomitan always points at SubMiner proxy URL when `ankiConnect.proxy.enabled=true`; this ensures mined cards pass through proxy and trigger auto-enrichment.
- Added regression tests for blocked existing-server case and force-override injection path.
<!-- SECTION:FINAL_SUMMARY:END --> <!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -4,6 +4,7 @@ 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:
@@ -12,24 +13,30 @@ 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,9 +1,10 @@
--- ---
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: In Progress status: Done
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:
@@ -32,14 +33,17 @@ 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.
@@ -51,6 +55,7 @@ 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 -->
@@ -58,18 +63,22 @@ 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,9 +1,10 @@
--- ---
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: In Progress status: Done
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:
@@ -15,18 +16,24 @@ 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.
@@ -37,7 +44,9 @@ 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.
@@ -45,7 +54,23 @@ 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

@@ -0,0 +1,41 @@
---
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

@@ -0,0 +1,39 @@
---
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

@@ -0,0 +1,53 @@
---
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-03-04 12:07'
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.
Follow-up adjustments (2026-03-04):
- Hover pause now resumes immediately when leaving subtitle text (no Yomitan-popup hover retention).
- Added `subtitleStyle.autoPauseVideoOnYomitanPopup` (default `false`) to optionally keep playback paused while Yomitan popup is open, with auto-resume on close only when SubMiner initiated the popup pause.
- Yomitan popup control keybinds added while popup is open: `J/K` scroll, `M` mine, `P` audio play, `[` previous audio variant, `]` next audio variant (within selected source).
- Extension copy drift detection widened so popup runtime changes are reliably re-copied on launch (`popup.js`, `popup-main.js`, `display.js`, `display-audio.js`).
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,55 @@
---
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

@@ -0,0 +1,50 @@
---
id: TASK-79
title: 'Jimaku modal: auto-close after successful subtitle load'
status: Done
assignee: []
created_date: '2026-03-01 13:52'
updated_date: '2026-03-01 14:06'
labels: []
dependencies: []
priority: medium
ordinal: 10000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix Jimaku modal UX so selecting a subtitle file closes the modal automatically once subtitle download+load succeeds.
Current behavior:
- Subtitle file downloads and loads into mpv.
- Jimaku modal remains open until manual close.
Expected behavior:
- On successful `jimakuDownloadFile` result, close modal immediately.
- Keep error behavior unchanged (stay open + show error).
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Successful subtitle file selection/download in Jimaku closes modal automatically.
- [x] #2 Existing error path keeps modal open and shows error.
- [x] #3 Regression test covers success auto-close behavior.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed renderer Jimaku success flow to close modal immediately after successful `jimakuDownloadFile` result. Added regression test (`src/renderer/modals/jimaku.test.ts`) that reproduces keyboard file-selection success path and asserts modal close state + `notifyOverlayModalClosed('jimaku')` emission. Kept failure path unchanged.
Also wired new test into `test:core:src` and `test:core:dist` package scripts.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,50 @@
---
id: TASK-80
title: 'Jimaku download: rename subtitle to current video basename'
status: Done
assignee: []
created_date: '2026-03-01 14:17'
updated_date: '2026-03-01 14:19'
labels: []
dependencies: []
priority: medium
ordinal: 11000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When user selects a Jimaku subtitle, save subtitle with filename derived from currently playing media filename instead of Jimaku release filename.
Example:
- Current media: `anime.mkv`
- Downloaded subtitle extension: `.srt`
- Saved subtitle path: `anime.ja.srt`
Scope:
- Apply in Jimaku download IPC path before writing file.
- Preserve collision-avoidance behavior (suffix with jimaku entry id/counter when target exists).
- Keep mpv load flow unchanged except using renamed path.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Jimaku subtitle destination name uses current media basename plus `.ja` and subtitle extension.
- [x] #2 Existing duplicate filename conflict handling still works.
- [x] #3 Regression tests cover renamed destination path behavior.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Jimaku download path generation now derives subtitle filename from currently playing media basename and keeps subtitle extension from Jimaku file (`anime.mkv` + `.srt` => `anime.ja.srt`). Added pure helper `buildJimakuSubtitleFilenameFromMediaPath` and routed IPC download flow through it before existing duplicate-path conflict handling. Added regression tests for local path, missing extension fallback, and remote URL media paths.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,58 @@
---
id: TASK-81
title: 'Tokenization performance: disable Yomitan MeCab parser, gate local MeCab init, and add persistent MeCab process'
status: Done
assignee: []
created_date: '2026-03-02 07:44'
updated_date: '2026-03-02 20:44'
labels: []
dependencies: []
priority: high
ordinal: 9001
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Reduce subtitle annotation latency by:
- disabling Yomitan-side MeCab parser requests (`useMecabParser=false`);
- initializing local MeCab only when POS-dependent annotations are enabled (N+1 / JLPT / frequency);
- replacing per-line local MeCab process spawning with a persistent parser process that auto-shuts down after idle time and restarts on demand.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Yomitan parse requests disable MeCab parser path.
- [x] #2 MeCab warmup/init is skipped when all POS-dependent annotation toggles are off.
- [x] #3 Local MeCab tokenizer uses persistent process across subtitle lines.
- [x] #4 Persistent MeCab process auto-shuts down after idle timeout and restarts on next tokenize activity.
- [x] #5 Tests cover parser flag, warmup gating, and persistent MeCab lifecycle behavior.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented tokenizer latency optimizations:
- switched Yomitan parse requests to `useMecabParser: false`;
- added annotation-aware MeCab initialization gating in runtime warmup flow;
- added persistent local MeCab process (default idle shutdown: 30s) with queued requests, retry-on-process-end, idle auto-shutdown, and automatic restart on new work;
- added regression tests for Yomitan parse flag, MeCab warmup gating, and persistent/idle lifecycle behavior;
- fixed tokenization warmup gate so first-use warmup completion is sticky (`tokenizationWarmupCompleted`) and sequential `tokenizeSubtitle` calls no longer re-run Yomitan/dictionary warmup path;
- added regression coverage in `src/main/runtime/composers/mpv-runtime-composer.test.ts` for sequential tokenize calls (`warmup` side effects run once);
- post-review critical fix: treat Yomitan default-profile Anki server sync `no-change` as successful check, so `lastSyncedYomitanAnkiServer` is cached and expensive sync checks do not repeat on every subtitle line;
- added regression assertion in `src/core/services/tokenizer/yomitan-parser-runtime.test.ts` for `updated: false` path returning sync success;
- post-review performance fix: refactored POS enrichment to pre-index MeCab tokens by surface plus character-position overlap index, replacing repeated active-candidate filtering/full-scan behavior with direct overlap candidate lookup per token;
- added regression tests in `src/core/services/tokenizer/parser-enrichment-stage.test.ts` for repeated distant-token scan access and repeated active-candidate filter scans; both fail on scan-based behavior and pass with indexed lookup;
- post-review startup fix: moved JLPT/frequency dictionary initialization from synchronous FS APIs to async `fs/promises` path inspection/read and cooperative chunked entry processing to reduce main-thread stall risk during cold start;
- post-review first-line latency fix: decoupled tokenization warmup gating so first `tokenizeSubtitle` only waits on Yomitan extension readiness, while MeCab check + dictionary prewarm continue in parallel background warmups;
- validated with targeted tests and `tsc --noEmit`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,60 @@
---
id: TASK-82
title: 'Subtitle frequency highlighting: fix noisy Yomitan readings and restore known/N+1 color priority'
status: Done
assignee: []
created_date: '2026-03-02 20:10'
updated_date: '2026-03-02 01:44'
labels: []
dependencies: []
priority: high
ordinal: 9002
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Address frequency-highlighting regressions:
- tokens like `断じて` missed rank assignment when Yomitan merged-token reading was truncated/noisy;
- known/N+1 tokens were incorrectly colored by frequency color instead of known/N+1 color.
Expected behavior:
- known/N+1 color always wins;
- if token is frequent and within `topX`, frequency rank label can still appear on hover/metadata.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Frequency lookup succeeds for noisy/truncated merged-token readings via robust fallback behavior.
- [x] #2 Merged-token reading normalization restores missing kana suffixes where safe (`headword === surface` path).
- [x] #3 Known/N+1 tokens keep known/N+1 color classes; frequency color class does not override them.
- [x] #4 Frequency rank hover label remains available for in-range frequent tokens, including known/N+1.
- [x] #5 Regression tests added for tokenizer and renderer behavior.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented and validated:
- tokenizer now normalizes selected Yomitan merged-token readings by appending missing trailing kana suffixes when safe (`headword === surface`);
- frequency lookup now does lazy fallback: requests `{term, reading}` first, and only requests `{term, reading: null}` for misses;
- this removes eager `(term, null)` payload inflation on medium-frequency lines and reduces extension RPC payload/load;
- renderer restored known/N+1 color priority over frequency class coloring;
- frequency rank label display remains available for frequent known/N+1 tokens;
- added regression tests covering noisy-reading fallback, lazy fallback-query behavior, and renderer class/label precedence.
Related commits:
- `17a417e` (`fix(subtitle): improve frequency highlight reliability`)
- `79f37f3` (`fix(subtitle): prioritize known and n+1 colors over frequency`)
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,53 @@
---
id: TASK-83
title: 'Jellyfin subtitle delay: shift to adjacent cue without seek jumps'
status: Done
assignee: []
created_date: '2026-03-02 00:06'
updated_date: '2026-03-02 00:06'
labels: []
dependencies: []
priority: high
ordinal: 9003
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add keybinding-friendly special commands that shift `sub-delay` to align current subtitle start with next/previous cue start, without `sub-seek` probing (avoid playback jump).
Scope:
- add special commands for next/previous line alignment;
- compute delta from active subtitle cue timeline (external subtitle file/URL, including Jellyfin-delivered URLs);
- apply `add sub-delay <delta>` and show OSD value;
- keep existing proxy OSD behavior for direct `sub-delay` keybinding commands.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 New special commands exist for subtitle-delay shift to next/previous cue boundary.
- [x] #2 Shift logic parses active external subtitle source timings (SRT/VTT/ASS) and computes delta from current `sub-start`.
- [x] #3 Runtime applies delay shift without `sub-seek` and shows OSD feedback.
- [x] #4 Direct `sub-delay` proxy commands also show OSD current value.
- [x] #5 Tests added for cue parsing/shift behavior and IPC dispatch wiring.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented no-jump subtitle-delay alignment commands:
- added `__sub-delay-next-line` and `__sub-delay-prev-line` special commands;
- added `createShiftSubtitleDelayToAdjacentCueHandler` to parse cue start times from active external subtitle source and apply `add sub-delay` delta from current `sub-start`;
- wired command handling through IPC runtime deps into main runtime;
- retained/extended OSD proxy feedback for `sub-delay` keybindings;
- updated configuration docs and added regression tests for subtitle-delay shift and IPC command routing.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,37 @@
---
id: TASK-84
title: 'Docs Plausible endpoint uses /api/event path'
status: Done
assignee: []
created_date: '2026-03-03 00:00'
updated_date: '2026-03-03 00:00'
labels: []
dependencies: []
priority: medium
ordinal: 12000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix VitePress docs Plausible tracker config to post to hosted worker API event endpoint instead of worker root URL.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Docs theme Plausible `endpoint` points to `https://worker.subminer.moe/api/event`.
- [x] #2 Plausible docs test asserts `/api/event` endpoint path.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Updated docs Plausible tracker endpoint to `https://worker.subminer.moe/api/event` and updated regression test expectation accordingly.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,70 @@
---
id: TASK-84
title: Migrate AniSkip metadata+lookup orchestration to launcher/Electron
status: Done
assignee:
- Codex
created_date: '2026-03-03 08:31'
updated_date: '2026-03-03 08:35'
labels:
- enhancement
- aniskip
- launcher
- mpv-plugin
dependencies: []
references:
- launcher/aniskip-metadata.ts
- launcher/mpv.ts
- plugin/subminer/aniskip.lua
- plugin/subminer/options.lua
- plugin/subminer/state.lua
- plugin/subminer/lifecycle.lua
- plugin/subminer/messages.lua
- plugin/subminer.conf
- launcher/aniskip-metadata.test.ts
documentation:
- docs/mpv-plugin.md
- launcher/aniskip-metadata.ts
- plugin/subminer/aniskip.lua
- docs/architecture.md
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Move AniSkip MAL/title-to-MAL lookup and intro payload resolution from mpv Lua to launcher Electron flow, while keeping mpv-side intro skip UX and chapter/chapter prompt behavior in plugin. Launcher should infer/analyze file metadata, fetch AniSkip payload when launching files, and pass resolved skip window via script options; plugin should trust launcher payload and fall back only when absent.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launcher infers AniSkip metadata for file targets using existing guessit/fallback logic and performs AniSkip MAL + payload resolution during mpv startup.
- [x] #2 Launcher injects script options containing resolved MAL id and intro window fields (or explicit lookup-failure status) into mpv startup.
- [x] #3 Lua plugin consumes launcher-provided AniSkip intro data and skips all network lookups when payload is present.
- [x] #4 Standalone mpv/plugin usage without launcher payload continues to function using existing async in-plugin lookup path.
- [x] #5 Docs and defaults are updated to document new script-option contract.
- [x] #6 Launcher tests cover payload generation contract and fallback behavior where metadata is unavailable.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add launcher-side AniSkip payload resolution helpers in launcher/aniskip-metadata.ts (MAL prefix lookup + AniSkip payload fetch + result normalization).
2. Wire launcher/mpv.ts + buildSubminerScriptOpts to pass resolved AniSkip fields/mode in --script-opts for file playback.
3. Update plugin/subminer/aniskip.lua plus options/state to consume injected payload: if intro_start/end present, apply immediately and skip network lookup; otherwise retain existing async behavior.
4. Ensure fallback for standalone mpv usage remains intact for no-launcher/manual refresh.
5. Add/update tests/docs/config references for new script-opt contract and edge cases.
<!-- SECTION:PLAN:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Executed end-to-end migration so launcher resolves AniSkip title/MAL/payload before mpv start and injects it via --script-opts. Plugin now parses and consumes launcher payload (JSON/url/base64), applies OP intro from payload, tracks payload metadata in state, and keeps legacy async lookup path for non-launcher/absent payload playback. Added launcher config key aniskip_payload and updated launcher/aniskip-metadata tests for resolve/payload behavior and contract validation.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,38 @@
---
id: TASK-85
title: 'Remove docs Plausible analytics integration'
status: Done
assignee: []
created_date: '2026-03-03 00:00'
updated_date: '2026-03-03 00:00'
labels: []
dependencies: []
priority: medium
ordinal: 12001
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Remove Plausible analytics integration from docs theme and dependency graph. Keep docs build/runtime analytics-free.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Docs theme no longer imports or initializes Plausible tracker.
- [x] #2 `@plausible-analytics/tracker` removed from dependencies and lockfile.
- [x] #3 Docs analytics test reflects absence of Plausible wiring.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Deleted Plausible runtime wiring from VitePress theme, removed tracker package via `bun remove`, and updated docs test to assert no Plausible integration remains.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,68 @@
---
id: TASK-86
title: 'Renderer: keyboard-driven Yomitan lookup mode and popup key forwarding'
status: Done
assignee:
- Codex
created_date: '2026-03-04 13:40'
updated_date: '2026-03-05 11:30'
labels:
- enhancement
- renderer
- yomitan
dependencies:
- TASK-77
references:
- src/renderer/handlers/keyboard.ts
- src/renderer/handlers/mouse.ts
- src/renderer/renderer.ts
- src/renderer/state.ts
- src/renderer/yomitan-popup.ts
- src/core/services/overlay-window.ts
- src/preload.ts
- src/shared/ipc/contracts.ts
- src/types.ts
- vendor/yomitan/js/app/frontend.js
- vendor/yomitan/js/app/popup.js
- vendor/yomitan/js/display/display.js
- vendor/yomitan/js/display/popup-main.js
- vendor/yomitan/js/display/display-audio.js
documentation:
- README.md
- docs/usage.md
- docs/shortcuts.md
priority: medium
ordinal: 13000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add true keyboard-driven token lookup flow in overlay:
- Toggle keyboard token-selection mode and navigate tokens by keyboard (`Arrow` + `HJKL`).
- Toggle Yomitan lookup window for selected token via fixed accelerator (`Ctrl/Cmd+Y`) without requiring mouse click.
- Preserve keyboard-only workflow while popup is open by forwarding popup keys (`J/K`, `M`, `P`, `[`, `]`) and restoring overlay focus on popup close.
- Ensure selection styling and hover metadata tooltips (frequency/JLPT) work for keyboard-selected token.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Keyboard mode toggle exists and shows visual selection outline for active token.
- [x] #2 Navigation works via arrows and vim keys while keyboard mode is enabled.
- [x] #3 Lookup window toggles from selected token with `Ctrl/Cmd+Y`; close path restores overlay keyboard focus.
- [x] #4 Popup-local controls work via keyboard forwarding (`J/K`, `M`, `P`, `[`, `]`), including mine action.
- [x] #5 Frequency/JLPT hover tags render for keyboard-selected token.
- [x] #6 Renderer/runtime tests cover new visibility/selection behavior, and docs are updated.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented keyboard-driven Yomitan workflow end-to-end in renderer + bundled Yomitan runtime bridge. Added overlay-level keyboard mode state, token selection sync, lookup toggle routing, popup command forwarding, and focus recovery after popup close. Follow-up fixes kept lookup open while moving between tokens, made popup-local `J/K` and `ArrowUp/ArrowDown` scroll work from overlay-owned focus with key repeat, skipped keyboard/token annotation flow for parser groups that have no dictionary-backed headword, and preserved paused playback when token navigation jumps across subtitle lines. Updated user docs/README to document the final shortcut behavior.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,71 @@
---
id: TASK-87
title: >-
Codebase health: harden verification and retire dead architecture identified
in the March 2026 review
status: To Do
assignee: []
created_date: '2026-03-06 03:19'
updated_date: '2026-03-06 03:20'
labels:
- tech-debt
- tests
- maintainability
milestone: m-0
dependencies: []
references:
- package.json
- README.md
- src/main.ts
- src/anki-integration.ts
- src/core/services/immersion-tracker-service.test.ts
- src/translators/index.ts
- src/subsync/engines.ts
- src/subtitle/pipeline.ts
documentation:
- docs/reports/2026-02-22-task-100-dead-code-report.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Track the remediation work from the March 6, 2026 code review. The review found that the default test gate only exercises 53 of 241 test files, the dedicated subtitle test lane is a no-op, SQLite-backed immersion tracking tests are conditionally skipped in the standard Bun run, src/main.ts still contains a large dead-symbol backlog, several registry/pipeline modules appear unreferenced from live execution paths, and src/anki-integration.ts remains an oversized orchestration file. This parent task should coordinate a safe sequence: improve verification first, then remove dead code and continue decomposition with good test coverage in place.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Child tasks are created for each remediation workstream with explicit dependencies and enough context for an isolated agent to execute them.
- [ ] #2 The parent task records the recommended sequencing and parallelization strategy so replacement agents can resume without conversation history.
- [ ] #3 Completion of the parent task leaves the repository with a materially more trustworthy test gate, less dead architecture, and clearer ownership boundaries for the main runtime and Anki integration surfaces.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
Recommended sequencing:
1. Run TASK-87.1, TASK-87.2, TASK-87.3, and TASK-87.7 first. These are the safety-net and tooling tasks and can largely proceed in parallel.
2. Start TASK-87.4 once TASK-87.1 lands so src/main.ts cleanup happens under a more trustworthy test matrix.
3. Start TASK-87.5 after TASK-87.1 and TASK-87.2 so dead subsync/pipeline cleanup happens with stronger subtitle and runtime verification.
4. Start TASK-87.6 after TASK-87.1 so Anki refactors happen with broader default coverage in place.
5. Keep PRs focused: do not combine verification work with architectural cleanup unless a narrow dependency requires it.
Parallelization guidance:
- Wave 1 parallel: TASK-87.1, TASK-87.2, TASK-87.3, TASK-87.7
- Wave 2 parallel: TASK-87.4, TASK-87.5, TASK-87.6
Shared review context to restate in child tasks:
- Standard test scripts currently reference only 53 unique test files out of 241 discovered test and type-test files under src/ and launcher/.
- test:subtitle is currently a placeholder echo even though subtitle sync is a user-facing feature.
- SQLite-backed immersion tracker tests are conditionally skipped in the standard Bun run.
- src/main.ts trips many noUnusedLocals/noUnusedParameters diagnostics.
- src/translators/index.ts, src/subsync/engines.ts, src/subtitle/pipeline.ts, src/tokenizers/index.ts, and src/token-mergers/index.ts appeared unreferenced during review and must be re-verified before deletion.
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,53 @@
---
id: TASK-87.1
title: >-
Testing workflow: make standard test commands reflect the maintained test
surface
status: To Do
assignee: []
created_date: '2026-03-06 03:19'
updated_date: '2026-03-06 03:21'
labels:
- tests
- maintainability
milestone: m-0
dependencies: []
references:
- package.json
- src/main-entry-runtime.test.ts
- src/anki-integration/anki-connect-proxy.test.ts
- src/main/runtime/jellyfin-remote-playback.test.ts
- src/main/runtime/registry.test.ts
documentation:
- docs/reports/2026-02-22-task-100-dead-code-report.md
parent_task_id: TASK-87
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
The current package scripts hand-enumerate a small subset of test files, which leaves the standard green signal misleading. A local audit found 241 test/type-test files under src/ and launcher/, but only 53 unique files referenced by the standard package.json test scripts. This task should redesign the runnable test matrix so maintained tests are either executed by the standard commands or intentionally excluded through a documented rule, instead of silently drifting out of coverage.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 The repository has a documented and reproducible test matrix for standard development commands, including which suites belong in the default lane versus slower or environment-specific lanes.
- [ ] #2 The standard test entrypoints stop relying on a brittle hand-maintained allowlist for the currently covered unit and integration suites, or an explicit documented mechanism exists that prevents silent omission of new tests.
- [ ] #3 Representative tests that were previously outside the standard lane from src/main/runtime, src/anki-integration, and entry/runtime surfaces are executed by an automated command and included in the documented matrix.
- [ ] #4 Documentation for contributors explains which command to run for fast verification, full verification, and environment-specific verification.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inventory the current test surface under src/ and launcher/ and compare it to package.json scripts to classify fast, full, slow, and environment-specific suites.
2. Replace or reduce the brittle hand-maintained allowlist so new maintained tests do not silently miss the standard matrix.
3. Update contributor docs with the intended fast/full/environment-specific commands.
4. Verify the new matrix by running the relevant commands and by demonstrating at least one previously omitted runtime/Anki/entry test now belongs to an automated lane.
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,53 @@
---
id: TASK-87.2
title: >-
Subtitle sync verification: replace the no-op subtitle lane with real
automated coverage
status: To Do
assignee: []
created_date: '2026-03-06 03:19'
updated_date: '2026-03-06 03:21'
labels:
- tests
- subsync
milestone: m-0
dependencies: []
references:
- package.json
- README.md
- src/core/services/subsync.ts
- src/core/services/subsync.test.ts
- src/subsync/utils.ts
documentation:
- docs/reports/2026-02-22-task-100-dead-code-report.md
parent_task_id: TASK-87
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
SubMiner advertises subtitle syncing with alass and ffsubsync, but the dedicated test:subtitle command currently does not run any tests. There is already lower-level coverage in src/core/services/subsync.test.ts, but the test matrix and contributor-facing commands do not reflect that reality. This task should replace the no-op lane with real verification, align scripts with the existing subsync test surface, and make the user-facing docs honest about how subtitle sync is verified.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 The test:subtitle entrypoint runs real automated verification instead of echoing a placeholder message.
- [ ] #2 The subtitle verification lane covers both alass and ffsubsync behavior, including at least one non-happy-path scenario relevant to current functionality.
- [ ] #3 Contributor-facing documentation points to the real subtitle verification command and no longer implies a dedicated test lane exists when it does not.
- [ ] #4 The resulting verification strategy integrates cleanly with the repository-wide test matrix without duplicating or hiding existing subsync coverage.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Audit the existing subtitle-sync test surface, especially src/core/services/subsync.test.ts, and decide whether test:subtitle should reuse or regroup that coverage.
2. Replace the placeholder script with a real automated command and keep the matrix legible alongside TASK-87.1 work.
3. Update README or related docs so the advertised subtitle verification path matches reality.
4. Verify both alass and ffsubsync behavior remain covered by the resulting lane.
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,52 @@
---
id: TASK-87.3
title: >-
Immersion tracking verification: make SQLite-backed persistence tests visible
and reproducible
status: To Do
assignee: []
created_date: '2026-03-06 03:19'
updated_date: '2026-03-06 03:21'
labels:
- tests
- immersion-tracking
milestone: m-0
dependencies: []
references:
- src/core/services/immersion-tracker-service.test.ts
- src/core/services/immersion-tracker/storage-session.test.ts
- src/core/services/immersion-tracker-service.ts
- package.json
documentation:
- docs/reports/2026-02-22-task-100-dead-code-report.md
parent_task_id: TASK-87
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
The immersion tracker is persistence-heavy, but its SQLite-backed tests are conditionally skipped in the standard Bun run when node:sqlite support is unavailable. That creates a blind spot around session finalization, telemetry persistence, and retention behavior. This task should establish a reliable automated verification path for the database-backed cases and make the prerequisite/runtime behavior explicit to contributors and CI.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Database-backed immersion tracking tests run in at least one documented automated command that is practical for contributors or CI to execute.
- [ ] #2 If the current runtime cannot execute the SQLite-backed tests, the repository exposes that limitation clearly instead of silently reporting a misleading green result.
- [ ] #3 Contributor-facing documentation explains how to run the immersion tracker verification lane and any environment prerequisites it depends on.
- [ ] #4 The resulting verification covers session persistence or finalization behavior that is not exercised by the pure seam tests alone.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Confirm which SQLite-backed immersion tests are currently skipped and why in the standard Bun environment.
2. Establish a reproducible command or lane for the DB-backed cases, or make the unsupported-runtime limitation explicit and actionable.
3. Document prerequisites and expected behavior for contributors and CI.
4. Verify at least one persistence/finalization path beyond the seam tests is exercised by the new lane.
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,53 @@
---
id: TASK-87.4
title: >-
Runtime composition root: remove dead symbols and tighten module boundaries in
src/main.ts
status: To Do
assignee: []
created_date: '2026-03-06 03:19'
updated_date: '2026-03-06 03:21'
labels:
- tech-debt
- runtime
- maintainability
milestone: m-0
dependencies:
- TASK-87.1
references:
- src/main.ts
- src/main/runtime
- package.json
documentation:
- docs/reports/2026-02-22-task-100-dead-code-report.md
parent_task_id: TASK-87
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
A noUnusedLocals/noUnusedParameters compile pass reports a large concentration of dead imports and dead locals in src/main.ts. The file is also far beyond the repos preferred size guideline, which makes the runtime composition root difficult to review and easy to break. This task should remove confirmed dead symbols, continue extracting coherent slices where that improves readability, and leave the entrypoint materially easier to understand without changing behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 src/main.ts no longer emits dead-symbol diagnostics under a noUnusedLocals/noUnusedParameters compile pass for the areas touched by this cleanup.
- [ ] #2 Unused imports, destructured values, and stale locals identified in the current composition root are removed or relocated without behavior changes.
- [ ] #3 The resulting composition root has clearer ownership boundaries for at least one runtime slice that is currently buried in the monolith.
- [ ] #4 Relevant runtime and startup verification commands pass after the cleanup, and any command changes are documented if needed.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Re-run the noUnusedLocals/noUnusedParameters compile pass and capture the src/main.ts diagnostics cluster before editing.
2. Remove dead imports, destructured values, and stale locals in small reviewable slices; extract a coherent helper/module only where that materially reduces coupling.
3. Keep changes behavior-preserving and avoid mixing unrelated cleanup outside src/main.ts unless required to compile.
4. Verify with the updated runtime/startup test commands from TASK-87.1 plus a noUnused compile pass.
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,57 @@
---
id: TASK-87.5
title: >-
Dead architecture cleanup: delete unused registry and pipeline modules that
are off the live path
status: To Do
assignee: []
created_date: '2026-03-06 03:20'
updated_date: '2026-03-06 03:21'
labels:
- tech-debt
- dead-code
milestone: m-0
dependencies:
- TASK-87.1
- TASK-87.2
references:
- src/translators/index.ts
- src/subsync/engines.ts
- src/subtitle/pipeline.ts
- src/tokenizers/index.ts
- src/token-mergers/index.ts
- src/core/services/subsync.ts
- src/core/services/tokenizer.ts
documentation:
- docs/reports/2026-02-22-task-100-dead-code-report.md
parent_task_id: TASK-87
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
The review found several modules that appear self-contained but unused from the applications live execution paths: src/translators/index.ts, src/subsync/engines.ts, src/subtitle/pipeline.ts, src/tokenizers/index.ts, and src/token-mergers/index.ts. At the same time, the real runtime behavior is implemented elsewhere. This task should verify those modules are truly unused, remove or consolidate them, and clean up any stale exports, docs, or tests so contributors are not misled by duplicate architecture.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Each candidate module identified in the review is either removed as dead code or justified and reconnected to a real supported execution path.
- [ ] #2 Any stale exports, imports, or tests associated with the removed or consolidated modules are cleaned up so the codebase has a single obvious path for the affected behavior.
- [ ] #3 The cleanup does not regress live tokenization or subtitle sync behavior and the relevant verification commands remain green.
- [ ] #4 Contributor-facing documentation or internal notes no longer imply that removed duplicate architecture is part of the current design.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Re-verify each candidate module is off the live path by tracing imports from current runtime entrypoints before deleting anything.
2. Remove or consolidate truly dead modules and clean associated exports/imports/tests so only the supported path remains obvious.
3. Pay special attention to subtitle sync and tokenization surfaces, since duplicate architecture exists near active code.
4. Verify the relevant tokenization and subsync commands/tests still pass and update any stale docs or notes.
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,57 @@
---
id: TASK-87.6
title: >-
Anki integration maintainability: continue decomposing the oversized
orchestration layer
status: To Do
assignee: []
created_date: '2026-03-06 03:20'
updated_date: '2026-03-06 03:21'
labels:
- tech-debt
- anki
- maintainability
milestone: m-0
dependencies:
- TASK-87.1
references:
- src/anki-integration.ts
- src/anki-integration/field-grouping-workflow.ts
- src/anki-integration/note-update-workflow.ts
- src/anki-integration/card-creation.ts
- src/anki-integration/anki-connect-proxy.ts
- src/anki-integration.test.ts
documentation:
- docs/reports/2026-02-22-task-100-dead-code-report.md
- docs/anki-integration.md
parent_task_id: TASK-87
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
src/anki-integration.ts remains an oversized orchestration file even after earlier extractions. It still mixes config normalization, polling setup, media generation, duplicate resolution, field grouping workflows, and user feedback coordination in one class. This task should continue the decomposition so the remaining orchestration surface is smaller and easier to reason about, while preserving existing Anki, proxy, field grouping, and note update behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 The responsibilities currently concentrated in src/anki-integration.ts are split into clearer modules or services with narrow ownership boundaries.
- [ ] #2 The resulting orchestration surface is materially smaller and easier to review, with at least one mixed-responsibility cluster extracted behind a well-named interface.
- [ ] #3 Existing Anki integration behavior remains covered by automated verification, including note update, field grouping, and proxy-related flows that the refactor touches.
- [ ] #4 Any developer-facing docs or notes needed to understand the new structure are updated in the same task.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Map the remaining responsibility clusters inside src/anki-integration.ts and choose one or more extraction seams that reduce mixed concerns without changing behavior.
2. Move logic behind narrow interfaces/modules rather than creating another giant helper; keep orchestration readable.
3. Preserve coverage for field grouping, note update, proxy, and card creation flows touched by the refactor.
4. Update docs or internal notes if the new structure changes where contributors should look for a given behavior.
<!-- SECTION:PLAN:END -->

View File

@@ -0,0 +1,51 @@
---
id: TASK-87.7
title: >-
Developer workflow hygiene: make docs watch reproducible and remove stale
small-surface drift
status: To Do
assignee: []
created_date: '2026-03-06 03:20'
updated_date: '2026-03-06 03:21'
labels:
- tooling
- tech-debt
milestone: m-0
dependencies: []
references:
- package.json
- bun.lock
- src/anki-integration/field-grouping-workflow.ts
documentation:
- docs/reports/2026-02-22-task-100-dead-code-report.md
parent_task_id: TASK-87
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
The review found a few low-risk but recurring hygiene issues: docs:watch depends on bunx concurrently even though concurrently is not declared in package metadata, and small stale API surface remains after recent refactors, such as unused parameters in field-grouping workflow code. This task should make the developer workflow reproducible and clean up low-risk stale symbols that do not warrant a dedicated architecture task.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 The docs:watch workflow runs through declared project tooling or is rewritten to avoid undeclared dependencies.
- [ ] #2 Small stale symbols or parameters identified during the review outside the main composition-root cleanup are removed without behavior changes.
- [ ] #3 Any contributor-facing command changes are reflected in repository documentation.
- [ ] #4 The cleanup remains scoped to low-risk workflow and hygiene fixes rather than expanding into large architectural refactors.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Fix the docs:watch workflow so it relies on declared project tooling or an equivalent checked-in command path.
2. Clean up low-risk stale symbols surfaced by the review outside the main.ts architecture task, such as unused parameters left behind by refactors.
3. Keep the task scoped: avoid pulling in main composition-root cleanup or larger Anki/runtime refactors.
4. Verify the affected developer commands still work and document any usage changes.
<!-- SECTION:PLAN:END -->

View File

@@ -5,7 +5,6 @@
* 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.
@@ -17,7 +16,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.
// ========================================== // ==========================================
@@ -27,7 +26,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.
// ========================================== // ==========================================
@@ -36,7 +35,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.
// ========================================== // ==========================================
@@ -57,7 +56,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.
// ========================================== // ==========================================
@@ -77,7 +76,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.
// ========================================== // ==========================================
@@ -88,7 +87,8 @@
"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.
"replace": true, // Replace active subtitle file when synchronization succeeds.
}, // Subsync engine and executable paths. }, // Subsync engine and executable paths.
// ========================================== // ==========================================
@@ -96,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.
// ========================================== // ==========================================
@@ -107,6 +107,7 @@
"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,24 +130,19 @@
"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": [ "bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
"#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": "Manrope, Inter", // Font family setting. "fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // 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.
@@ -158,8 +154,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.
// ========================================== // ==========================================
@@ -176,17 +172,15 @@
"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": [ "tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"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
@@ -195,7 +189,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
@@ -208,7 +202,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
@@ -216,7 +210,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
@@ -225,20 +219,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.
// ========================================== // ==========================================
@@ -248,7 +242,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.
// ========================================== // ==========================================
@@ -259,10 +253,7 @@
"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": [ "primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher.
"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.
// ========================================== // ==========================================
@@ -271,7 +262,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.
// ========================================== // ==========================================
@@ -295,16 +286,8 @@
"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": [ "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
"mkv", "transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
"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.
// ========================================== // ==========================================
@@ -315,7 +298,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.
// ========================================== // ==========================================
@@ -337,7 +320,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,6 +69,7 @@ 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

@@ -188,7 +188,9 @@ export default {
}); });
}; };
onMounted(render); onMounted(() => {
render();
});
watch(() => route.path, render); watch(() => route.path, render);
}, },
}; };

View File

@@ -1,6 +1,7 @@
# 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
@@ -43,12 +44,15 @@ Polling mode uses the query `"deck:<your-deck>" added:1` to find recently added
Then point Yomitan/clients to `http://127.0.0.1:8766` instead of `8765`. Then point Yomitan/clients to `http://127.0.0.1:8766` instead of `8765`.
When SubMiner loads the bundled Yomitan extension, it also attempts to update the **default Yomitan profile** (`profiles[0].options.anki.server`) to the active SubMiner endpoint: When SubMiner loads the bundled Yomitan extension, it also attempts to update the **active bundled Yomitan profile** (`profiles[profileCurrent].options.anki.server`) to the active SubMiner endpoint:
- proxy URL when `ankiConnect.proxy.enabled` is `true` - proxy URL when `ankiConnect.proxy.enabled` is `true`
- direct `ankiConnect.url` when proxy mode is disabled - direct `ankiConnect.url` when proxy mode is disabled
To avoid clobbering custom setups, this auto-update only changes the default profile when its current server is blank or the stock Yomitan default (`http://127.0.0.1:8765`). Server update behavior differs by mode:
- Proxy mode (`ankiConnect.proxy.enabled: true`): SubMiner force-syncs the bundled active profile to the proxy URL so `addNote` traffic goes through the local proxy and auto-enrichment can trigger.
- Direct mode (`ankiConnect.proxy.enabled: false`): SubMiner only replaces blank/default server values (`http://127.0.0.1:8765`) to avoid overwriting custom direct-server setups.
For browser-based Yomitan or other external clients (for example Texthooker in a normal browser profile), set their Anki server to the same proxy URL separately: `http://127.0.0.1:8766` (or your configured `proxy.host` + `proxy.port`). For browser-based Yomitan or other external clients (for example Texthooker in a normal browser profile), set their Anki server to the same proxy URL separately: `http://127.0.0.1:8766` (or your configured `proxy.host` + `proxy.port`).
@@ -68,7 +72,7 @@ In Yomitan, go to Settings → Profile and:
3. Set server to `http://127.0.0.1:8766` (or your configured proxy URL). 3. Set server to `http://127.0.0.1:8766` (or your configured proxy URL).
4. Save and make that profile active when using SubMiner. 4. Save and make that profile active when using SubMiner.
This is only for non-bundled, external/browser Yomitan or other clients. The bundled profile auto-update logic only targets `profiles[0]` when it is blank or still default. This is only for non-bundled, external/browser Yomitan or other clients. Bundled Yomitan profile sync behavior is described above (force-sync in proxy mode, conservative sync in direct mode).
### Proxy Troubleshooting (quick checks) ### Proxy Troubleshooting (quick checks)
@@ -274,7 +278,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) |
@@ -299,7 +303,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,27 +72,471 @@ Restart-required changes:
The configuration file includes several main sections: The configuration file includes several main sections:
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media **Core Settings**
- [**Logging**](#logging) - Runtime log level
- [**Auto-Start Overlay**](#auto-start-overlay) - Automatically show overlay on MPV connection - [**Auto-Start Overlay**](#auto-start-overlay) - Automatically show overlay on MPV connection
- [**Visible Overlay Subtitle Binding**](#visible-overlay-subtitle-binding) - Link visible overlay toggles to MPV subtitle visibility - [**Startup Warmups**](#startup-warmups) - Control what preloads on startup vs first-use defer
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` - [**WebSocket Server**](#websocket-server) - Built-in subtitle broadcasting server
- [**Subtitle Position Edit**](#subtitle-position-edit) - Fine-tune subtitle alignment in overlay - [**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
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types
- [**N+1 Word Highlighting**](#n1-word-highlighting) - Known-word cache and single-target highlighting
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
**External Integrations**
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**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; resume after leaving subtitle area (`true` by default). |
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while Yomitan popup is open; resume when popup closes (`false` 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 |
| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue |
| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue |
| `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. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__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`) and subtitle delay commands (`sub-delay`), 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:
@@ -223,13 +667,28 @@ 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 Note Type Support:** ### Kiku/Lapis Integration
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`. 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.
When enabled, sentence cards automatically set `IsSentenceCard` to `"x"` and populate the `Expression` field. Audio cards set `IsAudioCard` to `"x"`. ```jsonc
"ankiConnect": {
"isLapis": {
"enabled": true,
"sentenceCardModel": "Japanese sentences"
},
"isKiku": {
"enabled": true,
"fieldGrouping": "manual",
"deleteDuplicateInAuto": true
}
}
```
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 `isLapis` to mine dedicated sentence cards. SubMiner sets `IsSentenceCard` to `"x"` and fills the sentence fields for the configured model.
- 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
@@ -281,111 +740,7 @@ 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>
**Image Quality Notes:** ## External Integrations
- `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
### 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
{
"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`.
### Auto Subtitle Sync
Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
```json
{
"subsync": {
"defaultMode": "auto",
"alass_path": "",
"ffsubsync_path": "",
"ffmpeg_path": ""
}
}
```
| Option | Values | Description |
| ---------------- | -------------------- | ----------------------------------------------------------------------------------------------------------- |
| `defaultMode` | `"auto"`, `"manual"` | `auto`: try `alass` against secondary subtitle, then fallback to `ffsubsync`; `manual`: open overlay picker |
| `alass_path` | string path | Path to `alass` executable. Empty or `null` falls back to `/usr/bin/alass`. |
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` falls back to `/usr/bin/ffsubsync`. |
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
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 ### Jimaku
@@ -407,6 +762,33 @@ Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry dela
Set `openBrowser` to `false` to only print the URL without opening a browser. Set `openBrowser` to `false` to only print the URL without opening a browser.
### Auto Subtitle Sync
Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
```json
{
"subsync": {
"defaultMode": "auto",
"alass_path": "",
"ffsubsync_path": "",
"ffmpeg_path": "",
"replace": true
}
}
```
| Option | Values | Description |
| ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `defaultMode` | `"auto"`, `"manual"` | `auto`: try `alass` against secondary subtitle, then fallback to `ffsubsync`; `manual`: open overlay picker |
| `alass_path` | string path | Path to `alass` executable. Empty or `null` falls back to `/usr/bin/alass`. |
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` falls back to `/usr/bin/ffsubsync`. |
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
Customize it there, or set it to `null` to disable.
### 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.
@@ -499,6 +881,7 @@ 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:
@@ -507,7 +890,7 @@ Launcher subcommands:
- `subminer jellyfin -l --server ... --username ... --password ...` logs in. - `subminer jellyfin -l --server ... --username ... --password ...` logs in.
- `subminer jellyfin --logout` clears stored credentials. - `subminer jellyfin --logout` clears stored credentials.
- `subminer jellyfin -p` opens play picker. - `subminer jellyfin -p` opens play picker.
- `subminer jellyfin -d` starts cast discovery mode. - `subminer jellyfin -d` starts cast discovery mode in background/tray mode.
- These launcher commands also accept `--password-store=<backend>` to override the launcher-app forwarded Electron switch. - These launcher commands also accept `--password-store=<backend>` to override the launcher-app forwarded Electron switch.
See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide. See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide.
@@ -553,308 +936,6 @@ 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:

72
docs/demos.md Normal file
View File

@@ -0,0 +1,72 @@
# 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,11 +6,14 @@ 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 (v1) ## Schema (v3)
Schema versioning table: Schema versioning table:
@@ -18,15 +21,21 @@ Schema versioning table:
Core entities: Core entities:
- `imm_videos`: video key/title/source metadata + optional media metadata fields - `imm_videos`: video key/title/source metadata + optional media metadata fields, `CREATED_DATE`/`LAST_UPDATE_DATE`
- `imm_sessions`: session UUID, video reference, timing/status fields - `imm_sessions`: session UUID, video reference, timing/status fields, `CREATED_DATE`/`LAST_UPDATE_DATE`
- `imm_session_telemetry`: high-frequency session aggregates over time - `imm_session_telemetry`: high-frequency session aggregates over time, `CREATED_DATE`/`LAST_UPDATE_DATE`
- `imm_session_events`: event stream with compact numeric event types - `imm_session_events`: event stream with compact numeric event types, `CREATED_DATE`/`LAST_UPDATE_DATE`
Rollups: Rollups:
- `imm_daily_rollups` - `imm_daily_rollups`: includes `CREATED_DATE`/`LAST_UPDATE_DATE`
- `imm_monthly_rollups` - `imm_monthly_rollups`: includes `CREATED_DATE`/`LAST_UPDATE_DATE`
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:
@@ -147,4 +156,3 @@ 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,9 +6,19 @@ 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(':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"'); expect(docsIndexContents).toContain(
expect(docsIndexContents).toContain('<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />'); ':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"',
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(
expect(docsIndexContents).toContain('<img :src="`/assets/minecard.gif?v=${demoAssetVersion}`" alt="SubMiner demo GIF fallback" style="width: 100%; height: auto;" />'); '<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(
'<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.gif?v=${demoAssetVersion}`" alt="SubMiner demo GIF fallback" style="width: 100%; height: auto;" /> <img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
</a> </a>
</video> </video>
</section> </section>

View File

@@ -194,7 +194,10 @@ 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
# Start the overlay (connects to mpv IPC) # Play a file (default plugin config auto-starts visible overlay and waits for annotation readiness)
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

@@ -60,12 +60,18 @@ Launcher wrapper equivalent for interactive playback flow:
subminer jellyfin -p subminer jellyfin -p
``` ```
Launcher wrapper for Jellyfin cast discovery mode (foreground app process): Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
```bash ```bash
subminer jellyfin -d subminer jellyfin -d
``` ```
Stop discovery session/app:
```bash
subminer app --stop
```
`subminer jf ...` is an alias for `subminer jellyfin ...`. `subminer jf ...` is an alias for `subminer jellyfin ...`.
To clear saved session credentials: To clear saved session credentials:
@@ -80,6 +86,17 @@ subminer jellyfin --logout
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search term SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search term
``` ```
Optional listing controls:
- `--jellyfin-recursive=true|false` (default: true)
- `--jellyfin-include-item-types=Series,Season,Folder,CollectionFolder,Movie,...`
These are used by the launcher picker flow to:
- keep root search focused on shows/folders/movies (exclude episode rows)
- browse selected anime/show directories as folder-or-file lists
- recurse for playable files only after selecting a folder
5. Start playback: 5. Start playback:
```bash ```bash

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,8 +53,9 @@ SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
## Common Commands ## Common Commands
```bash ```bash
subminer video.mkv # play a specific file subminer video.mkv # play a specific file (default plugin config auto-starts visible overlay)
subminer --start video.mkv # play + explicitly start overlay subminer --start video.mkv # optional explicit overlay start when plugin auto_start=no
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
``` ```
@@ -62,7 +63,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 |
@@ -79,20 +80,22 @@ 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 |
| `-S, --start` | Start overlay after mpv launches | | `--start` | Explicitly start overlay after mpv launches |
| `-T, --no-texthooker`| Disable texthooker server | | `-S, --start-overlay` | Explicitly start overlay after mpv launches |
| `-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,6 +33,8 @@ 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`)
- Optional auto-pause while Yomitan popup is open (`subtitleStyle.autoPauseVideoOnYomitanPopup`)
- 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
@@ -100,7 +102,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,11 +78,15 @@ 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=no auto_start=yes
# 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=no 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 changes. # Show OSD messages for overlay status changes.
osd_messages=yes osd_messages=yes
@@ -117,14 +121,15 @@ 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` | `no` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` | | `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load 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_visible_overlay` | `yes` | `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 |
@@ -132,6 +137,7 @@ aniskip_button_duration=3
| `aniskip_season` | `""` | numeric season | Optional season hint | | `aniskip_season` | `""` | numeric season | Optional season hint |
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id | | `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed | | `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed |
| `aniskip_payload` | `""` | JSON / base64-encoded JSON | Optional pre-fetched AniSkip payload for this media. When set, plugin skips network lookup |
| `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt | | `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt |
| `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) | | `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) |
| `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) | | `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) |
@@ -181,6 +187,7 @@ 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
``` ```
@@ -202,7 +209,8 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
- You explicitly call `script-message subminer-aniskip-refresh`. - You explicitly call `script-message subminer-aniskip-refresh`.
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`). - Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
- MAL/title resolution is cached for the current mpv session. - MAL/title resolution is cached for the current mpv session.
- When launched via `subminer`, launcher runs `guessit` first (file targets) and passes title/season/episode to the plugin; fallback is filename-derived title. - When launched via `subminer`, launcher can pass `aniskip_payload` (pre-fetched AniSkip `skip-times` payload) and the plugin applies it directly without making API calls.
- If the payload is absent or invalid, lookup falls back to title/MAL-based async fetch.
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`). - Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters. - If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing y-k` by default). - At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing y-k` by default).
@@ -211,6 +219,8 @@ 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,10 +13,12 @@
### 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.
@@ -28,12 +30,14 @@ 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.
@@ -47,6 +51,7 @@ 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**

15
docs/plausible.test.ts Normal file
View File

@@ -0,0 +1,15 @@
import { expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
test('docs theme has no plausible analytics wiring', () => {
expect(docsThemeContents).not.toContain('@plausible-analytics/tracker');
expect(docsThemeContents).not.toContain('initPlausibleTracker');
expect(docsThemeContents).not.toContain('worker.subminer.moe');
expect(docsThemeContents).not.toContain('domain:');
expect(docsThemeContents).not.toContain('outboundLinks: true');
expect(docsThemeContents).not.toContain('fileDownloads: true');
expect(docsThemeContents).not.toContain('formSubmissions: true');
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 MiB

After

Width:  |  Height:  |  Size: 23 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 MiB

View File

@@ -5,7 +5,6 @@
* 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.
@@ -17,7 +16,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.
// ========================================== // ==========================================
@@ -27,7 +26,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.
// ========================================== // ==========================================
@@ -36,7 +35,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.
// ========================================== // ==========================================
@@ -57,7 +56,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.
// ========================================== // ==========================================
@@ -77,7 +76,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.
// ========================================== // ==========================================
@@ -88,7 +87,8 @@
"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.
"replace": true, // Replace active subtitle file when synchronization succeeds.
}, // Subsync engine and executable paths. }, // Subsync engine and executable paths.
// ========================================== // ==========================================
@@ -96,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.
// ========================================== // ==========================================
@@ -107,6 +107,8 @@
"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; resume after leaving subtitle area. Values: true | false
"autoPauseVideoOnYomitanPopup": false, // Automatically pause mpv playback while Yomitan popup is open; resume when popup closes. 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,24 +131,19 @@
"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": [ "bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
"#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": "Manrope, Inter", // Font family setting. "fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // 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.
@@ -158,8 +155,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.
// ========================================== // ==========================================
@@ -176,17 +173,15 @@
"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": [ "tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"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
@@ -195,7 +190,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
@@ -208,7 +203,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
@@ -216,7 +211,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
@@ -225,20 +220,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.
// ========================================== // ==========================================
@@ -248,7 +243,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.
// ========================================== // ==========================================
@@ -259,10 +254,7 @@
"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": [ "primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher.
"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.
// ========================================== // ==========================================
@@ -271,7 +263,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.
// ========================================== // ==========================================
@@ -295,16 +287,8 @@
"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": [ "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
"mkv", "transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
"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.
// ========================================== // ==========================================
@@ -315,7 +299,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.
// ========================================== // ==========================================
@@ -337,7 +321,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,12 +38,16 @@ 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 |
| `ArrowDown` | Seek backward 60 seconds | | `ArrowDown` | Seek backward 60 seconds |
| `Shift+H` | Jump to previous subtitle | | `Shift+H` | Jump to previous subtitle |
| `Shift+L` | Jump to next subtitle | | `Shift+L` | Jump to next subtitle |
| `Shift+[` | Shift subtitle delay to previous subtitle cue |
| `Shift+]` | Shift subtitle delay to next subtitle cue |
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) | | `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) | | `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
| `Q` | Quit mpv | | `Q` | Quit mpv |
@@ -54,6 +58,33 @@ 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 after leaving subtitle area). Optional popup behavior: set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true` to keep playback paused while Yomitan popup is open.
When a Yomitan popup is open, SubMiner also provides popup control shortcuts:
| Shortcut | Action |
| ----------- | ----------------------------------------------- |
| `J` | Scroll definitions down |
| `K` | Scroll definitions up |
| `ArrowDown` | Scroll definitions down |
| `ArrowUp` | Scroll definitions up |
| `M` | Mine/add selected term |
| `P` | Play selected term audio |
| `[` | Play previous available audio (selected source) |
| `]` | Play next available audio (selected source) |
## Keyboard-Driven Lookup Mode
These shortcuts are fixed (not configurable) and require overlay focus.
| Shortcut | Action |
| ------------------------------ | -------------------------------------------------------------------------------------------- |
| `Ctrl/Cmd+Shift+Y` | Toggle keyboard-driven token selection mode on/off |
| `Ctrl/Cmd+Y` | Toggle lookup popup for selected token (open when closed, close when open) |
| `ArrowLeft/Right`, `H`, or `L` | Move selected token (previous/next); if lookup is open, refresh definition for the new token |
Keyboard-driven mode draws a selection outline around the active token. Use `Ctrl/Cmd+Y` to open or close lookup for that token. While the popup is open, popup-local controls still work from the overlay (`J/K`, `ArrowUp/ArrowDown`, `M`, `P`, `[`, `]`) and focus is forced back to the overlay so token navigation can continue without clicking subtitle text again. Moving left/right past the start or end of the line jumps to the previous or next subtitle line and keeps playback paused if it was already paused.
## Subtitle & Feature Shortcuts ## Subtitle & Feature Shortcuts
| Shortcut | Action | Config key | | Shortcut | Action | Config key |
@@ -63,24 +94,12 @@ These keybindings can be overridden or disabled via the `keybindings` config arr
| `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, use subtitle position edit mode (`Ctrl/Cmd+Shift+P`) to fine-tune the offset with arrow keys, then save with `Enter` or `Ctrl+S`. If the overlay position is slightly off, right-click and drag on subtitle text to fine-tune the overlay subtitle offset.
## 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 in position edit mode. - **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.
- **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,15 +1,19 @@
# 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. Overlay start is explicit (`--start`, `-S`, or `y-s`). | | **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. |
| **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 --start video.mkv`. `subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer video.mkv`.
## Live Config Reload ## Live Config Reload
@@ -34,8 +38,9 @@ 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 subminer video.mkv # Play specific file (default plugin config auto-starts visible overlay)
subminer --start video.mkv # Play + explicitly start overlay subminer --start video.mkv # Optional explicit overlay start (use when plugin auto_start=no)
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
@@ -50,7 +55,8 @@ subminer jellyfin # Open Jellyfin setup window (subcommand form)
subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret' subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret'
subminer jellyfin --logout # Clear stored Jellyfin token/session data subminer jellyfin --logout # Clear stored Jellyfin token/session data
subminer jellyfin -p # Interactive Jellyfin library/item picker + playback subminer jellyfin -p # Interactive Jellyfin library/item picker + playback
subminer jellyfin -d # Jellyfin cast-discovery mode (foreground app) subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app)
subminer app --stop # Stop background app (including Jellyfin cast broadcast)
subminer doctor # Dependency + config + socket diagnostics subminer doctor # Dependency + config + socket diagnostics
subminer config path # Print active config path subminer config path # Print active config path
subminer config show # Print active config contents subminer config show # Print active config contents
@@ -146,6 +152,14 @@ 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.
@@ -168,7 +182,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 |
@@ -191,14 +205,18 @@ 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. Playback resumes as soon as the cursor leaves subtitle text. Set `subtitleStyle.autoPauseVideoOnHover` to `false` to disable this behavior.
If you want playback to stay paused while a Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true`. When enabled, SubMiner auto-resumes on popup close only if SubMiner paused playback for that popup.
Keyboard-driven lookup mode is available with fixed shortcuts: `Ctrl/Cmd+Shift+Y` toggles token-selection mode, `ArrowLeft/Right` (or `H/L`) moves the selected token, and `Ctrl/Cmd+Y` opens or closes lookup for that token.
If the Yomitan popup is open, you can control it directly from the overlay without moving focus into the popup: `J/K` or `ArrowUp/ArrowDown` scroll definitions, `M` mines/adds the selected term, `P` plays term audio, `[` plays the previous available audio, and `]` plays the next available audio in the selected source. While lookup stays open, `ArrowLeft/Right` (or `H/L`) moves to the previous or next token and refreshes the definition for the new token. If you move past the start or end of the current subtitle line, SubMiner jumps to the previous or next subtitle line, moves the selector to the edge token on that line, and keeps playback paused if it was already paused.
### 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

@@ -4,8 +4,38 @@ import {
inferAniSkipMetadataForFile, inferAniSkipMetadataForFile,
buildSubminerScriptOpts, buildSubminerScriptOpts,
parseAniSkipGuessitJson, parseAniSkipGuessitJson,
resolveAniSkipMetadataForFile,
} from './aniskip-metadata'; } from './aniskip-metadata';
function makeMockResponse(payload: unknown): Response {
return {
ok: true,
status: 200,
json: async () => payload,
} as Response;
}
function normalizeFetchInput(input: string | URL | Request): string {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.toString();
return input.url;
}
async function withMockFetch(
handler: (input: string | URL | Request) => Promise<Response>,
fn: () => Promise<void>,
): Promise<void> {
const original = globalThis.fetch;
(globalThis as { fetch: typeof fetch }).fetch = (async (input: string | URL | Request) => {
return handler(input);
}) as typeof fetch;
try {
await fn();
} finally {
(globalThis as { fetch: typeof fetch }).fetch = original;
}
}
test('parseAniSkipGuessitJson extracts title season and episode', () => { test('parseAniSkipGuessitJson extracts title season and episode', () => {
const parsed = parseAniSkipGuessitJson( const parsed = parseAniSkipGuessitJson(
JSON.stringify({ title: 'My Show', season: 2, episode: 7 }), JSON.stringify({ title: 'My Show', season: 2, episode: 7 }),
@@ -16,6 +46,10 @@ test('parseAniSkipGuessitJson extracts title season and episode', () => {
season: 2, season: 2,
episode: 7, episode: 7,
source: 'guessit', source: 'guessit',
malId: null,
introStart: null,
introEnd: null,
lookupStatus: 'lookup_failed',
}); });
}); });
@@ -34,6 +68,10 @@ test('parseAniSkipGuessitJson prefers series over episode title', () => {
season: 1, season: 1,
episode: 10, episode: 10,
source: 'guessit', source: 'guessit',
malId: null,
introStart: null,
introEnd: null,
lookupStatus: 'lookup_failed',
}); });
}); });
@@ -60,16 +98,80 @@ test('inferAniSkipMetadataForFile falls back to anime directory title when filen
assert.equal(parsed.source, 'fallback'); assert.equal(parsed.source, 'fallback');
}); });
test('buildSubminerScriptOpts includes aniskip metadata fields', () => { test('resolveAniSkipMetadataForFile resolves MAL id and intro payload', async () => {
await withMockFetch(
async (input) => {
const url = normalizeFetchInput(input);
if (url.includes('myanimelist.net/search/prefix.json')) {
return makeMockResponse({
categories: [
{
items: [
{ id: '9876', name: 'Wrong Match' },
{ id: '1234', name: 'My Show' },
],
},
],
});
}
if (url.includes('api.aniskip.com/v1/skip-times/1234/7')) {
return makeMockResponse({
found: true,
results: [{ skip_type: 'op', interval: { start_time: 12.5, end_time: 54.2 } }],
});
}
throw new Error(`unexpected url: ${url}`);
},
async () => {
const resolved = await resolveAniSkipMetadataForFile('/media/Anime.My.Show.S01E07.mkv');
assert.equal(resolved.malId, 1234);
assert.equal(resolved.introStart, 12.5);
assert.equal(resolved.introEnd, 54.2);
assert.equal(resolved.lookupStatus, 'ready');
assert.equal(resolved.title, 'Anime My Show');
},
);
});
test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses', async () => {
await withMockFetch(
async () => makeMockResponse({ categories: [] }),
async () => {
const resolved = await resolveAniSkipMetadataForFile('/media/NopeShow.S01E03.mkv');
assert.equal(resolved.malId, null);
assert.equal(resolved.lookupStatus, 'missing_mal_id');
},
);
});
test('buildSubminerScriptOpts includes aniskip payload 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',
malId: 1234,
introStart: 30.5,
introEnd: 62,
lookupStatus: 'ready',
}); });
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/); assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/); assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/); assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
assert.match(opts, /subminer-aniskip_season=1/); assert.match(opts, /subminer-aniskip_season=1/);
assert.match(opts, /subminer-aniskip_episode=5/); assert.match(opts, /subminer-aniskip_episode=5/);
assert.match(opts, /subminer-aniskip_mal_id=1234/);
assert.match(opts, /subminer-aniskip_intro_start=30.5/);
assert.match(opts, /subminer-aniskip_intro_end=62/);
assert.match(opts, /subminer-aniskip_lookup_status=ready/);
assert.ok(payloadMatch !== null);
assert.equal(payloadMatch[1].includes('%'), false);
const payloadJson = Buffer.from(payloadMatch[1], 'base64url').toString('utf-8');
const payload = JSON.parse(payloadJson);
assert.equal(payload.found, true);
const first = payload.results?.[0];
assert.equal(first.skip_type, 'op');
assert.equal(first.interval.start_time, 30.5);
assert.equal(first.interval.end_time, 62);
}); });

View File

@@ -2,11 +2,22 @@ import path from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { commandExists } from './util.js'; import { commandExists } from './util.js';
export type AniSkipLookupStatus =
| 'ready'
| 'missing_mal_id'
| 'missing_episode'
| 'missing_payload'
| 'lookup_failed';
export interface AniSkipMetadata { export interface AniSkipMetadata {
title: string; title: string;
season: number | null; season: number | null;
episode: number | null; episode: number | null;
source: 'guessit' | 'fallback'; source: 'guessit' | 'fallback';
malId: number | null;
introStart: number | null;
introEnd: number | null;
lookupStatus?: AniSkipLookupStatus;
} }
interface InferAniSkipDeps { interface InferAniSkipDeps {
@@ -14,6 +25,50 @@ interface InferAniSkipDeps {
runGuessit: (mediaPath: string) => string | null; runGuessit: (mediaPath: string) => string | null;
} }
interface MalSearchResult {
id?: unknown;
name?: unknown;
}
interface MalSearchCategory {
items?: unknown;
}
interface MalSearchResponse {
categories?: unknown;
}
interface AniSkipIntervalPayload {
start_time?: unknown;
end_time?: unknown;
}
interface AniSkipSkipItemPayload {
skip_type?: unknown;
interval?: unknown;
}
interface AniSkipPayloadResponse {
found?: unknown;
results?: unknown;
}
const MAL_PREFIX_API = 'https://myanimelist.net/search/prefix.json?type=anime&keyword=';
const ANISKIP_PAYLOAD_API = 'https://api.aniskip.com/v1/skip-times/';
const MAL_USER_AGENT = 'SubMiner-launcher/ani-skip';
const MAL_MATCH_STOPWORDS = new Set([
'the',
'this',
'that',
'world',
'animated',
'series',
'season',
'no',
'on',
'and',
]);
function toPositiveInt(value: unknown): number | null { function toPositiveInt(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) { if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.floor(value); return Math.floor(value);
@@ -27,8 +82,233 @@ function toPositiveInt(value: unknown): number | null {
return null; return null;
} }
function toPositiveNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value;
}
if (typeof value === 'string') {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return null;
}
function normalizeForMatch(value: string): string {
return value
.toLowerCase()
.replace(/[^\w]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function tokenizeMatchWords(value: string): string[] {
const words = normalizeForMatch(value)
.split(' ')
.filter((word) => word.length >= 3);
return words.filter((word) => !MAL_MATCH_STOPWORDS.has(word));
}
function titleOverlapScore(expectedTitle: string, candidateTitle: string): number {
const expected = normalizeForMatch(expectedTitle);
const candidate = normalizeForMatch(candidateTitle);
if (!expected || !candidate) return 0;
if (candidate.includes(expected)) return 120;
const expectedTokens = tokenizeMatchWords(expectedTitle);
if (expectedTokens.length === 0) return 0;
const candidateSet = new Set(tokenizeMatchWords(candidateTitle));
let score = 0;
let matched = 0;
for (const token of expectedTokens) {
if (candidateSet.has(token)) {
score += 30;
matched += 1;
} else {
score -= 20;
}
}
if (matched === 0) {
score -= 80;
}
const coverage = matched / expectedTokens.length;
if (expectedTokens.length >= 2) {
if (coverage >= 0.8) score += 30;
else if (coverage >= 0.6) score += 10;
else score -= 50;
} else if (coverage >= 1) {
score += 10;
}
return score;
}
function hasAnySequelMarker(candidateTitle: string): boolean {
const normalized = ` ${normalizeForMatch(candidateTitle)} `;
if (!normalized.trim()) return false;
const markers = [
'season 2',
'season 3',
'season 4',
'2nd season',
'3rd season',
'4th season',
'second season',
'third season',
'fourth season',
' ii ',
' iii ',
' iv ',
];
return markers.some((marker) => normalized.includes(marker));
}
function seasonSignalScore(requestedSeason: number | null, candidateTitle: string): number {
const season = toPositiveInt(requestedSeason);
if (!season || season < 1) return 0;
const normalized = ` ${normalizeForMatch(candidateTitle)} `;
if (!normalized.trim()) return 0;
if (season === 1) {
return hasAnySequelMarker(candidateTitle) ? -60 : 20;
}
const numericMarker = ` season ${season} `;
const ordinalMarker = ` ${season}th season `;
if (normalized.includes(numericMarker) || normalized.includes(ordinalMarker)) {
return 40;
}
const romanAliases = {
2: [' ii ', ' second season ', ' 2nd season '],
3: [' iii ', ' third season ', ' 3rd season '],
4: [' iv ', ' fourth season ', ' 4th season '],
5: [' v ', ' fifth season ', ' 5th season '],
} as const;
const aliases = romanAliases[season] ?? [];
return aliases.some((alias) => normalized.includes(alias))
? 40
: hasAnySequelMarker(candidateTitle)
? -20
: 5;
}
function toMalSearchItems(payload: unknown): MalSearchResult[] {
const parsed = payload as MalSearchResponse;
const categories = Array.isArray(parsed?.categories) ? parsed.categories : null;
if (!categories) return [];
const items: MalSearchResult[] = [];
for (const category of categories) {
const typedCategory = category as MalSearchCategory;
const rawItems = Array.isArray(typedCategory?.items) ? typedCategory.items : [];
for (const rawItem of rawItems) {
const item = rawItem as Record<string, unknown>;
items.push({
id: item?.id,
name: item?.name,
});
}
}
return items;
}
function normalizeEpisodePayload(value: unknown): number | null {
return toPositiveNumber(value);
}
function parseAniSkipPayload(payload: unknown): { start: number; end: number } | null {
const parsed = payload as AniSkipPayloadResponse;
const results = Array.isArray(parsed?.results) ? parsed.results : null;
if (!results) return null;
for (const rawResult of results) {
const result = rawResult as AniSkipSkipItemPayload;
if (
result.skip_type !== 'op' ||
typeof result.interval !== 'object' ||
result.interval === null
) {
continue;
}
const interval = result.interval as AniSkipIntervalPayload;
const start = normalizeEpisodePayload(interval?.start_time);
const end = normalizeEpisodePayload(interval?.end_time);
if (start !== null && end !== null && end > start) {
return { start, end };
}
}
return null;
}
async function fetchJson<T>(url: string): Promise<T | null> {
const response = await fetch(url, {
headers: {
'User-Agent': MAL_USER_AGENT,
},
});
if (!response.ok) return null;
try {
return (await response.json()) as T;
} catch {
return null;
}
}
async function resolveMalIdFromTitle(title: string, season: number | null): Promise<number | null> {
const lookup = season && season > 1 ? `${title} Season ${season}` : title;
const payload = await fetchJson<unknown>(`${MAL_PREFIX_API}${encodeURIComponent(lookup)}`);
const items = toMalSearchItems(payload);
if (!items.length) return null;
let bestScore = Number.NEGATIVE_INFINITY;
let bestMalId: number | null = null;
for (const item of items) {
const id = toPositiveInt(item.id);
if (!id) continue;
const name = typeof item.name === 'string' ? item.name : '';
if (!name) continue;
const score = titleOverlapScore(title, name) + seasonSignalScore(season, name);
if (score > bestScore) {
bestScore = score;
bestMalId = id;
}
}
return bestMalId;
}
async function fetchAniSkipPayload(
malId: number,
episode: number,
): Promise<{ start: number; end: number } | null> {
const payload = await fetchJson<unknown>(
`${ANISKIP_PAYLOAD_API}${malId}/${episode}?types=op&types=ed`,
);
const parsed = payload as AniSkipPayloadResponse;
if (!parsed || parsed.found !== true) return null;
return parseAniSkipPayload(parsed);
}
function detectEpisodeFromName(baseName: string): number | null { function detectEpisodeFromName(baseName: string): number | null {
const patterns = [/[Ss]\d+[Ee](\d{1,3})/, /(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/, /[-\s](\d{1,3})$/]; const patterns = [
/[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;
@@ -129,6 +409,10 @@ export function parseAniSkipGuessitJson(stdout: string, mediaPath: string): AniS
season, season,
episode: episodeFromDirect ?? episodeFromList, episode: episodeFromDirect ?? episodeFromList,
source: 'guessit', source: 'guessit',
malId: null,
introStart: null,
introEnd: null,
lookupStatus: 'lookup_failed',
}; };
} catch { } catch {
return null; return null;
@@ -167,11 +451,100 @@ export function inferAniSkipMetadataForFile(
season: detectSeasonFromNameOrDir(mediaPath), season: detectSeasonFromNameOrDir(mediaPath),
episode: detectEpisodeFromName(baseName), episode: detectEpisodeFromName(baseName),
source: 'fallback', source: 'fallback',
malId: null,
introStart: null,
introEnd: null,
lookupStatus: 'lookup_failed',
}; };
} }
export async function resolveAniSkipMetadataForFile(mediaPath: string): Promise<AniSkipMetadata> {
const inferred = inferAniSkipMetadataForFile(mediaPath);
if (!inferred.title) {
return { ...inferred, lookupStatus: 'lookup_failed' };
}
try {
const malId = await resolveMalIdFromTitle(inferred.title, inferred.season);
if (!malId) {
return {
...inferred,
malId: null,
introStart: null,
introEnd: null,
lookupStatus: 'missing_mal_id',
};
}
if (!inferred.episode) {
return {
...inferred,
malId,
introStart: null,
introEnd: null,
lookupStatus: 'missing_episode',
};
}
const payload = await fetchAniSkipPayload(malId, inferred.episode);
if (!payload) {
return {
...inferred,
malId,
introStart: null,
introEnd: null,
lookupStatus: 'missing_payload',
};
}
return {
...inferred,
malId,
introStart: payload.start,
introEnd: payload.end,
lookupStatus: 'ready',
};
} catch {
return {
...inferred,
malId: inferred.malId,
introStart: inferred.introStart,
introEnd: inferred.introEnd,
lookupStatus: 'lookup_failed',
};
}
}
function sanitizeScriptOptValue(value: string): string { function sanitizeScriptOptValue(value: string): string {
return value.replace(/,/g, ' ').replace(/[\r\n]/g, ' ').replace(/\s+/g, ' ').trim(); return value
.replace(/,/g, ' ')
.replace(/[\r\n]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function buildLauncherAniSkipPayload(aniSkipMetadata: AniSkipMetadata): string | null {
if (!aniSkipMetadata.malId || !aniSkipMetadata.introStart || !aniSkipMetadata.introEnd) {
return null;
}
if (aniSkipMetadata.introEnd <= aniSkipMetadata.introStart) {
return null;
}
const payload = {
found: true,
results: [
{
skip_type: 'op',
interval: {
start_time: aniSkipMetadata.introStart,
end_time: aniSkipMetadata.introEnd,
},
},
],
};
// mpv --script-opts treats `%` as an escape prefix, so URL-encoding can break parsing.
// Base64url stays script-opts-safe and is decoded by the plugin launcher payload parser.
return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url');
} }
export function buildSubminerScriptOpts( export function buildSubminerScriptOpts(
@@ -192,5 +565,23 @@ export function buildSubminerScriptOpts(
if (aniSkipMetadata && aniSkipMetadata.episode && aniSkipMetadata.episode > 0) { if (aniSkipMetadata && aniSkipMetadata.episode && aniSkipMetadata.episode > 0) {
parts.push(`subminer-aniskip_episode=${aniSkipMetadata.episode}`); parts.push(`subminer-aniskip_episode=${aniSkipMetadata.episode}`);
} }
if (aniSkipMetadata && aniSkipMetadata.malId && aniSkipMetadata.malId > 0) {
parts.push(`subminer-aniskip_mal_id=${aniSkipMetadata.malId}`);
}
if (aniSkipMetadata && aniSkipMetadata.introStart !== null && aniSkipMetadata.introStart > 0) {
parts.push(`subminer-aniskip_intro_start=${aniSkipMetadata.introStart}`);
}
if (aniSkipMetadata && aniSkipMetadata.introEnd !== null && aniSkipMetadata.introEnd > 0) {
parts.push(`subminer-aniskip_intro_end=${aniSkipMetadata.introEnd}`);
}
if (aniSkipMetadata?.lookupStatus) {
parts.push(
`subminer-aniskip_lookup_status=${sanitizeScriptOptValue(aniSkipMetadata.lookupStatus)}`,
);
}
const aniskipPayload = aniSkipMetadata ? buildLauncherAniSkipPayload(aniSkipMetadata) : null;
if (aniskipPayload) {
parts.push(`subminer-aniskip_payload=${sanitizeScriptOptValue(aniskipPayload)}`);
}
return parts.join(','); return parts.join(',');
} }

View File

@@ -33,6 +33,12 @@ 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 } from '../types.js'; import type { Args, LauncherJellyfinConfig, PluginRuntimeConfig } from '../types.js';
import type { ProcessAdapter } from '../process-adapter.js'; import type { ProcessAdapter } from '../process-adapter.js';
export interface LauncherCommandContext { export interface LauncherCommandContext {
@@ -6,6 +6,7 @@ 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

@@ -65,7 +65,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
} }
if (args.jellyfinDiscovery) { if (args.jellyfinDiscovery) {
const forwarded = ['--start']; const forwarded = ['--background', '--jellyfin-remote-announce'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded); appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded); runAppCommandWithInherit(appPath, forwarded);

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, processAdapter } = context; const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, 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,13 +137,23 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
log('info', args.logLevel, 'YouTube subtitle mode: off'); log('info', args.logLevel, 'YouTube subtitle mode: off');
} }
startMpv( const shouldPauseUntilOverlayReady =
pluginRuntimeConfig.autoStart &&
pluginRuntimeConfig.autoStartVisibleOverlay &&
pluginRuntimeConfig.autoStartPauseUntilReady;
if (shouldPauseUntilOverlayReady) {
log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
}
await startMpv(
selectedTarget.target, selectedTarget.target,
selectedTarget.kind, selectedTarget.kind,
args, args,
mpvSocketPath, mpvSocketPath,
appPath, appPath,
preloadedSubtitles, preloadedSubtitles,
{ startPaused: shouldPauseUntilOverlayReady },
); );
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') { if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
@@ -167,6 +177,7 @@ 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) {
@@ -179,6 +190,12 @@ 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,10 +51,27 @@ 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 ignores inline comments', () => { test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => {
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,11 +4,9 @@ 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( const output = execFileSync('bun', ['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'], {
'bun', encoding: 'utf8',
['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,7 +182,8 @@ 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: typeof options.passwordStore === 'string' ? options.passwordStore : undefined, passwordStore:
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,22 +15,67 @@ export function getPluginConfigCandidates(): string[] {
); );
} }
export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeConfig { export function parsePluginRuntimeConfigContent(
const runtimeConfig: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH }; content: string,
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 socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i); const keyValueMatch = trimmed.match(/^([a-z0-9_-]+)\s*=\s*(.+)$/i);
if (!socketMatch) continue; if (!keyValueMatch) continue;
const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || ''; const key = (keyValueMatch[1] || '').toLowerCase();
if (value) runtimeConfig.socketPath = value; const value = (keyValueMatch[2] || '').split('#', 1)[0]?.trim() || '';
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 = { socketPath: DEFAULT_SOCKET_PATH }; const defaults: PluginRuntimeConfig = {
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;
@@ -39,7 +84,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
log( log(
'debug', 'debug',
logLevel, logLevel,
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}`, `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}`,
); );
return parsed; return parsed;
} catch { } catch {
@@ -51,7 +96,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})`, `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})`,
); );
return defaults; return defaults;
} }

View File

@@ -1,5 +1,6 @@
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import type { import type {
Args, Args,
@@ -8,8 +9,8 @@ import type {
JellyfinItemEntry, JellyfinItemEntry,
JellyfinGroupEntry, JellyfinGroupEntry,
} from './types.js'; } from './types.js';
import { log, fail } from './log.js'; import { log, fail, getMpvLogPath } from './log.js';
import { commandExists, resolvePathMaybe } from './util.js'; import { commandExists, resolvePathMaybe, sleep } from './util.js';
import { import {
pickLibrary, pickLibrary,
pickItem, pickItem,
@@ -18,12 +19,17 @@ import {
findRofiTheme, findRofiTheme,
} from './picker.js'; } from './picker.js';
import { loadLauncherJellyfinConfig } from './config.js'; import { loadLauncherJellyfinConfig } from './config.js';
import { resolveLauncherMainConfigPath } from './config/shared-config-reader.js';
import { import {
runAppCommandWithInheritLogged, runAppCommandWithInheritLogged,
runAppCommandCaptureOutput,
launchAppStartDetached,
launchMpvIdleDetached, launchMpvIdleDetached,
waitForUnixSocketReady, waitForUnixSocketReady,
} from './mpv.js'; } from './mpv.js';
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
export function sanitizeServerUrl(value: string): string { export function sanitizeServerUrl(value: string): string {
return value.trim().replace(/\/+$/, ''); return value.trim().replace(/\/+$/, '');
} }
@@ -114,6 +120,606 @@ export function formatJellyfinItemDisplay(item: Record<string, unknown>): string
return `${name} (${type})`; return `${name} (${type})`;
} }
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, '');
}
function parseNamedJellyfinRecord(payload: string): {
name: string;
id: string;
type: string;
} | null {
const typeClose = payload.lastIndexOf(')');
if (typeClose !== payload.length - 1) return null;
const typeOpen = payload.lastIndexOf(' (');
if (typeOpen <= 0 || typeOpen >= typeClose) return null;
const idClose = payload.lastIndexOf(']', typeOpen);
if (idClose <= 0) return null;
const idOpen = payload.lastIndexOf(' [', idClose);
if (idOpen <= 0 || idOpen >= idClose) return null;
const name = payload.slice(0, idOpen).trim();
const id = payload.slice(idOpen + 2, idClose).trim();
const type = payload.slice(typeOpen + 2, typeClose).trim();
if (!name || !id || !type) return null;
return { name, id, type };
}
export function parseJellyfinLibrariesFromAppOutput(output: string): JellyfinLibraryEntry[] {
const libraries: JellyfinLibraryEntry[] = [];
const seenIds = new Set<string>();
for (const rawLine of output.split(/\r?\n/)) {
const line = stripAnsi(rawLine);
const markerIndex = line.indexOf('Jellyfin library:');
if (markerIndex < 0) continue;
const payload = line.slice(markerIndex + 'Jellyfin library:'.length).trim();
const parsed = parseNamedJellyfinRecord(payload);
if (!parsed || seenIds.has(parsed.id)) continue;
seenIds.add(parsed.id);
libraries.push({
id: parsed.id,
name: parsed.name,
kind: parsed.type,
});
}
return libraries;
}
export function parseJellyfinItemsFromAppOutput(output: string): JellyfinItemEntry[] {
const items: JellyfinItemEntry[] = [];
const seenIds = new Set<string>();
for (const rawLine of output.split(/\r?\n/)) {
const line = stripAnsi(rawLine);
const markerIndex = line.indexOf('Jellyfin item:');
if (markerIndex < 0) continue;
const payload = line.slice(markerIndex + 'Jellyfin item:'.length).trim();
const parsed = parseNamedJellyfinRecord(payload);
if (!parsed || seenIds.has(parsed.id)) continue;
seenIds.add(parsed.id);
items.push({
id: parsed.id,
name: parsed.name,
type: parsed.type,
display: parsed.name,
});
}
return items;
}
export function parseJellyfinErrorFromAppOutput(output: string): string {
const lines = output.split(/\r?\n/).map((line) => stripAnsi(line).trim());
for (let i = lines.length - 1; i >= 0; i -= 1) {
const line = lines[i];
if (!line) continue;
const bracketedErrorIndex = line.indexOf('[ERROR]');
if (bracketedErrorIndex >= 0) {
const message = line.slice(bracketedErrorIndex + '[ERROR]'.length).trim();
if (message.length > 0) return message;
}
const mainErrorIndex = line.indexOf(' - ERROR - ');
if (mainErrorIndex >= 0) {
const message = line.slice(mainErrorIndex + ' - ERROR - '.length).trim();
if (message.length > 0) return message;
}
if (line.includes('Missing Jellyfin session')) {
return 'Missing Jellyfin session. Run `subminer jellyfin -l` to log in again.';
}
}
return '';
}
type JellyfinPreviewAuthResponse = {
serverUrl: string;
accessToken: string;
userId: string;
};
export function parseJellyfinPreviewAuthResponse(raw: string): JellyfinPreviewAuthResponse | null {
if (!raw || raw.trim().length === 0) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
const candidate = parsed as Record<string, unknown>;
const serverUrl = sanitizeServerUrl(
typeof candidate.serverUrl === 'string' ? candidate.serverUrl : '',
);
const accessToken = typeof candidate.accessToken === 'string' ? candidate.accessToken.trim() : '';
const userId = typeof candidate.userId === 'string' ? candidate.userId.trim() : '';
if (!serverUrl || !accessToken) return null;
return {
serverUrl,
accessToken,
userId,
};
}
export function shouldRetryWithStartForNoRunningInstance(errorMessage: string): boolean {
return errorMessage.includes('No running instance. Use --start to launch the app.');
}
export function deriveJellyfinTokenStorePath(configPath: string): string {
return path.join(path.dirname(configPath), 'jellyfin-token-store.json');
}
export function hasStoredJellyfinSession(
configPath: string,
exists: (candidate: string) => boolean = fs.existsSync,
): boolean {
return exists(deriveJellyfinTokenStorePath(configPath));
}
export function readUtf8FileAppendedSince(logPath: string, offsetBytes: number): string {
try {
const buffer = fs.readFileSync(logPath);
if (buffer.length === 0) return '';
const normalizedOffset =
Number.isFinite(offsetBytes) && offsetBytes >= 0 ? Math.floor(offsetBytes) : 0;
const startOffset = normalizedOffset > buffer.length ? 0 : normalizedOffset;
return buffer.subarray(startOffset).toString('utf8');
} catch {
return '';
}
}
export function parseEpisodePathFromDisplay(
display: string,
): { seriesName: string; seasonNumber: number } | null {
const normalized = display.trim().replace(/\s+/g, ' ');
const match = normalized.match(/^(.*?)\s+S(\d{1,2})E\d{1,3}\b/i);
if (!match) return null;
const seriesName = match[1].trim();
const seasonNumber = Number.parseInt(match[2], 10);
if (!seriesName || !Number.isFinite(seasonNumber) || seasonNumber < 0) return null;
return { seriesName, seasonNumber };
}
function normalizeJellyfinType(type: string): string {
return type.trim().toLowerCase();
}
export function isJellyfinPlayableType(type: string): boolean {
const normalizedType = normalizeJellyfinType(type);
return (
normalizedType === 'movie' ||
normalizedType === 'episode' ||
normalizedType === 'audio' ||
normalizedType === 'video' ||
normalizedType === 'musicvideo'
);
}
export function isJellyfinContainerType(type: string): boolean {
const normalizedType = normalizeJellyfinType(type);
return (
normalizedType === 'series' ||
normalizedType === 'season' ||
normalizedType === 'folder' ||
normalizedType === 'collectionfolder'
);
}
function isJellyfinRootSearchType(type: string): boolean {
const normalizedType = normalizeJellyfinType(type);
return (
isJellyfinContainerType(normalizedType) ||
normalizedType === 'movie' ||
normalizedType === 'video' ||
normalizedType === 'musicvideo'
);
}
export function buildRootSearchGroups(items: JellyfinItemEntry[]): JellyfinGroupEntry[] {
const seenIds = new Set<string>();
const groups: JellyfinGroupEntry[] = [];
for (const item of items) {
if (!item.id || seenIds.has(item.id) || !isJellyfinRootSearchType(item.type)) continue;
seenIds.add(item.id);
groups.push({
id: item.id,
name: item.name,
type: item.type,
display: `${item.name} (${item.type})`,
});
}
return groups;
}
export type JellyfinChildSelection =
| { kind: 'playable'; id: string }
| { kind: 'container'; id: string };
export function classifyJellyfinChildSelection(
selectedChild: Pick<JellyfinGroupEntry, 'id' | 'type'>,
): JellyfinChildSelection {
if (isJellyfinPlayableType(selectedChild.type)) {
return { kind: 'playable', id: selectedChild.id };
}
if (isJellyfinContainerType(selectedChild.type)) {
return { kind: 'container', id: selectedChild.id };
}
fail('Selected Jellyfin item is not playable.');
}
async function runAppJellyfinListCommand(
appPath: string,
args: Args,
appArgs: string[],
label: string,
): Promise<string> {
const attempt = await runAppJellyfinCommand(appPath, args, appArgs, label);
if (attempt.status !== 0) {
const message = attempt.output.trim();
fail(message || `${label} failed.`);
}
if (attempt.error) {
fail(attempt.error);
}
return attempt.output;
}
async function runAppJellyfinCommand(
appPath: string,
args: Args,
appArgs: string[],
label: string,
): Promise<{ status: number; output: string; error: string; logOffset: number }> {
const forwardedBase = [...appArgs];
const serverOverride = sanitizeServerUrl(args.jellyfinServer || '');
if (serverOverride) {
forwardedBase.push('--jellyfin-server', serverOverride);
}
if (args.passwordStore) {
forwardedBase.push('--password-store', args.passwordStore);
}
const readLogAppendedSince = (offset: number): string => {
const logPath = getMpvLogPath();
return readUtf8FileAppendedSince(logPath, offset);
};
const hasCommandSignal = (output: string): boolean => {
if (label === 'jellyfin-libraries') {
return (
output.includes('Jellyfin library:') || output.includes('No Jellyfin libraries found.')
);
}
if (label === 'jellyfin-items') {
return (
output.includes('Jellyfin item:') ||
output.includes('No Jellyfin items found for the selected library/search.')
);
}
if (label === 'jellyfin-preview-auth') {
return output.includes('Jellyfin preview auth written.');
}
return output.trim().length > 0;
};
const runOnce = (): { status: number; output: string; error: string; logOffset: number } => {
const forwarded = [...forwardedBase];
const logPath = getMpvLogPath();
let logOffset = 0;
try {
if (fs.existsSync(logPath)) {
logOffset = fs.statSync(logPath).size;
}
} catch {
logOffset = 0;
}
log('debug', args.logLevel, `${label}: launching app with args: ${forwarded.join(' ')}`);
const result = runAppCommandCaptureOutput(appPath, forwarded);
log('debug', args.logLevel, `${label}: app command exited with status ${result.status}`);
let output = `${result.stdout || ''}\n${result.stderr || ''}\n${readLogAppendedSince(logOffset)}`;
let error = parseJellyfinErrorFromAppOutput(output);
return { status: result.status, output, error, logOffset };
};
let retriedAfterStart = false;
let attempt = runOnce();
if (shouldRetryWithStartForNoRunningInstance(attempt.error)) {
log('debug', args.logLevel, `${label}: starting app detached, then retrying command`);
launchAppStartDetached(appPath, args.logLevel);
await sleep(1000);
retriedAfterStart = true;
attempt = runOnce();
}
if (attempt.status === 0 && !attempt.error && !hasCommandSignal(attempt.output)) {
// When app is already running, command handling happens in the primary process and log
// lines can land slightly after the helper process exits.
const settleWindowMs = (() => {
if (label === 'jellyfin-items') {
return retriedAfterStart ? 45000 : 30000;
}
return retriedAfterStart ? 12000 : 4000;
})();
const settleDeadline = Date.now() + settleWindowMs;
const settleOffset = attempt.logOffset;
while (Date.now() < settleDeadline) {
await sleep(100);
const settledOutput = readLogAppendedSince(settleOffset);
if (!settledOutput.trim()) {
continue;
}
attempt.output = `${attempt.output}\n${settledOutput}`;
attempt.error = parseJellyfinErrorFromAppOutput(attempt.output);
if (attempt.error || hasCommandSignal(attempt.output)) {
break;
}
}
}
return attempt;
}
async function requestJellyfinPreviewAuthFromApp(
appPath: string,
args: Args,
): Promise<JellyfinPreviewAuthResponse | null> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jf-preview-auth-'));
const responsePath = path.join(tmpDir, 'response.json');
try {
const attempt = await runAppJellyfinCommand(
appPath,
args,
['--jellyfin-preview-auth', `--jellyfin-response-path=${responsePath}`],
'jellyfin-preview-auth',
);
if (attempt.status !== 0 || attempt.error) {
return null;
}
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
try {
if (fs.existsSync(responsePath)) {
const raw = fs.readFileSync(responsePath, 'utf8');
const parsed = parseJellyfinPreviewAuthResponse(raw);
if (parsed) {
return parsed;
}
}
} catch {
// retry until timeout
}
await sleep(100);
}
return null;
} finally {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore cleanup failures
}
}
}
async function resolveJellyfinSelectionViaApp(
appPath: string,
args: Args,
session: JellyfinSessionConfig,
themePath: string | null = null,
): Promise<string> {
const listLibrariesOutput = await runAppJellyfinListCommand(
appPath,
args,
['--jellyfin-libraries'],
'jellyfin-libraries',
);
const libraries = parseJellyfinLibrariesFromAppOutput(listLibrariesOutput);
if (libraries.length === 0) {
fail('No Jellyfin libraries found.');
}
const iconlessSession: JellyfinSessionConfig = {
...session,
userId: session.userId || 'launcher',
};
const noIcon = (): string | null => null;
const hasPreviewSession = Boolean(iconlessSession.serverUrl && iconlessSession.accessToken);
const pickerSession: JellyfinSessionConfig = {
...iconlessSession,
pullPictures: hasPreviewSession && iconlessSession.pullPictures === true,
};
const ensureIconForPicker = hasPreviewSession ? ensureJellyfinIcon : noIcon;
if (!hasPreviewSession) {
log(
'debug',
args.logLevel,
'Jellyfin picker image previews disabled (no launcher-accessible Jellyfin token).',
);
}
const configuredDefaultLibraryId = session.defaultLibraryId;
const hasConfiguredDefault = libraries.some(
(library) => library.id === configuredDefaultLibraryId,
);
let libraryId = hasConfiguredDefault ? configuredDefaultLibraryId : '';
if (!libraryId) {
libraryId = pickLibrary(
pickerSession,
libraries,
args.useRofi,
ensureIconForPicker,
'',
themePath,
);
if (!libraryId) fail('No Jellyfin library selected.');
}
const searchTerm = await promptOptionalJellyfinSearch(args.useRofi, themePath);
const normalizedSearch = searchTerm.trim();
const searchLimit = 400;
const browseLimit = 2500;
const rootIncludeItemTypes = 'Series,Season,Folder,CollectionFolder,Movie,Video,MusicVideo';
const directoryIncludeItemTypes =
'Series,Season,Folder,CollectionFolder,Movie,Episode,Audio,Video,MusicVideo';
const recursivePlayableIncludeItemTypes = 'Movie,Episode,Audio,Video,MusicVideo';
const listItemsViaApp = async (
parentId: string,
options: {
search?: string;
limit: number;
recursive?: boolean;
includeItemTypes?: string;
},
): Promise<JellyfinItemEntry[]> => {
const itemArgs = [
'--jellyfin-items',
`--jellyfin-library-id=${parentId}`,
`--jellyfin-limit=${Math.max(1, options.limit)}`,
];
const normalized = (options.search || '').trim();
if (normalized.length > 0) {
itemArgs.push(`--jellyfin-search=${normalized}`);
}
if (typeof options.recursive === 'boolean') {
itemArgs.push(`--jellyfin-recursive=${options.recursive ? 'true' : 'false'}`);
}
const includeItemTypes = options.includeItemTypes?.trim();
if (includeItemTypes) {
itemArgs.push(`--jellyfin-include-item-types=${includeItemTypes}`);
}
const output = await runAppJellyfinListCommand(appPath, args, itemArgs, 'jellyfin-items');
return parseJellyfinItemsFromAppOutput(output);
};
let rootItems =
normalizedSearch.length > 0
? await listItemsViaApp(libraryId, {
search: normalizedSearch,
limit: searchLimit,
recursive: true,
includeItemTypes: rootIncludeItemTypes,
})
: await listItemsViaApp(libraryId, {
limit: browseLimit,
recursive: false,
includeItemTypes: rootIncludeItemTypes,
});
if (normalizedSearch.length > 0 && rootItems.length === 0) {
// Compatibility fallback for older app binaries that may ignore custom search include types.
log(
'debug',
args.logLevel,
`jellyfin-items: no direct search hits for "${normalizedSearch}", falling back to unfiltered library query`,
);
rootItems = await listItemsViaApp(libraryId, {
limit: browseLimit,
recursive: false,
includeItemTypes: rootIncludeItemTypes,
});
}
const rootGroups = buildRootSearchGroups(rootItems);
if (rootGroups.length === 0) {
fail('No Jellyfin shows or movies found.');
}
const rootById = new Map(rootGroups.map((group) => [group.id, group]));
const selectedRootId = pickGroup(
pickerSession,
rootGroups,
args.useRofi,
ensureIconForPicker,
normalizedSearch,
themePath,
);
if (!selectedRootId) fail('No Jellyfin show/movie selected.');
const selectedRoot = rootById.get(selectedRootId);
if (!selectedRoot) fail('Invalid Jellyfin root selection.');
if (isJellyfinPlayableType(selectedRoot.type)) {
return selectedRoot.id;
}
const pickPlayableDescendants = async (parentId: string): Promise<string> => {
const descendantItems = await listItemsViaApp(parentId, {
limit: browseLimit,
recursive: true,
includeItemTypes: recursivePlayableIncludeItemTypes,
});
const playableItems = descendantItems.filter((item) => isJellyfinPlayableType(item.type));
if (playableItems.length === 0) {
fail('No playable Jellyfin items found.');
}
const selectedItemId = pickItem(
pickerSession,
playableItems,
args.useRofi,
ensureIconForPicker,
'',
themePath,
);
if (!selectedItemId) {
fail('No Jellyfin item selected.');
}
return selectedItemId;
};
let currentContainerId = selectedRoot.id;
while (true) {
const directoryEntries = await listItemsViaApp(currentContainerId, {
limit: browseLimit,
recursive: false,
includeItemTypes: directoryIncludeItemTypes,
});
const seenIds = new Set<string>();
const childGroups: JellyfinGroupEntry[] = [];
for (const item of directoryEntries) {
if (!item.id || seenIds.has(item.id)) continue;
if (!isJellyfinContainerType(item.type) && !isJellyfinPlayableType(item.type)) continue;
seenIds.add(item.id);
childGroups.push({
id: item.id,
name: item.name,
type: item.type,
display: `${item.name} (${item.type})`,
});
}
if (childGroups.length === 0) {
return await pickPlayableDescendants(currentContainerId);
}
const childById = new Map(childGroups.map((group) => [group.id, group]));
const selectedChildId = pickGroup(
pickerSession,
childGroups,
args.useRofi,
ensureIconForPicker,
'',
themePath,
);
if (!selectedChildId) fail('No Jellyfin folder/file selected.');
const selectedChild = childById.get(selectedChildId);
if (!selectedChild) fail('Invalid Jellyfin item selection.');
const selection = classifyJellyfinChildSelection(selectedChild);
if (selection.kind === 'playable') {
return selection.id;
}
currentContainerId = selection.id;
}
}
export async function resolveJellyfinSelection( export async function resolveJellyfinSelection(
args: Args, args: Args,
session: JellyfinSessionConfig, session: JellyfinSessionConfig,
@@ -367,18 +973,37 @@ export async function runJellyfinPlayMenu(
iconCacheDir: config.iconCacheDir || '', iconCacheDir: config.iconCacheDir || '',
}; };
if (!session.serverUrl || !session.accessToken || !session.userId) {
fail(
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
);
}
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null; const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
if (args.useRofi && !rofiTheme) { if (args.useRofi && !rofiTheme) {
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.'); log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
} }
const itemId = await resolveJellyfinSelection(args, session, rofiTheme); const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
let itemId = '';
if (hasDirectSession) {
itemId = await resolveJellyfinSelection(args, session, rofiTheme);
} else {
const configPath = resolveLauncherMainConfigPath();
if (!hasStoredJellyfinSession(configPath)) {
fail(
'Missing Jellyfin session. Run `subminer jellyfin -l --server <url> --username <user> --password <pass>` first.',
);
}
const previewAuth = await requestJellyfinPreviewAuthFromApp(appPath, args);
if (previewAuth) {
session.serverUrl = previewAuth.serverUrl || session.serverUrl;
session.accessToken = previewAuth.accessToken;
session.userId = previewAuth.userId || session.userId;
log('debug', args.logLevel, 'Jellyfin preview auth bridge ready for picker image previews.');
} else {
log(
'debug',
args.logLevel,
'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.',
);
}
itemId = await resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
}
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`); log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`); log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
let mpvReady = false; let mpvReady = false;
@@ -393,7 +1018,7 @@ export async function runJellyfinPlayMenu(
if (!mpvReady) { if (!mpvReady) {
fail(`MPV IPC socket not ready: ${mpvSocketPath}`); fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
} }
const forwarded = ['--start', '--jellyfin-play', '--jellyfin-item-id', itemId]; const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore); if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play'); runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');

View File

@@ -5,6 +5,19 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { resolveConfigFilePath } from '../src/config/path-resolution.js'; import { resolveConfigFilePath } from '../src/config/path-resolution.js';
import {
parseJellyfinLibrariesFromAppOutput,
parseJellyfinItemsFromAppOutput,
parseJellyfinErrorFromAppOutput,
parseJellyfinPreviewAuthResponse,
deriveJellyfinTokenStorePath,
hasStoredJellyfinSession,
shouldRetryWithStartForNoRunningInstance,
readUtf8FileAppendedSince,
parseEpisodePathFromDisplay,
buildRootSearchGroups,
classifyJellyfinChildSelection,
} from './jellyfin.js';
type RunResult = { type RunResult = {
status: number | null; status: number | null;
@@ -22,10 +35,14 @@ 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(process.execPath, ['run', path.join(process.cwd(), 'launcher/main.ts'), ...argv], { const result = spawnSync(
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 || '',
@@ -145,7 +162,7 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
}); });
}); });
test('jellyfin discovery routes to app --start with log-level forwarding', () => { test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
withTempDir((root) => { withTempDir((root) => {
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg'); const xdgConfigHome = path.join(root, 'xdg');
@@ -165,7 +182,37 @@ test('jellyfin discovery routes to app --start with log-level forwarding', () =>
const result = runLauncher(['jellyfin', 'discovery', '--log-level', 'debug'], env); const result = runLauncher(['jellyfin', 'discovery', '--log-level', 'debug'], env);
assert.equal(result.status, 0); assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--start\n--log-level\ndebug\n'); assert.equal(
fs.readFileSync(capturePath, 'utf8'),
'--background\n--jellyfin-remote-announce\n--log-level\ndebug\n',
);
});
});
test('jellyfin discovery via jf alias forwards remote announce for cast visibility', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['-R', 'jf', '--discovery', '--log-level', 'debug'], env);
assert.equal(result.status, 0);
assert.equal(
fs.readFileSync(capturePath, 'utf8'),
'--background\n--jellyfin-remote-announce\n--log-level\ndebug\n',
);
}); });
}); });
@@ -225,10 +272,7 @@ 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( const result = runLauncher(['jf', 'setup', '--password-store', 'gnome-libsecret'], env);
['jf', 'setup', '--password-store', 'gnome-libsecret'],
env,
);
assert.equal(result.status, 0); assert.equal(result.status, 0);
assert.equal( assert.equal(
@@ -237,3 +281,182 @@ test('jellyfin setup forwards password-store to app command', () => {
); );
}); });
}); });
test('parseJellyfinLibrariesFromAppOutput parses prefixed library lines', () => {
const parsed = parseJellyfinLibrariesFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin library: Anime [lib1] (tvshows)
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin library: Movies [lib2] (movies)
`);
assert.deepEqual(parsed, [
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
{ id: 'lib2', name: 'Movies', kind: 'movies' },
]);
});
test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
const parsed = parseJellyfinItemsFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin item: Solo Leveling S01E10 [item-10] (Episode)
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin item: Movie [Alt] [movie-1] (Movie)
`);
assert.deepEqual(parsed, [
{
id: 'item-10',
name: 'Solo Leveling S01E10',
type: 'Episode',
display: 'Solo Leveling S01E10',
},
{
id: 'movie-1',
name: 'Movie [Alt]',
type: 'Movie',
display: 'Movie [Alt]',
},
]);
});
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
const parsed = parseJellyfinErrorFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.
`);
assert.equal(
parsed,
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
);
});
test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => {
const parsed = parseJellyfinErrorFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}
`);
assert.equal(
parsed,
'[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}',
);
});
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
const parsed = parseJellyfinPreviewAuthResponse(
JSON.stringify({
serverUrl: 'http://pve-main:8096/',
accessToken: 'token-123',
userId: 'user-1',
}),
);
assert.deepEqual(parsed, {
serverUrl: 'http://pve-main:8096',
accessToken: 'token-123',
userId: 'user-1',
});
});
test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => {
assert.equal(parseJellyfinPreviewAuthResponse(''), null);
assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null);
assert.equal(
parseJellyfinPreviewAuthResponse(
JSON.stringify({
serverUrl: 'http://pve-main:8096',
accessToken: '',
userId: 'user-1',
}),
),
null,
);
});
test('deriveJellyfinTokenStorePath resolves alongside config path', () => {
const tokenPath = deriveJellyfinTokenStorePath('/home/test/.config/SubMiner/config.jsonc');
assert.equal(tokenPath, '/home/test/.config/SubMiner/jellyfin-token-store.json');
});
test('hasStoredJellyfinSession checks token-store existence', () => {
const exists = (candidate: string): boolean =>
candidate === '/home/test/.config/SubMiner/jellyfin-token-store.json';
assert.equal(hasStoredJellyfinSession('/home/test/.config/SubMiner/config.jsonc', exists), true);
assert.equal(hasStoredJellyfinSession('/home/test/.config/Other/alt.jsonc', exists), false);
});
test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => {
assert.equal(
shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'),
true,
);
assert.equal(
shouldRetryWithStartForNoRunningInstance(
'Missing Jellyfin session. Run --jellyfin-login first.',
),
false,
);
});
test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte logs', () => {
withTempDir((root) => {
const logPath = path.join(root, 'SubMiner.log');
const prefix = '[subminer] こんにちは\n';
const suffix = '[subminer] Jellyfin library: Movies [lib2] (movies)\n';
fs.writeFileSync(logPath, `${prefix}${suffix}`, 'utf8');
const byteOffset = Buffer.byteLength(prefix, 'utf8');
const fromByteOffset = readUtf8FileAppendedSince(logPath, byteOffset);
assert.match(fromByteOffset, /Jellyfin library: Movies \[lib2\] \(movies\)/);
const fromBeyondEnd = readUtf8FileAppendedSince(logPath, byteOffset + 9999);
assert.match(fromBeyondEnd, /Jellyfin library: Movies \[lib2\] \(movies\)/);
});
});
test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => {
assert.deepEqual(
parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'),
{
seriesName: 'KONOSUBA',
seasonNumber: 1,
},
);
assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), {
seriesName: 'Frieren',
seasonNumber: 2,
});
});
test('parseEpisodePathFromDisplay returns null for non-episode displays', () => {
assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null);
assert.equal(parseEpisodePathFromDisplay('Just A Name'), null);
});
test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => {
const groups = buildRootSearchGroups([
{ id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' },
{ id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' },
{ id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' },
]);
assert.deepEqual(groups, [
{
id: 'series-1',
name: 'The Eminence in Shadow',
type: 'Series',
display: 'The Eminence in Shadow (Series)',
},
{
id: 'movie-1',
name: 'Spirited Away',
type: 'Movie',
display: 'Spirited Away (Movie)',
},
]);
});
test('classifyJellyfinChildSelection keeps container drilldown state instead of flattening', () => {
const next = classifyJellyfinChildSelection({ id: 'season-2', type: 'Season' });
assert.deepEqual(next, {
kind: 'container',
id: 'season-2',
});
});

View File

@@ -19,14 +19,15 @@ import { runPlaybackCommand } from './commands/playback-command.js';
function createCommandContext( function createCommandContext(
args: ReturnType<typeof parseArgs>, args: ReturnType<typeof parseArgs>,
scriptPath: string, scriptPath: string,
mpvSocketPath: string, pluginRuntimeConfig: ReturnType<typeof readPluginRuntimeConfig>,
appPath: string | null, appPath: string | null,
): LauncherCommandContext { ): LauncherCommandContext {
return { return {
args, args,
scriptPath, scriptPath,
scriptName: path.basename(scriptPath), scriptName: path.basename(scriptPath),
mpvSocketPath, mpvSocketPath: pluginRuntimeConfig.socketPath,
pluginRuntimeConfig,
appPath, appPath,
launcherJellyfinConfig: loadLauncherJellyfinConfig(), launcherJellyfinConfig: loadLauncherJellyfinConfig(),
processAdapter: nodeProcessAdapter, processAdapter: nodeProcessAdapter,
@@ -55,7 +56,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.socketPath, appPath); const context = createCommandContext(args, scriptPath, pluginRuntimeConfig, appPath);
if (runDoctorCommand(context)) { if (runDoctorCommand(context)) {
return; return;
@@ -71,6 +72,7 @@ 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

@@ -5,7 +5,7 @@ import path from 'node:path';
import net from 'node:net'; import net from 'node:net';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import type { Args } from './types'; import type { Args } from './types';
import { startOverlay, state, waitForUnixSocketReady } from './mpv'; import { runAppCommandCaptureOutput, startOverlay, state, waitForUnixSocketReady } from './mpv';
import * as mpvModule from './mpv'; import * as mpvModule from './mpv';
function createTempSocketPath(): { dir: string; socketPath: string } { function createTempSocketPath(): { dir: string; socketPath: string } {
@@ -19,6 +19,18 @@ test('mpv module exposes only canonical socket readiness helper', () => {
assert.equal('waitForSocket' in mpvModule, false); assert.equal('waitForSocket' in mpvModule, false);
}); });
test('runAppCommandCaptureOutput captures status and stdio', () => {
const result = runAppCommandCaptureOutput(process.execPath, [
'-e',
'process.stdout.write("stdout-line"); process.stderr.write("stderr-line");',
]);
assert.equal(result.status, 0);
assert.equal(result.stdout, 'stdout-line');
assert.equal(result.stderr, 'stderr-line');
assert.equal(result.error, undefined);
});
test('waitForUnixSocketReady returns false when socket never appears', async () => { test('waitForUnixSocketReady returns false when socket never appears', async () => {
const { dir, socketPath } = createTempSocketPath(); const { dir, socketPath } = createTempSocketPath();
try { try {

View File

@@ -6,7 +6,7 @@ import { spawn, spawnSync } from 'node:child_process';
import type { LogLevel, Backend, Args, MpvTrack } from './types.js'; import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js'; import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
import { log, fail, getMpvLogPath } from './log.js'; import { log, fail, getMpvLogPath } from './log.js';
import { buildSubminerScriptOpts, inferAniSkipMetadataForFile } from './aniskip-metadata.js'; import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
import { import {
commandExists, commandExists,
isExecutable, isExecutable,
@@ -419,13 +419,14 @@ export async function loadSubtitleIntoMpv(
} }
} }
export function startMpv( export async function startMpv(
target: string, target: string,
targetKind: 'file' | 'url', targetKind: 'file' | 'url',
args: Args, args: Args,
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}`);
@@ -475,8 +476,11 @@ export function startMpv(
if (preloadedSubtitles?.secondaryPath) { if (preloadedSubtitles?.secondaryPath) {
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`); mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
} }
if (options?.startPaused) {
mpvArgs.push('--pause=yes');
}
const aniSkipMetadata = const aniSkipMetadata =
targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null; targetKind === 'file' ? await resolveAniSkipMetadataForFile(target) : null;
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata); const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
if (aniSkipMetadata) { if (aniSkipMetadata) {
log( log(
@@ -655,6 +659,28 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): ne
process.exit(result.status ?? 0); process.exit(result.status ?? 0);
} }
export function runAppCommandCaptureOutput(
appPath: string,
appArgs: string[],
): {
status: number;
stdout: string;
stderr: string;
error?: Error;
} {
const result = spawnSync(appPath, appArgs, {
env: buildAppEnv(),
encoding: 'utf8',
});
return {
status: result.status ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
error: result.error ?? undefined,
};
}
export function runAppCommandWithInheritLogged( export function runAppCommandWithInheritLogged(
appPath: string, appPath: string,
appArgs: string[], appArgs: string[],

View File

@@ -31,11 +31,7 @@ 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( const parsed = parseArgs(['jf', 'setup', '--password-store', 'gnome-libsecret'], 'subminer', {});
['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,3 +319,43 @@ 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,6 +129,9 @@ 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.1.2", "version": "0.3.0",
"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,6 +12,7 @@
"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 .",
@@ -22,8 +23,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/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/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/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/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/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/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/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/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/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/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,6 +28,10 @@ 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
@@ -49,6 +53,9 @@ aniskip_mal_id=
# Force episode number (optional). Leave blank for filename/title detection. # Force episode number (optional). Leave blank for filename/title detection.
aniskip_episode= aniskip_episode=
# Optional pre-fetched AniSkip payload for this media (JSON or base64 JSON). When set, the plugin uses this directly and skips network lookup.
aniskip_payload=
# Show intro skip OSD button while inside OP range. # Show intro skip OSD button while inside OP range.
aniskip_show_button=yes aniskip_show_button=yes

View File

@@ -13,6 +13,12 @@ function M.create(ctx)
local mal_lookup_cache = {} local mal_lookup_cache = {}
local payload_cache = {} local payload_cache = {}
local title_context_cache = {} local title_context_cache = {}
local base64_reverse = {}
local base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
for i = 1, #base64_chars do
base64_reverse[base64_chars:sub(i, i)] = i - 1
end
local function url_encode(text) local function url_encode(text)
if type(text) ~= "string" then if type(text) ~= "string" then
@@ -25,6 +31,109 @@ function M.create(ctx)
return encoded:gsub(" ", "%%20") return encoded:gsub(" ", "%%20")
end end
local function parse_json_payload(text)
if type(text) ~= "string" then
return nil
end
local parsed, parse_error = utils.parse_json(text)
if type(parsed) == "table" then
return parsed
end
return nil, parse_error
end
local function decode_base64(input)
if type(input) ~= "string" then
return nil
end
local cleaned = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/")
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
if cleaned == "" then
return nil
end
if #cleaned % 4 == 1 then
return nil
end
if #cleaned % 4 ~= 0 then
cleaned = cleaned .. string.rep("=", 4 - (#cleaned % 4))
end
if not cleaned:match("^[A-Za-z0-9+/%=]+$") then
return nil
end
local out = {}
local out_len = 0
for index = 1, #cleaned, 4 do
local c1 = cleaned:sub(index, index)
local c2 = cleaned:sub(index + 1, index + 1)
local c3 = cleaned:sub(index + 2, index + 2)
local c4 = cleaned:sub(index + 3, index + 3)
local v1 = base64_reverse[c1]
local v2 = base64_reverse[c2]
if not v1 or not v2 then
return nil
end
local v3 = c3 == "=" and 0 or base64_reverse[c3]
local v4 = c4 == "=" and 0 or base64_reverse[c4]
if (c3 ~= "=" and not v3) or (c4 ~= "=" and not v4) then
return nil
end
local n = (((v1 * 64 + v2) * 64 + v3) * 64 + v4)
local b1 = math.floor(n / 65536)
local remaining = n % 65536
local b2 = math.floor(remaining / 256)
local b3 = remaining % 256
out_len = out_len + 1
out[out_len] = string.char(b1)
if c3 ~= "=" then
out_len = out_len + 1
out[out_len] = string.char(b2)
end
if c4 ~= "=" then
out_len = out_len + 1
out[out_len] = string.char(b3)
end
end
return table.concat(out)
end
local function resolve_launcher_payload()
local raw_payload = type(opts.aniskip_payload) == "string" and opts.aniskip_payload or ""
local trimmed = raw_payload:match("^%s*(.-)%s*$") or ""
if trimmed == "" then
return nil
end
local parsed, parse_error = parse_json_payload(trimmed)
if type(parsed) == "table" then
return parsed
end
local url_decoded = trimmed:gsub("%%(%x%x)", function(hex)
local value = tonumber(hex, 16)
if value then
return string.char(value)
end
return "%"
end)
if url_decoded ~= trimmed then
parsed, parse_error = parse_json_payload(url_decoded)
if type(parsed) == "table" then
return parsed
end
end
local b64_decoded = decode_base64(trimmed)
if type(b64_decoded) == "string" and b64_decoded ~= "" then
parsed, parse_error = parse_json_payload(b64_decoded)
if type(parsed) == "table" then
return parsed
end
end
subminer_log("warn", "aniskip", "Invalid launcher AniSkip payload: " .. tostring(parse_error or "unparseable"))
return nil
end
local function run_json_curl_async(url, callback) local function run_json_curl_async(url, callback)
mp.command_native_async({ mp.command_native_async({
name = "subprocess", name = "subprocess",
@@ -296,6 +405,8 @@ function M.create(ctx)
state.aniskip.episode = nil state.aniskip.episode = nil
state.aniskip.intro_start = nil state.aniskip.intro_start = nil
state.aniskip.intro_end = nil state.aniskip.intro_end = nil
state.aniskip.payload = nil
state.aniskip.payload_source = nil
remove_aniskip_chapters() remove_aniskip_chapters()
end end
@@ -366,7 +477,17 @@ function M.create(ctx)
state.aniskip.intro_end = intro_end state.aniskip.intro_end = intro_end
state.aniskip.prompt_shown = false state.aniskip.prompt_shown = false
set_intro_chapters(intro_start, intro_end) set_intro_chapters(intro_start, intro_end)
subminer_log("info", "aniskip", string.format("Intro window %.3f -> %.3f (MAL %d, ep %d)", intro_start, intro_end, mal_id, episode)) subminer_log(
"info",
"aniskip",
string.format(
"Intro window %.3f -> %.3f (MAL %s, ep %s)",
intro_start,
intro_end,
tostring(mal_id or "-"),
tostring(episode or "-")
)
)
return true return true
end end
end end
@@ -374,6 +495,10 @@ function M.create(ctx)
return false return false
end end
local function has_launcher_payload()
return type(opts.aniskip_payload) == "string" and opts.aniskip_payload:match("%S") ~= nil
end
local function is_launcher_context() local function is_launcher_context()
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or "" local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
if forced_title ~= "" then if forced_title ~= "" then
@@ -391,6 +516,9 @@ function M.create(ctx)
if forced_season and forced_season > 0 then if forced_season and forced_season > 0 then
return true return true
end end
if has_launcher_payload() then
return true
end
return false return false
end end
@@ -500,6 +628,18 @@ function M.create(ctx)
end) end)
end end
local function fetch_payload_from_launcher(payload, mal_id, title, episode)
if not payload then
return false
end
state.aniskip.payload = payload
state.aniskip.payload_source = "launcher"
state.aniskip.mal_id = mal_id
state.aniskip.title = title
state.aniskip.episode = episode
return apply_aniskip_payload(mal_id, title, episode, payload)
end
local function fetch_aniskip_for_current_media(trigger_source) local function fetch_aniskip_for_current_media(trigger_source)
local trigger = type(trigger_source) == "string" and trigger_source or "manual" local trigger = type(trigger_source) == "string" and trigger_source or "manual"
if not opts.aniskip_enabled then if not opts.aniskip_enabled then
@@ -518,6 +658,28 @@ function M.create(ctx)
reset_aniskip_fields() reset_aniskip_fields()
local title, episode, season = resolve_title_and_episode() local title, episode, season = resolve_title_and_episode()
local lookup_titles = resolve_lookup_titles(title) local lookup_titles = resolve_lookup_titles(title)
local launcher_payload = resolve_launcher_payload()
if launcher_payload then
local launcher_mal_id = tonumber(opts.aniskip_mal_id)
if not launcher_mal_id then
launcher_mal_id = nil
end
if fetch_payload_from_launcher(launcher_payload, launcher_mal_id, title, episode) then
subminer_log(
"info",
"aniskip",
string.format(
"Using launcher-provided AniSkip payload (title=%s, season=%s, episode=%s)",
tostring(title or ""),
tostring(season or "-"),
tostring(episode or "-")
)
)
return
end
subminer_log("info", "aniskip", "Launcher payload present but no OP interval was available")
return
end
subminer_log( subminer_log(
"info", "info",
@@ -558,6 +720,8 @@ function M.create(ctx)
end end
return return
end end
state.aniskip.payload = payload
state.aniskip.payload_source = "remote"
if not apply_aniskip_payload(mal_id, title, episode, payload) then if not apply_aniskip_payload(mal_id, title, episode, payload) then
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval") subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
end end

View File

@@ -249,36 +249,22 @@ function M.create(ctx)
raw_close_idx = #raw_ass + 1 raw_close_idx = #raw_ass + 1
end end
local open_tag = string.format("{\\1c&H%s&}", hover_color) local before = raw_ass:sub(1, raw_open_idx - 1)
local close_tag = string.format("{\\1c&H%s&}", base_color) local hovered = raw_ass:sub(raw_open_idx, raw_close_idx - 1)
local changes = { local after = raw_ass:sub(raw_close_idx)
{ idx = raw_open_idx, tag = open_tag }, local hover_suffix = string.format("\\1c&H%s&", hover_color)
{ idx = raw_close_idx, tag = close_tag },
} -- Keep hover foreground stable even when inline ASS override tags (\1c/\c/\r) appear inside token.
table.sort(changes, function(a, b) hovered = hovered:gsub("{([^}]*)}", function(inner)
return a.idx < b.idx 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) end)
local output = {} local open_tag = string.format("{\\1c&H%s&}", hover_color)
local cursor = 1 local close_tag = string.format("{\\1c&H%s&}", base_color)
for _, change in ipairs(changes) do return before .. open_tag .. hovered .. close_tag .. after
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,6 +31,7 @@ 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
@@ -59,6 +60,7 @@ 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...")
@@ -73,6 +75,7 @@ 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,7 +45,14 @@ function M.create(ctx)
local function show_osd(message) local function show_osd(message)
if opts.osd_messages then if opts.osd_messages then
mp.osd_message("SubMiner: " .. message, 3) local payload = "SubMiner: " .. message
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,6 +29,9 @@ 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,7 +8,8 @@ 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 = false, auto_start_visible_overlay = true,
auto_start_pause_until_ready = true,
osd_messages = true, osd_messages = true,
log_level = "info", log_level = "info",
aniskip_enabled = true, aniskip_enabled = true,
@@ -16,6 +17,7 @@ function M.load(options_lib, default_socket_path)
aniskip_season = "", aniskip_season = "",
aniskip_mal_id = "", aniskip_mal_id = "",
aniskip_episode = "", aniskip_episode = "",
aniskip_payload = "",
aniskip_show_button = true, aniskip_show_button = true,
aniskip_button_text = "You can skip by pressing %s", aniskip_button_text = "You can skip by pressing %s",
aniskip_button_key = "y-k", aniskip_button_key = "y-k",

View File

@@ -2,6 +2,9 @@ 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
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
@@ -13,6 +16,7 @@ function M.create(ctx)
local subminer_log = ctx.log.subminer_log local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd local show_osd = ctx.log.show_osd
local normalize_log_level = ctx.log.normalize_log_level local normalize_log_level = ctx.log.normalize_log_level
local run_control_command_async
local function resolve_visible_overlay_startup() local function resolve_visible_overlay_startup()
local raw_visible_overlay = opts.auto_start_visible_overlay local raw_visible_overlay = opts.auto_start_visible_overlay
@@ -22,6 +26,14 @@ 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
@@ -53,6 +65,81 @@ 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 clear_auto_play_ready_osd_timer()
local timer = state.auto_play_ready_osd_timer
if timer and timer.kill then
timer:kill()
end
state.auto_play_ready_osd_timer = nil
end
local function disarm_auto_play_ready_gate(options)
local should_resume = options == nil or options.resume_playback ~= false
local was_armed = state.auto_play_ready_gate_armed
clear_auto_play_ready_timeout()
clear_auto_play_ready_osd_timer()
state.auto_play_ready_gate_armed = false
if was_armed and should_resume then
mp.set_property_native("pause", false)
end
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({ resume_playback = false })
mp.set_property_native("pause", false)
show_osd(AUTO_PLAY_READY_READY_OSD)
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()
clear_auto_play_ready_osd_timer()
end
state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true)
show_osd(AUTO_PLAY_READY_LOADING_OSD)
if type(mp.add_periodic_timer) == "function" then
state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function()
if state.auto_play_ready_gate_armed then
show_osd(AUTO_PLAY_READY_LOADING_OSD)
end
end)
end
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")
if state.overlay_running and resolve_visible_overlay_startup() then
run_control_command_async("show-visible-overlay", {
socket_path = opts.socket_path,
})
end
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 }
@@ -76,9 +163,6 @@ function M.create(ctx)
table.insert(args, socket_path) table.insert(args, socket_path)
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
@@ -89,7 +173,7 @@ function M.create(ctx)
return args return args
end end
local function run_control_command_async(action, overrides, callback) run_control_command_async = function(action, overrides, callback)
local args = build_command_args(action, overrides) local args = build_command_args(action, overrides)
subminer_log("debug", "process", "Control command: " .. table.concat(args, " ")) subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
mp.command_native_async({ mp.command_native_async({
@@ -182,6 +266,8 @@ 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")
@@ -189,16 +275,49 @@ 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")
local socket_path = overrides.socket_path or opts.socket_path
local should_pause_until_ready = (
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 visibility_action = resolve_visible_overlay_startup()
and "show-visible-overlay"
or "hide-visible-overlay"
run_control_command_async(visibility_action, {
socket_path = socket_path,
log_level = overrides.log_level,
})
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)
@@ -212,7 +331,7 @@ function M.create(ctx)
) )
end end
if attempt == 1 then if attempt == 1 and not state.auto_play_ready_gate_armed then
show_osd("Starting...") show_osd("Starting...")
end end
state.overlay_running = true state.overlay_running = true
@@ -236,9 +355,20 @@ 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, {
socket_path = socket_path,
log_level = overrides.log_level,
})
end
end) end)
end end
@@ -277,6 +407,7 @@ 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
@@ -326,6 +457,7 @@ 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")
@@ -384,6 +516,8 @@ 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

@@ -24,9 +24,14 @@ function M.new()
episode = nil, episode = nil,
intro_start = nil, intro_start = nil,
intro_end = nil, intro_end = nil,
payload = nil,
payload_source = nil,
found = false, found = false,
prompt_shown = false, prompt_shown = false,
}, },
auto_play_ready_gate_armed = false,
auto_play_ready_timeout = nil,
auto_play_ready_osd_timer = nil,
} }
end end

View File

@@ -11,11 +11,12 @@ 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
@@ -25,6 +26,7 @@ USAGE
} }
force=0 force=0
generate_webp=0
input="" input=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -36,6 +38,9 @@ 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
@@ -74,7 +79,7 @@ base="${filename%.*}"
mp4_out="$dir/$base.mp4" mp4_out="$dir/$base.mp4"
webm_out="$dir/$base.webm" webm_out="$dir/$base.webm"
gif_out="$dir/$base.gif" webp_out="$dir/$base.webp"
poster_out="$dir/$base-poster.jpg" poster_out="$dir/$base-poster.jpg"
overwrite_flag="-n" overwrite_flag="-n"
@@ -83,7 +88,11 @@ if [[ "$force" -eq 1 ]]; then
fi fi
if [[ "$force" -eq 0 ]]; then if [[ "$force" -eq 0 ]]; then
for output in "$mp4_out" "$webm_out" "$gif_out" "$poster_out"; do outputs=("$mp4_out" "$webm_out" "$poster_out")
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
@@ -98,7 +107,6 @@ 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
@@ -159,10 +167,20 @@ else
"$webm_out" "$webm_out"
fi fi
echo "Generating GIF: $gif_out" if [[ "$generate_webp" -eq 1 ]]; then
ffmpeg "$overwrite_flag" -i "$input" \ if ! has_encoder "libwebp"; then
-vf "$gif_vf" \ echo "Error: encoder not found: libwebp" >&2
"$gif_out" exit 1
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" \
@@ -174,5 +192,7 @@ 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"
echo "GIF: $gif_out" if [[ "$generate_webp" -eq 1 ]]; then
echo "WebP: $webp_out"
fi
echo "Poster: $poster_out" echo "Poster: $poster_out"

View File

@@ -8,6 +8,8 @@ local function run_plugin_scenario(config)
events = {}, events = {},
osd = {}, osd = {},
logs = {}, logs = {},
property_sets = {},
periodic_timers = {},
} }
local function make_mp_stub() local function make_mp_stub()
@@ -89,10 +91,32 @@ local function run_plugin_scenario(config)
end end
end end
function mp.add_timeout(_seconds, callback) function mp.add_timeout(seconds, callback)
if callback then local timeout = {
killed = false,
}
function timeout:kill()
self.killed = true
end
local delay = tonumber(seconds) or 0
if callback and delay < 5 then
callback() callback()
end end
return timeout
end
function mp.add_periodic_timer(seconds, callback)
local timer = {
seconds = seconds,
killed = false,
callback = callback,
}
function timer:kill()
self.killed = true
end
recorded.periodic_timers[#recorded.periodic_timers + 1] = timer
return timer
end end
function mp.register_script_message(name, fn) function mp.register_script_message(name, fn)
@@ -116,7 +140,12 @@ 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(...) end function mp.set_property_native(name, value)
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
@@ -242,6 +271,59 @@ 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 count_control_calls(async_calls, flag)
local count = 0
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
count = count + 1
end
end
return count
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
@@ -285,6 +367,44 @@ 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 count_property_set(property_sets, name, value)
local count = 0
for _, call in ipairs(property_sets) do
if call.name == name and call.value == value 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
@@ -373,6 +493,7 @@ 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",
@@ -387,11 +508,182 @@ do
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(
call_has_arg(start_call, "--show-visible-overlay"), call_has_arg(start_call, "--show-visible-overlay"),
"auto-start with visible overlay enabled should pass --show-visible-overlay" "auto-start with visible overlay enabled should include --show-visible-overlay on --start"
) )
assert_true( assert_true(
not call_has_arg(start_call, "--hide-visible-overlay"), not call_has_arg(start_call, "--hide-visible-overlay"),
"auto-start with visible overlay enabled should not pass --hide-visible-overlay" "auto-start with visible overlay enabled should not include --hide-visible-overlay on --start"
)
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_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
"duplicate auto-start should re-assert visible overlay state when 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 duplicate auto-start pause-until-ready scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
recorded.script_messages["subminer-autoplay-ready"]()
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_start_calls(recorded.async_calls) == 1,
"duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running"
)
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 4,
"duplicate pause-until-ready auto-start should re-assert visible overlay on both start and ready events"
)
assert_true(
count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 2,
"duplicate pause-until-ready auto-start should arm tokenization loading gate for each file"
)
assert_true(
count_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready") == 2,
"duplicate pause-until-ready auto-start should release tokenization gate for each file"
)
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 2,
"duplicate pause-until-ready auto-start should force pause for each file"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 2,
"duplicate pause-until-ready auto-start should resume playback for each file"
)
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 tokenization..."),
"pause-until-ready auto-start should show loading OSD message"
)
assert_true(
not has_osd_message(recorded.osd, "SubMiner: Starting..."),
"pause-until-ready auto-start should avoid replacing loading OSD with generic starting OSD"
)
assert_true(
has_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready"),
"autoplay-ready should show loaded OSD message"
)
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
"autoplay-ready should re-assert visible overlay state"
)
assert_true(
#recorded.periodic_timers == 1,
"pause-until-ready auto-start should create periodic loading OSD refresher"
)
assert_true(
recorded.periodic_timers[1].killed == true,
"autoplay-ready should stop periodic loading OSD refresher"
)
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 cleanup scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
fire_event(recorded, "end-file")
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 1,
"pause cleanup scenario should force pause while waiting for tokenization"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 1,
"ending file while gate is armed should clear forced pause state"
) )
end end
@@ -416,11 +708,15 @@ do
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(
call_has_arg(start_call, "--hide-visible-overlay"), call_has_arg(start_call, "--hide-visible-overlay"),
"auto-start with visible overlay disabled should pass --hide-visible-overlay" "auto-start with visible overlay disabled should include --hide-visible-overlay on --start"
) )
assert_true( assert_true(
not call_has_arg(start_call, "--show-visible-overlay"), not call_has_arg(start_call, "--show-visible-overlay"),
"auto-start with visible overlay disabled should not pass --show-visible-overlay" "auto-start with visible overlay disabled should not include --show-visible-overlay on --start"
)
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
@@ -446,6 +742,10 @@ 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

@@ -316,3 +316,33 @@ test('FieldGroupingMergeCollaborator deduplicates identical sentence, audio, and
assert.equal(merged.Picture, '<img data-group-id="202" src="same.png">'); assert.equal(merged.Picture, '<img data-group-id="202" src="same.png">');
assert.equal(merged.ExpressionAudio, merged.SentenceAudio); assert.equal(merged.ExpressionAudio, merged.SentenceAudio);
}); });
test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key query params', () => {
const integration = new AnkiIntegration(
{
metadata: {
pattern: '[SubMiner] %f (%t)',
},
} as never,
{} as never,
{
currentSubText: '',
currentVideoPath:
'stream?static=true&api_key=secret-token&MediaSourceId=a762ab23d26d4347e3cacdb83aaae405&AudioStreamIndex=3',
currentTimePos: 426,
currentSubStart: 426,
currentSubEnd: 428,
currentAudioStreamIndex: 3,
currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02',
send: () => true,
} as unknown as never,
);
const privateApi = integration as unknown as {
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
};
const result = privateApi.formatMiscInfoPattern('audio_123.mp3', 426);
assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)');
assert.equal(result.includes('api_key='), false);
});

View File

@@ -58,6 +58,55 @@ interface NoteInfo {
type CardKind = 'sentence' | 'audio'; type CardKind = 'sentence' | 'audio';
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function decodeURIComponentSafe(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function extractFilenameFromMediaPath(rawPath: string): string {
const trimmedPath = rawPath.trim();
if (!trimmedPath) return '';
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmedPath)) {
try {
const parsed = new URL(trimmedPath);
return decodeURIComponentSafe(path.basename(parsed.pathname));
} catch {
// Fall through to separator-based handling below.
}
}
const separatorIndex = trimmedPath.search(/[?#]/);
const pathWithoutQuery = separatorIndex >= 0 ? trimmedPath.slice(0, separatorIndex) : trimmedPath;
return decodeURIComponentSafe(path.basename(pathWithoutQuery));
}
function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean {
const loweredPath = rawPath.toLowerCase();
const loweredFilename = filename.toLowerCase();
if (loweredPath.includes('api_key=')) {
return true;
}
if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) {
return true;
}
return (
loweredFilename === 'stream' ||
loweredFilename === 'master.m3u8' ||
loweredFilename === 'index.m3u8' ||
loweredFilename === 'playlist.m3u8'
);
}
export class AnkiIntegration { export class AnkiIntegration {
private client: AnkiConnectClient; private client: AnkiConnectClient;
private mediaGenerator: MediaGenerator; private mediaGenerator: MediaGenerator;
@@ -239,7 +288,8 @@ export class AnkiIntegration {
} }
private createProxyServer(): AnkiConnectProxyServer { private createProxyServer(): AnkiConnectProxyServer {
const { AnkiConnectProxyServer } = require('./anki-integration/anki-connect-proxy') as typeof import('./anki-integration/anki-connect-proxy'); const { AnkiConnectProxyServer } =
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),
@@ -728,8 +778,12 @@ export class AnkiIntegration {
} }
const currentVideoPath = this.mpvClient.currentVideoPath || ''; const currentVideoPath = this.mpvClient.currentVideoPath || '';
const videoFilename = currentVideoPath ? path.basename(currentVideoPath) : ''; const videoFilename = extractFilenameFromMediaPath(currentVideoPath);
const filenameWithExt = videoFilename || fallbackFilename; const mediaTitle = trimToNonEmptyString(this.mpvClient.currentMediaTitle);
const filenameWithExt =
(shouldPreferMediaTitleForMiscInfo(currentVideoPath, videoFilename)
? mediaTitle || videoFilename
: videoFilename || mediaTitle) || fallbackFilename;
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, ''); const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const currentTimePos = const currentTimePos =

View File

@@ -27,9 +27,11 @@ 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'),
); );
@@ -50,9 +52,11 @@ 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]);
@@ -71,9 +75,11 @@ 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'),
); );
@@ -94,17 +100,15 @@ 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: [ actions: [{ action: 'version' }, { action: 'addNote' }, { action: 'addNotes' }],
{ 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'),
@@ -126,9 +130,11 @@ 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: {
@@ -154,17 +160,15 @@ 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: [ actions: [{ action: 'version' }, { action: 'addNote' }, { action: 'addNotes' }],
{ action: 'version' },
{ action: 'addNote' },
{ action: 'addNotes' },
],
}, },
}, },
Buffer.from( Buffer.from(
@@ -196,9 +200,11 @@ 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'),
); );
@@ -219,9 +225,11 @@ 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'),
); );
@@ -248,9 +256,11 @@ 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: {
@@ -265,6 +275,38 @@ 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,
@@ -274,13 +316,15 @@ test('proxy detects self-referential loop configuration', () => {
logError: () => undefined, logError: () => undefined,
}); });
const result = (proxy as unknown as { const result = (
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,7 +175,9 @@ export class AnkiConnectProxyServer {
} }
const action = const action =
typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? ''); typeof requestJson.action === 'string'
? requestJson.action
: String(requestJson.action ?? '');
if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') { if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') {
return; return;
} }
@@ -233,7 +235,8 @@ export class AnkiConnectProxyServer {
try { try {
const deck = this.deps.getDeck ? this.deps.getDeck() : undefined; const deck = this.deps.getDeck ? this.deps.getDeck() : undefined;
const query = deck ? `"deck:${deck}" added:1` : 'added:1'; const escapedDeck = deck ? deck.replace(/"/g, '\\"') : undefined;
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,7 +89,11 @@ test('findDuplicateNote checks both source expression/word values when both fiel
if (query.includes('昨日は雨だった。')) { if (query.includes('昨日は雨だった。')) {
return []; return [];
} }
if (query.includes('"Word:雨"') || query.includes('"word:雨"') || query.includes('"Expression:雨"')) { if (
query.includes('"Word:雨"') ||
query.includes('"word:雨"') ||
query.includes('"Expression:雨"')
) {
return [200]; return [200];
} }
return []; return [];

View File

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

View File

@@ -112,12 +112,7 @@ export class FieldGroupingWorkflow {
const keepNoteId = choice.keepNoteId; const keepNoteId = choice.keepNoteId;
const deleteNoteId = choice.deleteNoteId; const deleteNoteId = choice.deleteNoteId;
await this.performMerge( await this.performMerge(keepNoteId, deleteNoteId, expression, choice.deleteDuplicate);
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,18 +51,10 @@ function createWorkflowHarness() {
return out; return out;
}, },
findDuplicateNote: async (_expression, _excludeNoteId, _noteInfo) => null, findDuplicateNote: async (_expression, _excludeNoteId, _noteInfo) => null,
handleFieldGroupingAuto: async ( handleFieldGroupingAuto: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
_originalNoteId, undefined,
_newNoteId, handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
_newNoteInfo, false,
_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;

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