Compare commits
38 Commits
v0.2.2
...
536db5ff85
| Author | SHA1 | Date | |
|---|---|---|---|
|
536db5ff85
|
|||
|
39288a62b6
|
|||
|
93e392910c
|
|||
|
185528aee6
|
|||
|
870acb45d5
|
|||
|
40787e8b71
|
|||
|
98fd2a731e
|
|||
|
de8c15fd56
|
|||
|
370274e78a
|
|||
|
9e0c5e478e
|
|||
|
3f1702b0f6
|
|||
|
66c24767fb
|
|||
|
f8e961d105
|
|||
|
34a0feae71
|
|||
|
db5e3f9e50
|
|||
|
30a76d7767
|
|||
|
1e645f961b
|
|||
|
3a1d746a2e
|
|||
|
17fa10ba36
|
|||
|
d6c4a85a3b
|
|||
|
19c7448f26
|
|||
|
b212986682
|
|||
|
d07b0aa957
|
|||
|
603af36a48
|
|||
|
5ef3396205
|
|||
|
721036342d
|
|||
|
c7c91077fd
|
|||
|
771ea5777f
|
|||
|
151752b17a
|
|||
|
62f53071ec
|
|||
|
337e3268f1
|
|||
|
fa0cb00f70
|
|||
|
a33a87bf8f
|
|||
|
3c2c8453be
|
|||
|
3c5ba3a3d3
|
|||
|
1ae46cd4ba
|
|||
|
1e2b43a7dc
|
|||
|
0de278f3ab
|
4
.github/workflows/release.yml
vendored
@@ -242,7 +242,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
tar -czf "release/subminer-assets.tar.gz" \
|
tar -czf "release/subminer-assets.tar.gz" \
|
||||||
config.example.jsonc \
|
config.example.jsonc \
|
||||||
plugin/subminer \
|
plugin/subminer.lua \
|
||||||
plugin/subminer.conf \
|
plugin/subminer.conf \
|
||||||
assets/themes/subminer.rasi
|
assets/themes/subminer.rasi
|
||||||
|
|
||||||
@@ -304,7 +304,7 @@ jobs:
|
|||||||
### Optional Assets (config example + mpv plugin + rofi theme)
|
### Optional Assets (config example + mpv plugin + rofi theme)
|
||||||
1. Download `subminer-assets.tar.gz`
|
1. Download `subminer-assets.tar.gz`
|
||||||
2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc`
|
2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc`
|
||||||
3. Copy `plugin/subminer/` directory contents to `~/.config/mpv/scripts/`
|
3. Copy `plugin/subminer.lua` to `~/.config/mpv/scripts/`
|
||||||
4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/`
|
4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/`
|
||||||
5. Copy `assets/themes/subminer.rasi` to:
|
5. Copy `assets/themes/subminer.rasi` to:
|
||||||
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
|
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
<!-- BACKLOG.MD MCP GUIDELINES START -->
|
<!-- BACKLOG.MD MCP GUIDELINES START -->
|
||||||
|
|
||||||
<CRITICAL_INSTRUCTION>
|
<CRITICAL_INSTRUCTION>
|
||||||
@@ -16,7 +17,6 @@ This project uses Backlog.md MCP for all task and project management activities.
|
|||||||
- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work
|
- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work
|
||||||
|
|
||||||
These guides cover:
|
These guides cover:
|
||||||
|
|
||||||
- Decision framework for when to create tasks
|
- Decision framework for when to create tasks
|
||||||
- Search-first workflow to avoid duplicates
|
- Search-first workflow to avoid duplicates
|
||||||
- Links to detailed guides for task creation, execution, and finalization
|
- Links to detailed guides for task creation, execution, and finalization
|
||||||
|
|||||||
6
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config docs-dev docs docs-preview docs-watch dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
|
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config docs-dev docs docs-preview dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
|
||||||
|
|
||||||
APP_NAME := subminer
|
APP_NAME := subminer
|
||||||
THEME_SOURCE := assets/themes/subminer.rasi
|
THEME_SOURCE := assets/themes/subminer.rasi
|
||||||
@@ -57,7 +57,6 @@ help:
|
|||||||
" dev-toggle Toggle overlay in a running local Electron app" \
|
" dev-toggle Toggle overlay in a running local Electron app" \
|
||||||
" dev-stop Stop a running local Electron app" \
|
" dev-stop Stop a running local Electron app" \
|
||||||
" docs-dev Run VitePress docs dev server" \
|
" docs-dev Run VitePress docs dev server" \
|
||||||
" docs-watch Run VitePress docs dev + Backlog browser together" \
|
|
||||||
" docs Build VitePress static docs" \
|
" docs Build VitePress static docs" \
|
||||||
" docs-preview Preview built VitePress docs" \
|
" docs-preview Preview built VitePress docs" \
|
||||||
" install-linux Install Linux wrapper/theme/app artifacts" \
|
" install-linux Install Linux wrapper/theme/app artifacts" \
|
||||||
@@ -161,9 +160,6 @@ generate-example-config: ensure-bun
|
|||||||
docs-dev: ensure-bun
|
docs-dev: ensure-bun
|
||||||
@bun run docs:dev
|
@bun run docs:dev
|
||||||
|
|
||||||
docs-watch: ensure-bun
|
|
||||||
@bun run docs:watch
|
|
||||||
|
|
||||||
docs: ensure-bun
|
docs: ensure-bun
|
||||||
@bun run docs:build
|
@bun run docs:build
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](./assets/minecard.mp4)
|
[](./assets/minecard.mp4)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -28,7 +28,6 @@ SubMiner is an Electron overlay that sits on top of mpv. It turns your video pla
|
|||||||
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
|
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
|
||||||
- **Instant auto-enrichment** — Optional local AnkiConnect proxy enriches new Yomitan cards immediately
|
- **Instant auto-enrichment** — Optional local AnkiConnect proxy enriches new Yomitan cards immediately
|
||||||
- **Reading annotations** — Combines N+1 targeting, frequency-dictionary highlighting, and JLPT underlining while you read
|
- **Reading annotations** — Combines N+1 targeting, frequency-dictionary highlighting, and JLPT underlining while you read
|
||||||
- **Hover-aware playback** — By default, hovering subtitle text pauses mpv and resumes on mouse leave (`subtitleStyle.autoPauseVideoOnHover`)
|
|
||||||
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
|
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
|
||||||
- **Immersion tracking** — SQLite-powered stats on your watch time and mining activity
|
- **Immersion tracking** — SQLite-powered stats on your watch time and mining activity
|
||||||
- **Custom texthooker page** — Built-in custom texthooker page and websocket, no extra setup
|
- **Custom texthooker page** — Built-in custom texthooker page and websocket, no extra setup
|
||||||
@@ -66,18 +65,18 @@ cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
|||||||
mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### 3. Set up Yomitan Dictionaries
|
### 3. Set up Yomitan Dictionaries
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer app --yomitan
|
subminer app --start --yomitan
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Mine
|
### 4. Mine
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer app --start --background
|
subminer app --start --background
|
||||||
subminer video.mkv # default plugin config auto-starts visible overlay + resumes playback when ready
|
subminer video.mkv # y-t toggles overlay visibility
|
||||||
subminer --start video.mkv # optional explicit overlay start when plugin auto_start=no
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|||||||
BIN
assets/kiku-integration.gif
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 23 MiB After Width: | Height: | Size: 12 MiB |
BIN
assets/minecard.png
Normal file
|
After Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 21 MiB |
@@ -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"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ title: >-
|
|||||||
status: Done
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-02-28 02:38'
|
created_date: '2026-02-28 02:38'
|
||||||
updated_date: '2026-02-28 22:36'
|
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
references:
|
references:
|
||||||
@@ -20,24 +19,20 @@ references:
|
|||||||
- src/renderer/renderer.ts
|
- src/renderer/renderer.ts
|
||||||
- docs/plans/2026-02-26-secondary-subtitles-main-overlay.md
|
- docs/plans/2026-02-26-secondary-subtitles-main-overlay.md
|
||||||
priority: medium
|
priority: medium
|
||||||
ordinal: 1000
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
Scope: Branch-only commits main..HEAD on refactor-overlay (a14c9da through 9e4e588) rebuilt overlay behavior around visible overlay mode and removed legacy invisible overlay paths.
|
Scope: Branch-only commits main..HEAD on refactor-overlay (a14c9da through 9e4e588) rebuilt overlay behavior around visible overlay mode and removed legacy invisible overlay paths.
|
||||||
|
|
||||||
Delivered behavior:
|
Delivered behavior:
|
||||||
|
|
||||||
- Removed renderer invisible overlay layout/offset helpers and main hover-highlight runtime code paths.
|
- Removed renderer invisible overlay layout/offset helpers and main hover-highlight runtime code paths.
|
||||||
- Added explicit overlay-to-mpv subtitle visibility synchronization so visible overlay state controls primary subtitle visibility consistently.
|
- Added explicit overlay-to-mpv subtitle visibility synchronization so visible overlay state controls primary subtitle visibility consistently.
|
||||||
- Hardened overlay runtime/bootstrap lifecycle around modal fallback open state and bridge send path edge cases.
|
- Hardened overlay runtime/bootstrap lifecycle around modal fallback open state and bridge send path edge cases.
|
||||||
- Updated plugin/config/docs defaults to reflect visible-overlay-first behavior and subtitle binding controls.
|
- Updated plugin/config/docs defaults to reflect visible-overlay-first behavior and subtitle binding controls.
|
||||||
|
|
||||||
Risk/impact context:
|
Risk/impact context:
|
||||||
|
|
||||||
- Large cross-layer refactor touching runtime wiring, renderer event handling, and plugin behavior.
|
- Large cross-layer refactor touching runtime wiring, renderer event handling, and plugin behavior.
|
||||||
- Regression coverage added/updated for overlay runtime, mpv protocol handling, renderer cleanup, and subtitle rendering paths.
|
- Regression coverage added/updated for overlay runtime, mpv protocol handling, renderer cleanup, and subtitle rendering paths.
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
@@ -45,7 +40,5 @@ Risk/impact context:
|
|||||||
## Final Summary
|
## Final Summary
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
Completed and validated in branch commit set before merge. Refactor reduces dead overlay modes, centralizes subtitle visibility behavior, and documents new defaults/constraints.
|
Completed and validated in branch commit set before merge. Refactor reduces dead overlay modes, centralizes subtitle visibility behavior, and documents new defaults/constraints.
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
id: TASK-70
|
||||||
|
title: Polish YouTube subtitle generation pipeline
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-02-26 07:37'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
$Current YouTube subtitle generation in launcher/youtube.ts is functional but has implicit behavior, low observability, and unnecessary full-file work. This task modernizes the existing pipeline without changing core architecture.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Make track selection explicit (manual > auto > whisper per primary/secondary) with deterministic reasons.
|
||||||
|
- Avoid running whisper/audio work when a track is already satisfied.
|
||||||
|
- Add bounded execution for yt-dlp and whisper subprocesses.
|
||||||
|
- Improve stage-level logging for both automatic and preprocess modes.
|
||||||
|
- Make secondary track fallback decisions explicit and not implicit.
|
||||||
|
- Preserve existing user behavior except where policy is clarified.
|
||||||
|
|
||||||
|
Files expected:
|
||||||
|
- launcher/youtube.ts
|
||||||
|
- launcher/commands/playback-command.ts (if mode/status behavior requires)
|
||||||
|
- launcher/types.ts (if schema updates needed)
|
||||||
|
- launcher/config/args-normalizer.ts (if timeout/config options added)
|
||||||
|
- launcher/util.ts (if runExternalCommand timeout controls added)
|
||||||
|
- Add/update launcher subtitle-generation tests
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 Define deterministic track priority for each track: manual, then auto, then whisper (per track) and record source choice with reason.
|
||||||
|
- [ ] #2 If manual or auto satisfies a track, skip whisper for that same track and avoid unrelated full extraction/transcription work.
|
||||||
|
- [ ] #3 Introduce timeout or budget caps for yt-dlp and whisper calls; timeout should fail safe and unblock automatic playback.
|
||||||
|
- [ ] #4 Emit explicit status logs at each stage: metadata load, manual sub fetch, auto sub fetch, whisper audio extraction, whisper run, publish/load, final success/failure summary.
|
||||||
|
- [ ] #5 Make secondary handling explicit: transcribe target and translate target must only run when required by config and not by side-effect of primary logic.
|
||||||
|
- [ ] #6 Keep preprocess and automatic modes stable in success paths while making behavior in failure paths explicit and bounded.
|
||||||
|
- [ ] #7 Add tests for track-combination cases: primary available, secondary available, both missing, partial fallback, both missing with missing whisper config, timeout/error behavior.
|
||||||
|
- [ ] #8 Document any behavior changes if user-visible, especially fallback order, timeout behavior, and fallback disablement.
|
||||||
|
<!-- AC:END -->
|
||||||
@@ -6,7 +6,6 @@ title: >-
|
|||||||
status: Done
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-02-28 02:38'
|
created_date: '2026-02-28 02:38'
|
||||||
updated_date: '2026-02-28 22:36'
|
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
references:
|
references:
|
||||||
@@ -19,17 +18,14 @@ references:
|
|||||||
- docs/anki-integration.md
|
- docs/anki-integration.md
|
||||||
- config.example.jsonc
|
- config.example.jsonc
|
||||||
priority: medium
|
priority: medium
|
||||||
ordinal: 2000
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
Scope: Current unmerged working-tree changes implement an optional local AnkiConnect-compatible proxy and transport switching for card enrichment.
|
Scope: Current unmerged working-tree changes implement an optional local AnkiConnect-compatible proxy and transport switching for card enrichment.
|
||||||
|
|
||||||
Delivered behavior:
|
Delivered behavior:
|
||||||
|
|
||||||
- Added proxy server that forwards AnkiConnect requests and enqueues addNote/addNotes note IDs for post-create enrichment, with de-duplication and loop-configuration protection.
|
- Added proxy server that forwards AnkiConnect requests and enqueues addNote/addNotes note IDs for post-create enrichment, with de-duplication and loop-configuration protection.
|
||||||
- Added follow-up response-shape compatibility handling so proxy enqueue works for both envelope (`{result,error}`) and bare JSON payloads, including `multi` variants.
|
- Added follow-up response-shape compatibility handling so proxy enqueue works for both envelope (`{result,error}`) and bare JSON payloads, including `multi` variants.
|
||||||
- Added config schema/defaults/resolution for ankiConnect.proxy (enabled, host, port, upstreamUrl) with validation warnings and fallback behavior.
|
- Added config schema/defaults/resolution for ankiConnect.proxy (enabled, host, port, upstreamUrl) with validation warnings and fallback behavior.
|
||||||
@@ -38,7 +34,6 @@ Delivered behavior:
|
|||||||
- Updated user docs/config examples for proxy mode setup, troubleshooting, and mining workflow behavior.
|
- Updated user docs/config examples for proxy mode setup, troubleshooting, and mining workflow behavior.
|
||||||
|
|
||||||
Risk/impact context:
|
Risk/impact context:
|
||||||
|
|
||||||
- New network surface on local host/port; correctness depends on safe proxy upstream configuration and robust response handling.
|
- New network surface on local host/port; correctness depends on safe proxy upstream configuration and robust response handling.
|
||||||
- Tests added for proxy queue behavior, config resolution, and parser sync routines.
|
- Tests added for proxy queue behavior, config resolution, and parser sync routines.
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
@@ -46,7 +41,5 @@ Risk/impact context:
|
|||||||
## Final Summary
|
## Final Summary
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
Completed implementation in branch working tree; ready to merge once local changes are committed and test gate passes.
|
Completed implementation in branch working tree; ready to merge once local changes are committed and test gate passes.
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ title: 'macOS config validation UX: show full warning details in native dialog'
|
|||||||
status: Done
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-02-28 02:38'
|
created_date: '2026-02-28 02:38'
|
||||||
updated_date: '2026-02-28 22:36'
|
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
references:
|
references:
|
||||||
@@ -13,30 +12,24 @@ references:
|
|||||||
- src/main/runtime/startup-config.ts
|
- src/main/runtime/startup-config.ts
|
||||||
- docs/configuration.md
|
- docs/configuration.md
|
||||||
priority: low
|
priority: low
|
||||||
ordinal: 3000
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
Scope: Commit cc2f9ef improves startup config-warning visibility on macOS by ensuring full details are surfaced in the native UI path and reflected in docs.
|
Scope: Commit cc2f9ef improves startup config-warning visibility on macOS by ensuring full details are surfaced in the native UI path and reflected in docs.
|
||||||
|
|
||||||
Delivered behavior:
|
Delivered behavior:
|
||||||
|
|
||||||
- Config validation/runtime wiring updated so macOS users can access complete warning details instead of truncated notification-only text.
|
- Config validation/runtime wiring updated so macOS users can access complete warning details instead of truncated notification-only text.
|
||||||
- Added/updated tests around config validation and startup config warning flows.
|
- Added/updated tests around config validation and startup config warning flows.
|
||||||
- Updated configuration docs to clarify platform-specific warning presentation behavior.
|
- Updated configuration docs to clarify platform-specific warning presentation behavior.
|
||||||
|
|
||||||
Risk/impact context:
|
Risk/impact context:
|
||||||
|
|
||||||
- Low runtime risk; primarily user-facing diagnostics clarity improvement.
|
- Low runtime risk; primarily user-facing diagnostics clarity improvement.
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
## Final Summary
|
## Final Summary
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
Completed small follow-up fix to reduce config-debug friction on macOS.
|
Completed small follow-up fix to reduce config-debug friction on macOS.
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
---
|
---
|
||||||
id: TASK-73
|
id: TASK-73
|
||||||
title: 'MPV plugin: split into modules and optimize startup/command runtime'
|
title: 'MPV plugin: split into modules and optimize startup/command runtime'
|
||||||
status: Done
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-02-28 20:50'
|
created_date: '2026-02-28 20:50'
|
||||||
updated_date: '2026-02-28 22:36'
|
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
references:
|
references:
|
||||||
@@ -33,17 +32,14 @@ references:
|
|||||||
- docs/architecture.md
|
- docs/architecture.md
|
||||||
- README.md
|
- README.md
|
||||||
priority: medium
|
priority: medium
|
||||||
ordinal: 4000
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
Scope: Replace monolithic `plugin/subminer.lua` with modular plugin runtime; optimize command execution paths; align install/docs/tests; fix launcher smoke instability.
|
Scope: Replace monolithic `plugin/subminer.lua` with modular plugin runtime; optimize command execution paths; align install/docs/tests; fix launcher smoke instability.
|
||||||
|
|
||||||
Delivered behavior:
|
Delivered behavior:
|
||||||
|
|
||||||
- Full plugin cutover to `plugin/subminer/main.lua` + module directory (no runtime compatibility shim with old monolith file).
|
- Full plugin cutover to `plugin/subminer/main.lua` + module directory (no runtime compatibility shim with old monolith file).
|
||||||
- Process/control command path moved toward async subprocess usage for non-start actions (`stop`, `toggle`, `settings`, restart stop leg), reducing synchronous blocking in mpv script runtime.
|
- Process/control command path moved toward async subprocess usage for non-start actions (`stop`, `toggle`, `settings`, restart stop leg), reducing synchronous blocking in mpv script runtime.
|
||||||
- AniSkip path guarded: lookup runs only in SubMiner context (launcher metadata, explicit script-message refresh, or detected running app), instead of every opened file.
|
- AniSkip path guarded: lookup runs only in SubMiner context (launcher metadata, explicit script-message refresh, or detected running app), instead of every opened file.
|
||||||
@@ -55,7 +51,6 @@ Delivered behavior:
|
|||||||
- Playback command cleanup race fixed when mpv exits before exit-listener registration.
|
- Playback command cleanup race fixed when mpv exits before exit-listener registration.
|
||||||
|
|
||||||
Risk/impact context:
|
Risk/impact context:
|
||||||
|
|
||||||
- mpv plugin loading path changed from single-file to module directory; packaging/install paths must stay consistent with release assets.
|
- mpv plugin loading path changed from single-file to module directory; packaging/install paths must stay consistent with release assets.
|
||||||
- Async control/AniSkip path changes reduce blocking but can surface timing differences; regression checks added for cold start, file-load gating, and explicit refresh behavior.
|
- Async control/AniSkip path changes reduce blocking but can surface timing differences; regression checks added for cold start, file-load gating, and explicit refresh behavior.
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
@@ -63,22 +58,18 @@ Risk/impact context:
|
|||||||
## Final Summary
|
## Final Summary
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
AniSkip gate/async update delivered in plugin runtime:
|
AniSkip gate/async update delivered in plugin runtime:
|
||||||
|
|
||||||
- `plugin/subminer/lifecycle.lua`: deferred AniSkip fetch and overlay-start trigger.
|
- `plugin/subminer/lifecycle.lua`: deferred AniSkip fetch and overlay-start trigger.
|
||||||
- `plugin/subminer/aniskip.lua`: async lookup pipeline + context guard + session caches.
|
- `plugin/subminer/aniskip.lua`: async lookup pipeline + context guard + session caches.
|
||||||
- `plugin/subminer/environment.lua`: async app-running detection with short cache.
|
- `plugin/subminer/environment.lua`: async app-running detection with short cache.
|
||||||
- `plugin/subminer/messages.lua`: explicit script-message trigger wiring.
|
- `plugin/subminer/messages.lua`: explicit script-message trigger wiring.
|
||||||
|
|
||||||
Regression coverage updated:
|
Regression coverage updated:
|
||||||
|
|
||||||
- `scripts/test-plugin-start-gate.lua` now verifies:
|
- `scripts/test-plugin-start-gate.lua` now verifies:
|
||||||
- no sync `ps`/`curl` on non-context file load
|
- no sync `ps`/`curl` on non-context file load
|
||||||
- no AniSkip network lookup on non-context file load
|
- no AniSkip network lookup on non-context file load
|
||||||
- script-message refresh forces async AniSkip lookup
|
- script-message refresh forces async AniSkip lookup
|
||||||
|
|
||||||
Validation run:
|
Validation run:
|
||||||
|
|
||||||
- `bun run test:plugin:src` pass.
|
- `bun run test:plugin:src` pass.
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
---
|
---
|
||||||
id: TASK-74
|
id: TASK-74
|
||||||
title: 'Startup warmups: configurable warmup vs defer with low-power mode'
|
title: 'Startup warmups: configurable warmup vs defer with low-power mode'
|
||||||
status: Done
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-02-27 21:05'
|
created_date: '2026-02-27 21:05'
|
||||||
updated_date: '2026-03-01 04:14'
|
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
references:
|
references:
|
||||||
@@ -16,24 +15,18 @@ references:
|
|||||||
- src/main/runtime/startup-warmups.ts
|
- src/main/runtime/startup-warmups.ts
|
||||||
- src/main/runtime/startup-warmups-main-deps.ts
|
- src/main/runtime/startup-warmups-main-deps.ts
|
||||||
- src/main/runtime/composers/mpv-runtime-composer.ts
|
- src/main/runtime/composers/mpv-runtime-composer.ts
|
||||||
- src/core/services/startup.ts
|
|
||||||
- src/main.ts
|
- src/main.ts
|
||||||
- src/config/config.test.ts
|
- src/config/config.test.ts
|
||||||
- src/main/runtime/startup-warmups.test.ts
|
- src/main/runtime/startup-warmups.test.ts
|
||||||
- src/main/runtime/startup-warmups-main-deps.test.ts
|
|
||||||
- src/core/services/app-ready.test.ts
|
|
||||||
priority: medium
|
priority: medium
|
||||||
ordinal: 7000
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
Add startup warmup controls to allow per-integration warmup or deferred first-use loading.
|
Add startup warmup controls to allow per-integration warmup or deferred first-use loading.
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
|
|
||||||
- New config section `startupWarmups` with toggles for `mecab`, `yomitanExtension`, `subtitleDictionaries`, and `jellyfinRemoteSession`.
|
- New config section `startupWarmups` with toggles for `mecab`, `yomitanExtension`, `subtitleDictionaries`, and `jellyfinRemoteSession`.
|
||||||
- New `startupWarmups.lowPowerMode` policy: defer everything except Yomitan extension.
|
- New `startupWarmups.lowPowerMode` policy: defer everything except Yomitan extension.
|
||||||
- Keep default behavior as full warmup.
|
- Keep default behavior as full warmup.
|
||||||
@@ -44,9 +37,7 @@ Scope:
|
|||||||
## Final Summary
|
## Final Summary
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
Implemented:
|
Implemented:
|
||||||
|
|
||||||
- Added `startupWarmups` to config types/defaults/options/template/resolve.
|
- Added `startupWarmups` to config types/defaults/options/template/resolve.
|
||||||
- Warmup scheduler now uses per-integration gating functions.
|
- Warmup scheduler now uses per-integration gating functions.
|
||||||
- Low-power mode now defers MeCab, subtitle dictionaries, and Jellyfin remote session warmups while still warming Yomitan extension.
|
- Low-power mode now defers MeCab, subtitle dictionaries, and Jellyfin remote session warmups while still warming Yomitan extension.
|
||||||
@@ -54,23 +45,7 @@ Implemented:
|
|||||||
- Added/updated tests across config and runtime warmup modules.
|
- Added/updated tests across config and runtime warmup modules.
|
||||||
|
|
||||||
Validation:
|
Validation:
|
||||||
|
|
||||||
- `bun run test:config:src`
|
- `bun run test:config:src`
|
||||||
- `bun run test:core:src`
|
- `bun run test:core:src`
|
||||||
- `tsc --noEmit`
|
- `tsc --noEmit`
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
Follow-up updates:
|
|
||||||
|
|
||||||
- Startup now triggers warmups earlier in app-ready flow (right after config validation/log-level setup) instead of waiting for initial args/overlay actions. Goal: tokenization warmup is already done or mostly done by first visible-subs toggle.
|
|
||||||
- Tokenization warmup scheduling consolidated as `subtitle-tokenization` stage; when enabled by toggles, it runs Yomitan extension first, then MeCab/dictionary warmups.
|
|
||||||
- Added per-stage debug logs for warmup progress and skip reasons:
|
|
||||||
- `stage start/ready: yomitan-extension`
|
|
||||||
- `stage start/ready: mecab`
|
|
||||||
- `stage start/ready: subtitle-dictionaries`
|
|
||||||
- `stage start/ready: jellyfin-remote-session`
|
|
||||||
- `stage skipped: jellyfin-remote-session (disabled|auto-connect off)`
|
|
||||||
- Added regression tests for stage-level logging and earlier startup ordering:
|
|
||||||
- `src/main/runtime/startup-warmups.test.ts`
|
|
||||||
- `src/main/runtime/startup-warmups-main-deps.test.ts`
|
|
||||||
- `src/core/services/app-ready.test.ts`
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-75
|
|
||||||
title: 'Tokenizer: configurable POS exclusions for N+1 and frequency annotations'
|
|
||||||
status: Done
|
|
||||||
assignee: []
|
|
||||||
created_date: '2026-03-01 01:23'
|
|
||||||
updated_date: '2026-03-01 04:14'
|
|
||||||
labels: []
|
|
||||||
dependencies: []
|
|
||||||
priority: medium
|
|
||||||
ordinal: 6000
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
|
|
||||||
N+1 and frequency highlighting should ignore non-learning tokens (e.g., particles/auxiliary forms) based on MeCab POS1 tags, while remaining user-configurable.
|
|
||||||
|
|
||||||
Problem example: for subtitle phrase containing になれば, the highlighted N+1 target should not be the non-useful inflection/token piece when POS indicates an excluded class.
|
|
||||||
|
|
||||||
Implement configurable exclusion defaults with add/remove overrides so users can tune behavior without code changes.
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
|
|
||||||
- [x] #1 Default exclusion set omits non-useful POS1 classes from both N+1 candidate selection and frequency highlighting.
|
|
||||||
- [x] #2 Users can add extra POS1 exclusions and remove defaults via config.
|
|
||||||
- [x] #3 Tokenizer/annotation tests cover default behavior and config add/remove overrides.
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Final Summary
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
|
||||||
|
|
||||||
Implemented configurable annotation POS exclusions with defaults+add/remove for both MeCab POS1 and POS2, wired to N+1 candidate selection and frequency highlighting. Added POS2 default exclusion (非自立), expanded POS1 defaults for function words, added Yomitan->MeCab enrichment to carry pos2/pos3 metadata, updated config docs/examples, and added regression tests including になれば case.
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-76
|
|
||||||
title: 'Tokenizer: remove POS exclusion config surface and keep hardcoded defaults'
|
|
||||||
status: Done
|
|
||||||
assignee: []
|
|
||||||
created_date: '2026-03-01 02:45'
|
|
||||||
updated_date: '2026-03-01 04:14'
|
|
||||||
labels: []
|
|
||||||
dependencies: []
|
|
||||||
priority: medium
|
|
||||||
ordinal: 5000
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
|
|
||||||
Remove user-facing config keys for annotation POS exclusions. Keep N+1/frequency POS exclusion behavior as built-in defaults with no config required.
|
|
||||||
|
|
||||||
Scope: remove config parsing/registry/docs/example for annotationFilters.pos1Exclusions/pos2Exclusions while preserving runtime filtering behavior.
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
|
|
||||||
- [x] #1 No user-facing config option exists for annotation POS exclusions.
|
|
||||||
- [x] #2 Runtime N+1/frequency exclusion behavior remains active via built-in defaults.
|
|
||||||
- [x] #3 Config/docs/example/tests updated accordingly.
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Final Summary
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
|
||||||
|
|
||||||
Removed user-facing subtitleStyle.annotationFilters POS exclusion configuration (schema/resolver/options/docs/example). POS-based N+1/frequency filtering now always uses built-in defaults in runtime. Preserved robust exclusion behavior including merged-token overlap POS handling and N+1-only MeCab enrichment path.
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-77
|
|
||||||
title: 'Subtitle hover: auto-pause playback with config toggle'
|
|
||||||
status: Done
|
|
||||||
assignee: []
|
|
||||||
created_date: '2026-02-28 22:43'
|
|
||||||
updated_date: '2026-02-28 22:43'
|
|
||||||
labels: []
|
|
||||||
dependencies: []
|
|
||||||
priority: medium
|
|
||||||
ordinal: 8000
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
|
|
||||||
Add a user-facing subtitle config option to pause mpv playback when the cursor hovers subtitle text and resume playback when the cursor leaves.
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- New config key: `subtitleStyle.autoPauseVideoOnHover`.
|
|
||||||
- Default should be enabled.
|
|
||||||
- Hover pause/resume must not unpause if playback was already paused before hover.
|
|
||||||
- Docs/examples/tests updated.
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
|
|
||||||
- [x] #1 `subtitleStyle.autoPauseVideoOnHover` exists and defaults to `true`.
|
|
||||||
- [x] #2 Overlay pauses playback on subtitle hover and resumes on leave only when hover-triggered pause occurred.
|
|
||||||
- [x] #3 Main/renderer IPC exposes pause-state query for safe hover behavior.
|
|
||||||
- [x] #4 Config docs/examples and user docs/readme mention the new behavior and toggle.
|
|
||||||
- [x] #5 Regression tests cover config parsing/validation and hover behavior edge cases.
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Final Summary
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
|
||||||
|
|
||||||
Implemented `subtitleStyle.autoPauseVideoOnHover` with default `true`, wired through config defaults/resolution/types, renderer state/style, and mouse hover handlers. Added playback pause-state IPC (`getPlaybackPaused`) to avoid false resume when media was already paused. Added renderer hover behavior tests (including race/cancel case) and config/resolve tests. Updated config examples and docs (`README`, usage, shortcuts, mining workflow, configuration) to document default hover pause/resume behavior and disable path.
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-78
|
|
||||||
title: 'Launcher + mpv plugin: auto-start visible overlay pause-until-ready and single-start guard'
|
|
||||||
status: Done
|
|
||||||
assignee: []
|
|
||||||
created_date: '2026-02-28 22:45'
|
|
||||||
updated_date: '2026-02-28 22:45'
|
|
||||||
labels: []
|
|
||||||
dependencies: []
|
|
||||||
priority: medium
|
|
||||||
ordinal: 9000
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
|
|
||||||
Add startup gating behavior for wrapper + mpv plugin flow so playback starts paused when visible overlay auto-start is enabled, then auto-resumes only after subtitle tokenization is ready.
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Plugin option `auto_start_pause_until_ready` (default `yes`).
|
|
||||||
- Launcher reads plugin runtime config and starts mpv paused when `auto_start=yes`, `auto_start_visible_overlay=yes`, and `auto_start_pause_until_ready=yes`.
|
|
||||||
- Main process signals readiness via mpv script message after tokenized subtitle delivery.
|
|
||||||
- Prevent duplicate auto-start attempts from showing `SubMiner already running` OSD.
|
|
||||||
- Keep startup/loading OSD messaging visible and update docs/tests.
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
|
|
||||||
- [x] #1 Launcher reads `auto_start`, `auto_start_visible_overlay`, and `auto_start_pause_until_ready` from `subminer.conf` and starts mpv with `--pause=yes` when all are enabled.
|
|
||||||
- [x] #2 Plugin pauses on eligible auto-start and resumes only on readiness signal or timeout fallback.
|
|
||||||
- [x] #3 Main process emits `script-message subminer-autoplay-ready` after subtitle tokenization is ready.
|
|
||||||
- [x] #4 Auto-start duplicate triggers are idempotent (no duplicate `--start` behavior and no spurious `Already running` OSD for auto-start path).
|
|
||||||
- [x] #5 Docs and regression tests cover defaults, startup gating behavior, and duplicate-start suppression.
|
|
||||||
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Final Summary
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
|
||||||
|
|
||||||
Implemented startup pause gate across launcher/plugin/main runtime:
|
|
||||||
- Added plugin runtime config parsing in launcher (`auto_start`, `auto_start_visible_overlay`, `auto_start_pause_until_ready`) and mpv start-paused behavior for eligible runs.
|
|
||||||
- Added plugin auto-play gate state, timeout fallback, and readiness release via `subminer-autoplay-ready` script message.
|
|
||||||
- Added main-process readiness signaling after tokenization delivery, including unpause fallback command path.
|
|
||||||
- Split auto-start visibility control into separate control commands and added duplicate auto-start idempotency guard to suppress repeated auto-start `Already running` noise.
|
|
||||||
- Updated plugin defaults to enabled (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`) and refreshed docs (`README`, usage, launcher, installation, plugin/config docs).
|
|
||||||
- Added/updated regression coverage (`scripts/test-plugin-start-gate.lua`, launcher smoke/unit tests) validating paused startup, readiness resume, and duplicate-start suppression.
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
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 -->
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
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 -->
|
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Overlay Auto-Start
|
// Overlay Auto-Start
|
||||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
||||||
@@ -16,7 +17,7 @@
|
|||||||
// Control whether browser opens automatically for texthooker.
|
// Control whether browser opens automatically for texthooker.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"texthooker": {
|
"texthooker": {
|
||||||
"openBrowser": true, // Open browser setting. Values: true | false
|
"openBrowser": true // Open browser setting. Values: true | false
|
||||||
}, // Control whether browser opens automatically for texthooker.
|
}, // Control whether browser opens automatically for texthooker.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
||||||
"port": 6677, // Built-in subtitle websocket server port.
|
"port": 6677 // Built-in subtitle websocket server port.
|
||||||
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
// Set to debug for full runtime diagnostics.
|
// Set to debug for full runtime diagnostics.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"logging": {
|
"logging": {
|
||||||
"level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error
|
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||||
}, // Controls logging verbosity. Keep this as an object; do not replace with a bare string.
|
}, // Controls logging verbosity. Keep this as an object; do not replace with a bare string.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
|
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
|
||||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -76,7 +77,7 @@
|
|||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
"secondarySubLanguages": [], // Secondary sub languages setting.
|
||||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
||||||
"defaultMode": "hover", // Default mode setting.
|
"defaultMode": "hover" // Default mode setting.
|
||||||
}, // Dual subtitle track options.
|
}, // Dual subtitle track options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
"ffmpeg_path": "" // Ffmpeg path setting.
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -95,7 +96,7 @@
|
|||||||
// Initial vertical subtitle position from the bottom.
|
// Initial vertical subtitle position from the bottom.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitlePosition": {
|
"subtitlePosition": {
|
||||||
"yPercent": 10, // Y percent setting.
|
"yPercent": 10 // Y percent setting.
|
||||||
}, // Initial vertical subtitle position from the bottom.
|
}, // Initial vertical subtitle position from the bottom.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -106,7 +107,6 @@
|
|||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
|
||||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||||
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
||||||
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
@@ -129,19 +129,24 @@
|
|||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
"N3": "#f9e2af", // N3 setting.
|
"N3": "#f9e2af", // N3 setting.
|
||||||
"N4": "#a6e3a1", // N4 setting.
|
"N4": "#a6e3a1", // N4 setting.
|
||||||
"N5": "#8aadf4", // N5 setting.
|
"N5": "#8aadf4" // N5 setting.
|
||||||
}, // Jlpt colors setting.
|
}, // Jlpt colors setting.
|
||||||
"frequencyDictionary": {
|
"frequencyDictionary": {
|
||||||
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
||||||
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, SubMiner searches installed/default frequency-dictionary locations.
|
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, SubMiner searches installed/default frequency-dictionary locations.
|
||||||
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
|
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
|
||||||
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
|
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
|
||||||
"matchMode": "headword", // Frequency lookup text selection mode. Values: headword | surface
|
|
||||||
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
||||||
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
"bandedColors": [
|
||||||
|
"#ed8796",
|
||||||
|
"#f5a97f",
|
||||||
|
"#f9e2af",
|
||||||
|
"#a6e3a1",
|
||||||
|
"#8aadf4"
|
||||||
|
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
}, // Frequency dictionary setting.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
"fontFamily": "Manrope, Inter", // Font family setting.
|
||||||
"fontSize": 24, // Font size setting.
|
"fontSize": 24, // Font size setting.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
"fontColor": "#cad3f5", // Font color setting.
|
||||||
"lineHeight": 1.35, // Line height setting.
|
"lineHeight": 1.35, // Line height setting.
|
||||||
@@ -153,8 +158,8 @@
|
|||||||
"backgroundColor": "transparent", // Background color setting.
|
"backgroundColor": "transparent", // Background color setting.
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||||
"fontWeight": "normal", // Font weight setting.
|
"fontWeight": "normal", // Font weight setting.
|
||||||
"fontStyle": "normal", // Font style setting.
|
"fontStyle": "normal" // Font style setting.
|
||||||
}, // Secondary setting.
|
} // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -171,15 +176,17 @@
|
|||||||
"enabled": false, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
"enabled": false, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
||||||
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
|
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
|
||||||
"port": 8766, // Bind port for local AnkiConnect proxy.
|
"port": 8766, // Bind port for local AnkiConnect proxy.
|
||||||
"upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
|
"upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
|
||||||
}, // Proxy setting.
|
}, // Proxy setting.
|
||||||
"tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
"tags": [
|
||||||
|
"SubMiner"
|
||||||
|
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
"fields": {
|
"fields": {
|
||||||
"audio": "ExpressionAudio", // Audio setting.
|
"audio": "ExpressionAudio", // Audio setting.
|
||||||
"image": "Picture", // Image setting.
|
"image": "Picture", // Image setting.
|
||||||
"sentence": "Sentence", // Sentence setting.
|
"sentence": "Sentence", // Sentence setting.
|
||||||
"miscInfo": "MiscInfo", // Misc info setting.
|
"miscInfo": "MiscInfo", // Misc info setting.
|
||||||
"translation": "SelectionText", // Translation setting.
|
"translation": "SelectionText" // Translation setting.
|
||||||
}, // Fields setting.
|
}, // Fields setting.
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
@@ -188,7 +195,7 @@
|
|||||||
"model": "openai/gpt-4o-mini", // Model setting.
|
"model": "openai/gpt-4o-mini", // Model setting.
|
||||||
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
||||||
"targetLanguage": "English", // Target language setting.
|
"targetLanguage": "English", // Target language setting.
|
||||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
|
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting.
|
||||||
}, // Ai setting.
|
}, // Ai setting.
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
"generateAudio": true, // Generate audio setting. Values: true | false
|
||||||
@@ -201,7 +208,7 @@
|
|||||||
"animatedCrf": 35, // Animated crf setting.
|
"animatedCrf": 35, // Animated crf setting.
|
||||||
"audioPadding": 0.5, // Audio padding setting.
|
"audioPadding": 0.5, // Audio padding setting.
|
||||||
"fallbackDuration": 3, // Fallback duration setting.
|
"fallbackDuration": 3, // Fallback duration setting.
|
||||||
"maxMediaDuration": 30, // Max media duration setting.
|
"maxMediaDuration": 30 // Max media duration setting.
|
||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
||||||
@@ -209,7 +216,7 @@
|
|||||||
"mediaInsertMode": "append", // Media insert mode setting.
|
"mediaInsertMode": "append", // Media insert mode setting.
|
||||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
"highlightWord": true, // Highlight word setting. Values: true | false
|
||||||
"notificationType": "osd", // Notification type setting.
|
"notificationType": "osd", // Notification type setting.
|
||||||
"autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false
|
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||||
}, // Behavior setting.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||||
@@ -218,20 +225,20 @@
|
|||||||
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
|
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
|
||||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||||
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
|
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
|
||||||
"knownWord": "#a6da95", // Color used for legacy known-word highlights.
|
"knownWord": "#a6da95" // Color used for legacy known-word highlights.
|
||||||
}, // N plus one setting.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)", // Pattern setting.
|
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
||||||
}, // Metadata setting.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
"sentenceCardModel": "Japanese sentences", // Sentence card model setting.
|
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
||||||
}, // Is lapis setting.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false
|
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
||||||
}, // Is kiku setting.
|
} // Is kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -241,7 +248,7 @@
|
|||||||
"jimaku": {
|
"jimaku": {
|
||||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||||
"maxEntryResults": 10, // Maximum Jimaku search results returned.
|
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||||
}, // Jimaku API configuration and defaults.
|
}, // Jimaku API configuration and defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -252,7 +259,10 @@
|
|||||||
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
|
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
|
||||||
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
|
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
|
||||||
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
||||||
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher.
|
"primarySubLanguages": [
|
||||||
|
"ja",
|
||||||
|
"jpn"
|
||||||
|
] // Comma-separated primary subtitle language priority used by the launcher.
|
||||||
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -261,7 +271,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"anilist": {
|
"anilist": {
|
||||||
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
||||||
"accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
"accessToken": "" // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
||||||
}, // Anilist API credentials and update behavior.
|
}, // Anilist API credentials and update behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -285,8 +295,16 @@
|
|||||||
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
||||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
||||||
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||||
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
|
"directPlayContainers": [
|
||||||
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
|
"mkv",
|
||||||
|
"mp4",
|
||||||
|
"webm",
|
||||||
|
"mov",
|
||||||
|
"flac",
|
||||||
|
"mp3",
|
||||||
|
"aac"
|
||||||
|
], // Container allowlist for direct play decisions.
|
||||||
|
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
|
||||||
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -297,7 +315,7 @@
|
|||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750, // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -319,7 +337,7 @@
|
|||||||
"telemetryDays": 30, // Telemetry retention window in days.
|
"telemetryDays": 30, // Telemetry retention window in days.
|
||||||
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
||||||
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
||||||
"vacuumIntervalDays": 7, // Minimum days between VACUUM runs.
|
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
|
||||||
}, // Retention setting.
|
} // Retention setting.
|
||||||
}, // Enable/disable immersion tracking.
|
} // Enable/disable immersion tracking.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ export default {
|
|||||||
{ text: 'Launcher Script', link: '/launcher-script' },
|
{ text: 'Launcher Script', link: '/launcher-script' },
|
||||||
{ text: 'Usage', link: '/usage' },
|
{ text: 'Usage', link: '/usage' },
|
||||||
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
||||||
// { text: 'Feature Demos', link: '/demos' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Anki Integration
|
# Anki Integration
|
||||||
|
|
||||||
SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on to create and update Anki cards with sentence context, audio, and screenshots.
|
SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on to create and update Anki cards with sentence context, audio, and screenshots.
|
||||||
This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -274,8 +273,8 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
|||||||
|
|
||||||
### What Gets Merged
|
### What Gets Merged
|
||||||
|
|
||||||
| Field | Merge behavior |
|
| Field | Merge behavior |
|
||||||
| -------- | --------------------------------------------------------------- |
|
| -------- | -------------------------------------------------------------- |
|
||||||
| Sentence | Both sentences preserved (exact duplicate text is deduplicated) |
|
| Sentence | Both sentences preserved (exact duplicate text is deduplicated) |
|
||||||
| Audio | Both `[sound:...]` entries kept (exact duplicates deduplicated) |
|
| Audio | Both `[sound:...]` entries kept (exact duplicates deduplicated) |
|
||||||
| Image | Both images kept (exact duplicates deduplicated) |
|
| Image | Both images kept (exact duplicates deduplicated) |
|
||||||
@@ -300,7 +299,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
|||||||
"enabled": false,
|
"enabled": false,
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8766,
|
"port": 8766,
|
||||||
"upstreamUrl": "http://127.0.0.1:8765",
|
"upstreamUrl": "http://127.0.0.1:8765"
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"audio": "ExpressionAudio",
|
"audio": "ExpressionAudio",
|
||||||
|
|||||||
@@ -72,467 +72,27 @@ Restart-required changes:
|
|||||||
|
|
||||||
The configuration file includes several main sections:
|
The configuration file includes several main sections:
|
||||||
|
|
||||||
**Core Settings**
|
|
||||||
|
|
||||||
- [**Logging**](#logging) - Runtime log level
|
|
||||||
- [**Auto-Start Overlay**](#auto-start-overlay) - Automatically show overlay on MPV connection
|
|
||||||
- [**Startup Warmups**](#startup-warmups) - Control what preloads on startup vs first-use defer
|
|
||||||
- [**WebSocket Server**](#websocket-server) - Built-in subtitle broadcasting server
|
|
||||||
- [**Texthooker**](#texthooker) - Control browser opening behavior
|
|
||||||
|
|
||||||
**Subtitle Display**
|
|
||||||
|
|
||||||
- [**Subtitle Style**](#subtitle-style) - Appearance customization
|
|
||||||
- [**Subtitle Position**](#subtitle-position) - Overlay vertical positioning
|
|
||||||
- [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support
|
|
||||||
|
|
||||||
**Keyboard & Controls**
|
|
||||||
|
|
||||||
- [**Keybindings**](#keybindings) - MPV command shortcuts
|
|
||||||
- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts
|
|
||||||
- [**Manual Card Update Shortcuts**](#manual-card-update-shortcuts) - Shortcuts for manual Anki card workflows
|
|
||||||
- [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference
|
|
||||||
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
|
|
||||||
|
|
||||||
**Anki Integration**
|
|
||||||
|
|
||||||
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
|
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
|
||||||
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types
|
- [**Auto-Start Overlay**](#auto-start-overlay) - Automatically show overlay on MPV connection
|
||||||
- [**N+1 Word Highlighting**](#n1-word-highlighting) - Known-word cache and single-target highlighting
|
- [**Visible Overlay Subtitle Binding**](#visible-overlay-subtitle-binding) - Link visible overlay toggles to MPV subtitle visibility
|
||||||
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
|
|
||||||
|
|
||||||
**External Integrations**
|
|
||||||
|
|
||||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
|
||||||
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
||||||
|
- [**Subtitle Position Edit**](#subtitle-position-edit) - Fine-tune subtitle alignment in overlay
|
||||||
|
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||||
- [**AniList**](#anilist) - Optional post-watch progress updates
|
- [**AniList**](#anilist) - Optional post-watch progress updates
|
||||||
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
|
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
|
||||||
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
|
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
|
||||||
|
- [**Keybindings**](#keybindings) - MPV command shortcuts
|
||||||
|
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
|
||||||
|
- [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support
|
||||||
|
- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts
|
||||||
|
- [**Subtitle Position**](#subtitle-position) - Overlay vertical positioning
|
||||||
|
- [**Subtitle Style**](#subtitle-style) - Appearance customization
|
||||||
|
- [**Texthooker**](#texthooker) - Control browser opening behavior
|
||||||
|
- [**WebSocket Server**](#websocket-server) - Built-in subtitle broadcasting server
|
||||||
|
- [**Startup Warmups**](#startup-warmups) - Control what preloads on startup vs first-use defer
|
||||||
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
|
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
|
||||||
- [**YouTube Subtitle Generation**](#youtube-subtitle-generation) - Launcher defaults for yt-dlp + local whisper fallback
|
- [**YouTube Subtitle Generation**](#youtube-subtitle-generation) - Launcher defaults for yt-dlp + local whisper fallback
|
||||||
|
|
||||||
## Core Settings
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
|
|
||||||
Control the minimum log level for runtime output:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"logging": {
|
|
||||||
"level": "info"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| ------- | ----------------------------------- | ------------------------------------------------ |
|
|
||||||
| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) |
|
|
||||||
|
|
||||||
### Auto-Start Overlay
|
|
||||||
|
|
||||||
Control whether the overlay automatically becomes visible when it connects to mpv:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"auto_start_overlay": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| -------------------- | --------------- | ------------------------------------------------------ |
|
|
||||||
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) |
|
|
||||||
|
|
||||||
The mpv plugin controls startup overlay visibility via `auto_start_visible_overlay` in `subminer.conf`.
|
|
||||||
For wrapper-driven playback, `subminer.conf` can also enable startup pause gating with
|
|
||||||
`auto_start_pause_until_ready` (requires `auto_start=yes` + `auto_start_visible_overlay=yes`).
|
|
||||||
Current plugin defaults in `subminer.conf` are:
|
|
||||||
|
|
||||||
- `auto_start=yes`
|
|
||||||
- `auto_start_visible_overlay=yes`
|
|
||||||
- `auto_start_pause_until_ready=yes`
|
|
||||||
|
|
||||||
### Startup Warmups
|
|
||||||
|
|
||||||
Control which startup warmups run in the background versus deferring to first real usage:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"startupWarmups": {
|
|
||||||
"lowPowerMode": false,
|
|
||||||
"mecab": true,
|
|
||||||
"yomitanExtension": true,
|
|
||||||
"subtitleDictionaries": true,
|
|
||||||
"jellyfinRemoteSession": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| ----------------------- | --------------- | ------------------------------------------------------------------------------------------------- |
|
|
||||||
| `lowPowerMode` | `true`, `false` | Defer all warmups except Yomitan extension |
|
|
||||||
| `mecab` | `true`, `false` | Warm up MeCab tokenizer at startup |
|
|
||||||
| `yomitanExtension` | `true`, `false` | Warm up Yomitan extension at startup |
|
|
||||||
| `subtitleDictionaries` | `true`, `false` | Warm up JLPT + frequency dictionaries at startup |
|
|
||||||
| `jellyfinRemoteSession` | `true`, `false` | Warm up Jellyfin remote session at startup (still requires Jellyfin remote auto-connect settings) |
|
|
||||||
|
|
||||||
Defaults warm everything (`true` for all toggles, `lowPowerMode: false`). Setting a warmup toggle to `false` defers that work until first usage.
|
|
||||||
|
|
||||||
### WebSocket Server
|
|
||||||
|
|
||||||
The overlay includes a built-in WebSocket server that broadcasts subtitle text to connected clients (such as texthooker-ui) for external processing.
|
|
||||||
|
|
||||||
By default, the server uses "auto" mode: it starts automatically unless [mpv_websocket](https://github.com/kuroahna/mpv_websocket) is detected at `~/.config/mpv/mpv_websocket`. If you have mpv_websocket installed, the built-in server is skipped to avoid conflicts.
|
|
||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"websocket": {
|
|
||||||
"enabled": "auto",
|
|
||||||
"port": 6677
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| --------- | ------------------------- | -------------------------------------------------------- |
|
|
||||||
| `enabled` | `true`, `false`, `"auto"` | `"auto"` (default) disables if mpv_websocket is detected |
|
|
||||||
| `port` | number | WebSocket server port (default: 6677) |
|
|
||||||
|
|
||||||
### Texthooker
|
|
||||||
|
|
||||||
Control whether the browser opens automatically when texthooker starts:
|
|
||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"texthooker": {
|
|
||||||
"openBrowser": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Subtitle Display
|
|
||||||
|
|
||||||
### Subtitle Style
|
|
||||||
|
|
||||||
Customize the appearance of primary and secondary subtitles:
|
|
||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"subtitleStyle": {
|
|
||||||
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP",
|
|
||||||
"fontSize": 35,
|
|
||||||
"fontColor": "#cad3f5",
|
|
||||||
"fontWeight": "600",
|
|
||||||
"lineHeight": 1.35,
|
|
||||||
"letterSpacing": "-0.01em",
|
|
||||||
"wordSpacing": 0,
|
|
||||||
"fontKerning": "normal",
|
|
||||||
"textRendering": "geometricPrecision",
|
|
||||||
"textShadow": "0 3px 10px rgba(0,0,0,0.69)",
|
|
||||||
"fontStyle": "normal",
|
|
||||||
"backgroundColor": "rgb(30, 32, 48, 0.88)",
|
|
||||||
"backdropFilter": "blur(6px)",
|
|
||||||
"secondary": {
|
|
||||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif",
|
|
||||||
"fontSize": 24,
|
|
||||||
"fontColor": "#cad3f5",
|
|
||||||
"backgroundColor": "transparent"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| ---------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `fontFamily` | string | CSS font-family value (default: `"M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP"`) |
|
|
||||||
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
|
||||||
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
|
||||||
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
|
||||||
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
|
||||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
|
|
||||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
|
||||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
|
||||||
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
|
||||||
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
|
||||||
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
|
|
||||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
|
||||||
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
|
||||||
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
|
|
||||||
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
|
||||||
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
|
||||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
|
||||||
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
|
||||||
| `nPlusOneColor` | string | Existing n+1 highlight color (default: `#c6a0f6`) |
|
|
||||||
| `knownWordColor` | string | Existing known-word highlight color (default: `#a6da95`) |
|
|
||||||
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
|
||||||
| `secondary` | object | Override any of the above for secondary subtitles (optional) |
|
|
||||||
|
|
||||||
JLPT underlining is powered by offline term-meta bank files at runtime. See [`docs/jlpt-vocab-bundle.md`](jlpt-vocab-bundle.md) for required files, source/version refresh steps, and deterministic fallback behavior.
|
|
||||||
|
|
||||||
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
|
|
||||||
|
|
||||||
Lookup behavior:
|
|
||||||
|
|
||||||
- Set `frequencyDictionary.sourcePath` to a directory containing `term_meta_bank_*.json` for a fully custom source.
|
|
||||||
- If `sourcePath` is missing or empty, SubMiner searches default install/runtime locations for `frequency-dictionary` directories (for example app resources, user data paths, and current working directory).
|
|
||||||
- In both cases, only terms with a valid `frequencyRank` are used; everything else falls back to no highlighting.
|
|
||||||
- `frequencyDictionary.matchMode` controls which token text is used for frequency lookups: `headword` (dictionary form) or `surface` (visible subtitle text).
|
|
||||||
- Frequency highlighting skips tokens that look like non-lexical SFX/interjection noise (for example kana reduplication or short kana endings like `っ`), even when dictionary ranks exist.
|
|
||||||
|
|
||||||
In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window.
|
|
||||||
|
|
||||||
Secondary subtitle defaults: `fontFamily: "Inter, Noto Sans, Helvetica Neue, sans-serif"`, `fontSize: 24`, `fontColor: "#cad3f5"`, `backgroundColor: "transparent"`. Any property not set in `secondary` falls back to the CSS defaults.
|
|
||||||
|
|
||||||
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
|
|
||||||
|
|
||||||
`jlptColors` keys are:
|
|
||||||
|
|
||||||
| Key | Default | Description |
|
|
||||||
| ---- | --------- | ----------------------- |
|
|
||||||
| `N1` | `#ed8796` | JLPT N1 underline color |
|
|
||||||
| `N2` | `#f5a97f` | JLPT N2 underline color |
|
|
||||||
| `N3` | `#f9e2af` | JLPT N3 underline color |
|
|
||||||
| `N4` | `#a6e3a1` | JLPT N4 underline color |
|
|
||||||
| `N5` | `#8aadf4` | JLPT N5 underline color |
|
|
||||||
|
|
||||||
**Image Quality Notes:**
|
|
||||||
|
|
||||||
- `imageQuality` affects JPG and WebP only; PNG is lossless and ignores this setting
|
|
||||||
- JPG quality is mapped to FFmpeg's scale (2-31, lower = better)
|
|
||||||
- WebP quality uses FFmpeg's native 0-100 scale
|
|
||||||
|
|
||||||
### Subtitle Position
|
|
||||||
|
|
||||||
Set the initial vertical subtitle position (measured from the bottom of the screen):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"subtitlePosition": {
|
|
||||||
"yPercent": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| ---------- | ---------------- | ---------------------------------------------------------------------- |
|
|
||||||
| `yPercent` | number (0 - 100) | Distance from the bottom as a percent of screen height (default: `10`) |
|
|
||||||
In the overlay, you can fine-tune subtitle position at runtime with `Right-click + drag` on subtitle text.
|
|
||||||
|
|
||||||
### Secondary Subtitles
|
|
||||||
|
|
||||||
Display a second subtitle track (e.g., English alongside Japanese) in the overlay:
|
|
||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"secondarySub": {
|
|
||||||
"secondarySubLanguages": ["eng", "en"],
|
|
||||||
"autoLoadSecondarySub": true,
|
|
||||||
"defaultMode": "hover"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------ |
|
|
||||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) |
|
|
||||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
|
||||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
|
||||||
|
|
||||||
**Display modes:**
|
|
||||||
|
|
||||||
- **hidden** — Secondary subtitles not shown
|
|
||||||
- **visible** — Always visible at top of overlay
|
|
||||||
- **hover** — Only visible when hovering over the subtitle area (default)
|
|
||||||
|
|
||||||
**See `config.example.jsonc`** for additional secondary subtitle configuration options.
|
|
||||||
|
|
||||||
## Keyboard & Controls
|
|
||||||
|
|
||||||
### Keybindings
|
|
||||||
|
|
||||||
Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv:
|
|
||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options and more examples.
|
|
||||||
|
|
||||||
**Default keybindings:**
|
|
||||||
|
|
||||||
| Key | Command | Description |
|
|
||||||
| ----------------- | ---------------------------- | ------------------------------------- |
|
|
||||||
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
|
||||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
|
||||||
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
|
||||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
|
||||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
|
||||||
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
|
|
||||||
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
|
||||||
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
|
||||||
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
|
||||||
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
|
||||||
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
|
||||||
| `KeyQ` | `["quit"]` | Quit mpv |
|
|
||||||
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
|
|
||||||
|
|
||||||
**Custom keybindings example:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"keybindings": [
|
|
||||||
{ "key": "ArrowRight", "command": ["seek", 5] },
|
|
||||||
{ "key": "ArrowLeft", "command": ["seek", -5] },
|
|
||||||
{ "key": "Shift+ArrowRight", "command": ["seek", 30] },
|
|
||||||
{ "key": "KeyR", "command": ["script-binding", "immersive/auto-replay"] },
|
|
||||||
{ "key": "KeyA", "command": ["script-message", "ankiconnect-add-note"] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key format:** Use `KeyboardEvent.code` values (`Space`, `ArrowRight`, `KeyR`, etc.) with optional modifiers (`Ctrl+`, `Alt+`, `Shift+`, `Meta+`).
|
|
||||||
|
|
||||||
**Disable a default binding:** Set command to `null`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "key": "Space", "command": null }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
|
||||||
|
|
||||||
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
|
|
||||||
|
|
||||||
For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`), SubMiner also shows an mpv OSD notification after the command runs.
|
|
||||||
|
|
||||||
**See `config.example.jsonc`** for more keybinding examples and configuration options.
|
|
||||||
|
|
||||||
### Shortcuts Configuration
|
|
||||||
|
|
||||||
Customize or disable the overlay keyboard shortcuts:
|
|
||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"shortcuts": {
|
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
|
||||||
"copySubtitle": "CommandOrControl+C",
|
|
||||||
"copySubtitleMultiple": "CommandOrControl+Shift+C",
|
|
||||||
"updateLastCardFromClipboard": "CommandOrControl+V",
|
|
||||||
"triggerFieldGrouping": "CommandOrControl+G",
|
|
||||||
"triggerSubsync": "Ctrl+Alt+S",
|
|
||||||
"mineSentence": "CommandOrControl+S",
|
|
||||||
"mineSentenceMultiple": "CommandOrControl+Shift+S",
|
|
||||||
"markAudioCard": "CommandOrControl+Shift+A",
|
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O",
|
|
||||||
"openJimaku": "Ctrl+Shift+J",
|
|
||||||
"multiCopyTimeoutMs": 3000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| ----------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
|
||||||
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
|
||||||
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
|
|
||||||
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
|
||||||
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when `behavior.autoUpdateNewCards` is `false`) |
|
|
||||||
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
|
|
||||||
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
|
|
||||||
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
|
|
||||||
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
|
|
||||||
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
|
||||||
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
|
||||||
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
|
||||||
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
|
||||||
|
|
||||||
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
|
||||||
|
|
||||||
Set any shortcut to `null` to disable it.
|
|
||||||
|
|
||||||
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
|
|
||||||
|
|
||||||
### Manual Card Update Shortcuts
|
|
||||||
|
|
||||||
When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
|
|
||||||
|
|
||||||
| Shortcut | Action |
|
|
||||||
| -------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| `Ctrl+C` | Copy the current subtitle line to clipboard (preserves line breaks) |
|
|
||||||
| `Ctrl+Shift+C` | Enter multi-copy mode. Press `1-9` to copy that many recent lines, or `Esc` to cancel. Timeout: 3 seconds |
|
|
||||||
| `Ctrl+V` | Update the last added Anki card using subtitles from clipboard |
|
|
||||||
| `Ctrl+G` | Trigger Kiku duplicate field grouping for the last added card (only when `behavior.autoUpdateNewCards` is `false`) |
|
|
||||||
| `Ctrl+S` | Create a sentence card from the current subtitle line |
|
|
||||||
| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel |
|
|
||||||
| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) |
|
|
||||||
| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
|
|
||||||
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
|
|
||||||
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
|
|
||||||
|
|
||||||
**Multi-line copy workflow:**
|
|
||||||
|
|
||||||
1. Press `Ctrl+Shift+C`
|
|
||||||
2. Press a number key (`1-9`) within 3 seconds
|
|
||||||
3. The specified number of most recent subtitle lines are copied
|
|
||||||
4. Press `Ctrl+V` to update the last added card with the copied lines
|
|
||||||
|
|
||||||
These shortcuts are only active when the overlay window is visible and automatically disabled when hidden.
|
|
||||||
|
|
||||||
### Session Help Modal
|
|
||||||
|
|
||||||
The session help modal is opened with `Y-H` by default (falls back to `Y-K` if needed) and shows the current session keybindings and color legend.
|
|
||||||
|
|
||||||
You can filter the modal quickly with `/`:
|
|
||||||
|
|
||||||
- Type any part of the action name or shortcut in the search bar.
|
|
||||||
- Search is case-insensitive and ignores spaces/punctuation (`+`, `-`, `_`, `/`) so `ctrl w`, `ctrl+w`, and `ctrl+s` all match.
|
|
||||||
- Results are filtered across active MPV shortcuts, configured overlay shortcuts, and color legend items.
|
|
||||||
|
|
||||||
While the modal is open:
|
|
||||||
|
|
||||||
- `Esc`: close the modal (or clear the filter when text is entered)
|
|
||||||
- `↑/↓`, `j/k`: move selection
|
|
||||||
- Mouse/trackpad: click to select and activate rows
|
|
||||||
|
|
||||||
The list is generated at runtime from:
|
|
||||||
|
|
||||||
- Your active mpv keybindings (`keybindings`).
|
|
||||||
- Your configured overlay shortcuts (`shortcuts`, including runtime-loaded config values).
|
|
||||||
- Current subtitle color settings from `subtitleStyle`.
|
|
||||||
|
|
||||||
When config hot-reload updates shortcut/keybinding/style values, close and reopen the help modal to refresh the displayed entries.
|
|
||||||
|
|
||||||
### Runtime Option Palette
|
|
||||||
|
|
||||||
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
|
||||||
|
|
||||||
Current runtime options:
|
|
||||||
|
|
||||||
- `ankiConnect.behavior.autoUpdateNewCards` (`On` / `Off`)
|
|
||||||
- `ankiConnect.nPlusOne.highlightEnabled` (`On` / `Off`)
|
|
||||||
- `subtitleStyle.enableJlpt` (`On` / `Off`)
|
|
||||||
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
|
|
||||||
- `ankiConnect.nPlusOne.matchMode` (`headword` / `surface`)
|
|
||||||
- `ankiConnect.isKiku.fieldGrouping` (`auto` / `manual` / `disabled`)
|
|
||||||
|
|
||||||
Annotation toggles (`nPlusOne`, `enableJlpt`, `frequencyDictionary.enabled`) only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
|
||||||
|
|
||||||
Default shortcut: `Ctrl+Shift+O`
|
|
||||||
|
|
||||||
Palette controls:
|
|
||||||
|
|
||||||
- `Arrow Up/Down`: select option
|
|
||||||
- `Arrow Left/Right`: change selected value
|
|
||||||
- `Enter`: apply selected value
|
|
||||||
- `Esc`: close
|
|
||||||
|
|
||||||
## Anki Integration
|
|
||||||
|
|
||||||
### AnkiConnect
|
### AnkiConnect
|
||||||
|
|
||||||
Enable automatic Anki card creation and updates with media generation:
|
Enable automatic Anki card creation and updates with media generation:
|
||||||
@@ -612,11 +172,11 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `false`) |
|
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `false`) |
|
||||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||||
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||||
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
||||||
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
||||||
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||||
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||||
| `deck` | string | Anki deck to monitor for new cards |
|
| `deck` | string | Anki deck to monitor for new cards |
|
||||||
| `ankiConnect.nPlusOne.decks` | array of strings | Decks used for N+1 known-word cache lookups. When omitted/empty, falls back to `ankiConnect.deck`. |
|
| `ankiConnect.nPlusOne.decks` | array of strings | Decks used for N+1 known-word cache lookups. When omitted/empty, falls back to `ankiConnect.deck`. |
|
||||||
@@ -663,28 +223,13 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
||||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||||
|
|
||||||
### Kiku/Lapis Integration
|
**Kiku / Lapis Note Type Support:**
|
||||||
|
|
||||||
SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) workflows, with note-type-specific behavior built into Anki settings.
|
SubMiner supports the [Lapis](https://github.com/donkuri/lapis) and [Kiku](https://kiku.youyoumu.my.id/) note types. Both `isLapis.enabled` and `isKiku.enabled` can be true; Kiku takes precedence for grouping behavior, while sentence-card model/field settings come from `isLapis`.
|
||||||
|
|
||||||
```jsonc
|
When enabled, sentence cards automatically set `IsSentenceCard` to `"x"` and populate the `Expression` field. Audio cards set `IsAudioCard` to `"x"`.
|
||||||
"ankiConnect": {
|
|
||||||
"isLapis": {
|
|
||||||
"enabled": true,
|
|
||||||
"sentenceCardModel": "Japanese sentences"
|
|
||||||
},
|
|
||||||
"isKiku": {
|
|
||||||
"enabled": true,
|
|
||||||
"fieldGrouping": "manual",
|
|
||||||
"deleteDuplicateInAuto": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Enable `isLapis` to mine dedicated sentence cards. SubMiner sets `IsSentenceCard` to `"x"` and fills the sentence fields for the configured model.
|
Kiku extends Lapis with **field grouping** — when a duplicate card is detected (same Word/Expression), SubMiner merges the two cards' content into one using Kiku's `data-group-id` HTML structure, organizing each mining instance into separate pages within the note.
|
||||||
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
|
|
||||||
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
|
|
||||||
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
|
|
||||||
|
|
||||||
### N+1 Word Highlighting
|
### N+1 Word Highlighting
|
||||||
|
|
||||||
@@ -736,27 +281,77 @@ To refresh roughly once per day, set:
|
|||||||
|
|
||||||
<a :href="'/assets/kiku-integration.webm'" target="_blank" rel="noreferrer">Open demo in a new tab</a>
|
<a :href="'/assets/kiku-integration.webm'" target="_blank" rel="noreferrer">Open demo in a new tab</a>
|
||||||
|
|
||||||
## External Integrations
|
**Image Quality Notes:**
|
||||||
|
|
||||||
### Jimaku
|
- `imageQuality` affects JPG and WebP only; PNG is lossless and ignores this setting
|
||||||
|
- JPG quality is mapped to FFmpeg's scale (2-31, lower = better)
|
||||||
|
- WebP quality uses FFmpeg's native 0-100 scale
|
||||||
|
|
||||||
Configure Jimaku API access and defaults:
|
### Manual Card Update Shortcuts
|
||||||
|
|
||||||
|
When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| -------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `Ctrl+C` | Copy the current subtitle line to clipboard (preserves line breaks) |
|
||||||
|
| `Ctrl+Shift+C` | Enter multi-copy mode. Press `1-9` to copy that many recent lines, or `Esc` to cancel. Timeout: 3 seconds |
|
||||||
|
| `Ctrl+V` | Update the last added Anki card using subtitles from clipboard |
|
||||||
|
| `Ctrl+G` | Trigger Kiku duplicate field grouping for the last added card (only when `behavior.autoUpdateNewCards` is `false`) |
|
||||||
|
| `Ctrl+S` | Create a sentence card from the current subtitle line |
|
||||||
|
| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel |
|
||||||
|
| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) |
|
||||||
|
| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
|
||||||
|
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
|
||||||
|
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
|
||||||
|
|
||||||
|
**Multi-line copy workflow:**
|
||||||
|
|
||||||
|
1. Press `Ctrl+Shift+C`
|
||||||
|
2. Press a number key (`1-9`) within 3 seconds
|
||||||
|
3. The specified number of most recent subtitle lines are copied
|
||||||
|
4. Press `Ctrl+V` to update the last added card with the copied lines
|
||||||
|
|
||||||
|
These shortcuts are only active when the overlay window is visible and automatically disabled when hidden.
|
||||||
|
|
||||||
|
### Session help modal
|
||||||
|
|
||||||
|
The session help modal is opened with `Y-H` by default (falls back to `Y-K` if needed) and shows the current session keybindings and color legend.
|
||||||
|
|
||||||
|
You can filter the modal quickly with `/`:
|
||||||
|
|
||||||
|
- Type any part of the action name or shortcut in the search bar.
|
||||||
|
- Search is case-insensitive and ignores spaces/punctuation (`+`, `-`, `_`, `/`) so `ctrl w`, `ctrl+w`, and `ctrl+s` all match.
|
||||||
|
- Results are filtered across active MPV shortcuts, configured overlay shortcuts, and color legend items.
|
||||||
|
|
||||||
|
While the modal is open:
|
||||||
|
|
||||||
|
- `Esc`: close the modal (or clear the filter when text is entered)
|
||||||
|
- `↑/↓`, `j/k`: move selection
|
||||||
|
- Mouse/trackpad: click to select and activate rows
|
||||||
|
|
||||||
|
The list is generated at runtime from:
|
||||||
|
|
||||||
|
- Your active mpv keybindings (`keybindings`).
|
||||||
|
- Your configured overlay shortcuts (`shortcuts`, including runtime-loaded config values).
|
||||||
|
- Current subtitle color settings from `subtitleStyle`.
|
||||||
|
|
||||||
|
When config hot-reload updates shortcut/keybinding/style values, close and reopen the help modal to refresh the displayed entries.
|
||||||
|
|
||||||
|
### Auto-Start Overlay
|
||||||
|
|
||||||
|
Control whether the overlay automatically becomes visible when it connects to mpv:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"jimaku": {
|
"auto_start_overlay": false
|
||||||
"apiKey": "YOUR_API_KEY",
|
|
||||||
"apiKeyCommand": "cat ~/.jimaku_key",
|
|
||||||
"apiBaseUrl": "https://jimaku.cc",
|
|
||||||
"languagePreference": "ja",
|
|
||||||
"maxEntryResults": 10
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
|
| Option | Values | Description |
|
||||||
|
| -------------------- | --------------- | ------------------------------------------------------ |
|
||||||
|
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) |
|
||||||
|
|
||||||
Set `openBrowser` to `false` to only print the URL without opening a browser.
|
The mpv plugin controls startup overlay visibility via `auto_start_visible_overlay` in `subminer.conf`.
|
||||||
|
|
||||||
### Auto Subtitle Sync
|
### Auto Subtitle Sync
|
||||||
|
|
||||||
@@ -783,6 +378,35 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
|
|||||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||||
Customize it there, or set it to `null` to disable.
|
Customize it there, or set it to `null` to disable.
|
||||||
|
|
||||||
|
### Subtitle Position Edit
|
||||||
|
|
||||||
|
Subtitle positioning can be adjusted directly in the overlay:
|
||||||
|
|
||||||
|
- `Ctrl/Cmd+Shift+P` toggles position edit mode.
|
||||||
|
- Use arrow keys to move subtitle text.
|
||||||
|
- Press `Enter` or `Ctrl/Cmd+S` to save, or `Esc` to cancel.
|
||||||
|
- This edit-mode shortcut is fixed (not currently configurable in `shortcuts`/`keybindings`).
|
||||||
|
|
||||||
|
### Jimaku
|
||||||
|
|
||||||
|
Configure Jimaku API access and defaults:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jimaku": {
|
||||||
|
"apiKey": "YOUR_API_KEY",
|
||||||
|
"apiKeyCommand": "cat ~/.jimaku_key",
|
||||||
|
"apiBaseUrl": "https://jimaku.cc",
|
||||||
|
"languagePreference": "ja",
|
||||||
|
"maxEntryResults": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
|
||||||
|
|
||||||
|
Set `openBrowser` to `false` to only print the URL without opening a browser.
|
||||||
|
|
||||||
### AniList
|
### AniList
|
||||||
|
|
||||||
AniList integration is opt-in and disabled by default. Enable it to allow SubMiner to update watched episode progress after playback.
|
AniList integration is opt-in and disabled by default. Enable it to allow SubMiner to update watched episode progress after playback.
|
||||||
@@ -875,7 +499,6 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
|||||||
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
||||||
|
|
||||||
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup.
|
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup.
|
||||||
|
|
||||||
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
||||||
|
|
||||||
Launcher subcommands:
|
Launcher subcommands:
|
||||||
@@ -884,7 +507,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 in background/tray mode.
|
- `subminer jellyfin -d` starts cast discovery 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.
|
||||||
@@ -930,6 +553,308 @@ Troubleshooting:
|
|||||||
- If images do not render, confirm asset keys exactly match uploaded Discord asset names.
|
- If images do not render, confirm asset keys exactly match uploaded Discord asset names.
|
||||||
- If Discord is closed/not installed/disconnects, SubMiner continues running and quietly skips presence updates.
|
- If Discord is closed/not installed/disconnects, SubMiner continues running and quietly skips presence updates.
|
||||||
|
|
||||||
|
### Keybindings
|
||||||
|
|
||||||
|
Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv:
|
||||||
|
|
||||||
|
See `config.example.jsonc` for detailed configuration options and more examples.
|
||||||
|
|
||||||
|
**Default keybindings:**
|
||||||
|
|
||||||
|
| Key | Command | Description |
|
||||||
|
| ----------------- | -------------------------- | ------------------------------------- |
|
||||||
|
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
||||||
|
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||||
|
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
||||||
|
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
|
||||||
|
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
||||||
|
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
||||||
|
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
||||||
|
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
||||||
|
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
||||||
|
| `KeyQ` | `["quit"]` | Quit mpv |
|
||||||
|
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
|
||||||
|
|
||||||
|
**Custom keybindings example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"keybindings": [
|
||||||
|
{ "key": "ArrowRight", "command": ["seek", 5] },
|
||||||
|
{ "key": "ArrowLeft", "command": ["seek", -5] },
|
||||||
|
{ "key": "Shift+ArrowRight", "command": ["seek", 30] },
|
||||||
|
{ "key": "KeyR", "command": ["script-binding", "immersive/auto-replay"] },
|
||||||
|
{ "key": "KeyA", "command": ["script-message", "ankiconnect-add-note"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key format:** Use `KeyboardEvent.code` values (`Space`, `ArrowRight`, `KeyR`, etc.) with optional modifiers (`Ctrl+`, `Alt+`, `Shift+`, `Meta+`).
|
||||||
|
|
||||||
|
**Disable a default binding:** Set command to `null`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "key": "Space", "command": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
||||||
|
|
||||||
|
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
|
||||||
|
|
||||||
|
**See `config.example.jsonc`** for more keybinding examples and configuration options.
|
||||||
|
|
||||||
|
### Runtime Option Palette
|
||||||
|
|
||||||
|
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
||||||
|
|
||||||
|
Current runtime options:
|
||||||
|
|
||||||
|
- `ankiConnect.behavior.autoUpdateNewCards` (`On` / `Off`)
|
||||||
|
- `ankiConnect.isKiku.fieldGrouping` (`auto` / `manual` / `disabled`)
|
||||||
|
|
||||||
|
Default shortcut: `Ctrl+Shift+O`
|
||||||
|
|
||||||
|
Palette controls:
|
||||||
|
|
||||||
|
- `Arrow Up/Down`: select option
|
||||||
|
- `Arrow Left/Right`: change selected value
|
||||||
|
- `Enter`: apply selected value
|
||||||
|
- `Esc`: close
|
||||||
|
|
||||||
|
### Secondary Subtitles
|
||||||
|
|
||||||
|
Display a second subtitle track (e.g., English alongside Japanese) in the overlay:
|
||||||
|
|
||||||
|
See `config.example.jsonc` for detailed configuration options.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"secondarySub": {
|
||||||
|
"secondarySubLanguages": ["eng", "en"],
|
||||||
|
"autoLoadSecondarySub": true,
|
||||||
|
"defaultMode": "hover"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| ----------------------- | ---------------------------------- | ------------------------------------------------------ |
|
||||||
|
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) |
|
||||||
|
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
||||||
|
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||||
|
|
||||||
|
**Display modes:**
|
||||||
|
|
||||||
|
- **hidden** — Secondary subtitles not shown
|
||||||
|
- **visible** — Always visible at top of overlay
|
||||||
|
- **hover** — Only visible when hovering over the subtitle area (default)
|
||||||
|
|
||||||
|
**See `config.example.jsonc`** for additional secondary subtitle configuration options.
|
||||||
|
|
||||||
|
### Shortcuts Configuration
|
||||||
|
|
||||||
|
Customize or disable the overlay keyboard shortcuts:
|
||||||
|
|
||||||
|
See `config.example.jsonc` for detailed configuration options.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"shortcuts": {
|
||||||
|
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
||||||
|
"copySubtitle": "CommandOrControl+C",
|
||||||
|
"copySubtitleMultiple": "CommandOrControl+Shift+C",
|
||||||
|
"updateLastCardFromClipboard": "CommandOrControl+V",
|
||||||
|
"triggerFieldGrouping": "CommandOrControl+G",
|
||||||
|
"triggerSubsync": "Ctrl+Alt+S",
|
||||||
|
"mineSentence": "CommandOrControl+S",
|
||||||
|
"mineSentenceMultiple": "CommandOrControl+Shift+S",
|
||||||
|
"markAudioCard": "CommandOrControl+Shift+A",
|
||||||
|
"openRuntimeOptions": "CommandOrControl+Shift+O",
|
||||||
|
"openJimaku": "Ctrl+Shift+J",
|
||||||
|
"multiCopyTimeoutMs": 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| ------------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
||||||
|
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
||||||
|
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
|
||||||
|
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
||||||
|
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when `behavior.autoUpdateNewCards` is `false`) |
|
||||||
|
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
|
||||||
|
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
|
||||||
|
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
|
||||||
|
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
|
||||||
|
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
||||||
|
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
||||||
|
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
||||||
|
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
||||||
|
|
||||||
|
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
||||||
|
|
||||||
|
Set any shortcut to `null` to disable it.
|
||||||
|
|
||||||
|
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
|
||||||
|
|
||||||
|
### Subtitle Position
|
||||||
|
|
||||||
|
Set the initial vertical subtitle position (measured from the bottom of the screen):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subtitlePosition": {
|
||||||
|
"yPercent": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| ---------- | ---------------- | ---------------------------------------------------------------------- |
|
||||||
|
| `yPercent` | number (0 - 100) | Distance from the bottom as a percent of screen height (default: `10`) |
|
||||||
|
|
||||||
|
### Subtitle Style
|
||||||
|
|
||||||
|
Customize the appearance of primary and secondary subtitles:
|
||||||
|
|
||||||
|
See `config.example.jsonc` for detailed configuration options.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP",
|
||||||
|
"fontSize": 35,
|
||||||
|
"fontColor": "#cad3f5",
|
||||||
|
"fontWeight": "600",
|
||||||
|
"lineHeight": 1.35,
|
||||||
|
"letterSpacing": "-0.01em",
|
||||||
|
"wordSpacing": 0,
|
||||||
|
"fontKerning": "normal",
|
||||||
|
"textRendering": "geometricPrecision",
|
||||||
|
"textShadow": "0 3px 10px rgba(0,0,0,0.69)",
|
||||||
|
"fontStyle": "normal",
|
||||||
|
"backgroundColor": "rgb(30, 32, 48, 0.88)",
|
||||||
|
"backdropFilter": "blur(6px)",
|
||||||
|
"secondary": {
|
||||||
|
"fontFamily": "Manrope, Inter",
|
||||||
|
"fontSize": 24,
|
||||||
|
"fontColor": "#cad3f5",
|
||||||
|
"backgroundColor": "transparent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| ---------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `fontFamily` | string | CSS font-family value (default: `"M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP"`) |
|
||||||
|
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
||||||
|
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
||||||
|
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
||||||
|
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
||||||
|
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
|
||||||
|
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||||
|
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||||
|
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||||
|
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
||||||
|
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
|
||||||
|
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
||||||
|
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
||||||
|
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
||||||
|
| `nPlusOneColor` | string | Existing n+1 highlight color (default: `#c6a0f6`) |
|
||||||
|
| `knownWordColor` | string | Existing known-word highlight color (default: `#a6da95`) |
|
||||||
|
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||||
|
| `secondary` | object | Override any of the above for secondary subtitles (optional) |
|
||||||
|
|
||||||
|
JLPT underlining is powered by offline term-meta bank files at runtime. See [`docs/jlpt-vocab-bundle.md`](jlpt-vocab-bundle.md) for required files, source/version refresh steps, and deterministic fallback behavior.
|
||||||
|
|
||||||
|
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
|
||||||
|
|
||||||
|
Lookup behavior:
|
||||||
|
|
||||||
|
- Set `frequencyDictionary.sourcePath` to a directory containing `term_meta_bank_*.json` for a fully custom source.
|
||||||
|
- If `sourcePath` is missing or empty, SubMiner searches default install/runtime locations for `frequency-dictionary` directories (for example app resources, user data paths, and current working directory).
|
||||||
|
- In both cases, only terms with a valid `frequencyRank` are used; everything else falls back to no highlighting.
|
||||||
|
|
||||||
|
In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window.
|
||||||
|
|
||||||
|
Secondary subtitle defaults: `fontFamily: "Manrope, Inter"`, `fontSize: 24`, `fontColor: "#cad3f5"`, `backgroundColor: "transparent"`. Any property not set in `secondary` falls back to the CSS defaults.
|
||||||
|
|
||||||
|
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
|
||||||
|
|
||||||
|
`jlptColors` keys are:
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
| ---- | --------- | ----------------------- |
|
||||||
|
| `N1` | `#ed8796` | JLPT N1 underline color |
|
||||||
|
| `N2` | `#f5a97f` | JLPT N2 underline color |
|
||||||
|
| `N3` | `#f9e2af` | JLPT N3 underline color |
|
||||||
|
| `N4` | `#a6e3a1` | JLPT N4 underline color |
|
||||||
|
| `N5` | `#8aadf4` | JLPT N5 underline color |
|
||||||
|
|
||||||
|
### Texthooker
|
||||||
|
|
||||||
|
Control whether the browser opens automatically when texthooker starts:
|
||||||
|
|
||||||
|
See `config.example.jsonc` for detailed configuration options.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"texthooker": {
|
||||||
|
"openBrowser": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket Server
|
||||||
|
|
||||||
|
The overlay includes a built-in WebSocket server that broadcasts subtitle text to connected clients (such as texthooker-ui) for external processing.
|
||||||
|
|
||||||
|
By default, the server uses "auto" mode: it starts automatically unless [mpv_websocket](https://github.com/kuroahna/mpv_websocket) is detected at `~/.config/mpv/mpv_websocket`. If you have mpv_websocket installed, the built-in server is skipped to avoid conflicts.
|
||||||
|
|
||||||
|
See `config.example.jsonc` for detailed configuration options.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"websocket": {
|
||||||
|
"enabled": "auto",
|
||||||
|
"port": 6677
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| --------- | ------------------------- | -------------------------------------------------------- |
|
||||||
|
| `enabled` | `true`, `false`, `"auto"` | `"auto"` (default) disables if mpv_websocket is detected |
|
||||||
|
| `port` | number | WebSocket server port (default: 6677) |
|
||||||
|
|
||||||
|
### Startup Warmups
|
||||||
|
|
||||||
|
Control which startup warmups run in the background versus deferring to first real usage:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"startupWarmups": {
|
||||||
|
"lowPowerMode": false,
|
||||||
|
"mecab": true,
|
||||||
|
"yomitanExtension": true,
|
||||||
|
"subtitleDictionaries": true,
|
||||||
|
"jellyfinRemoteSession": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| ------------------------ | --------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
|
| `lowPowerMode` | `true`, `false` | Defer all warmups except Yomitan extension |
|
||||||
|
| `mecab` | `true`, `false` | Warm up MeCab tokenizer at startup |
|
||||||
|
| `yomitanExtension` | `true`, `false` | Warm up Yomitan extension at startup |
|
||||||
|
| `subtitleDictionaries` | `true`, `false` | Warm up JLPT + frequency dictionaries at startup |
|
||||||
|
| `jellyfinRemoteSession` | `true`, `false` | Warm up Jellyfin remote session at startup (still requires Jellyfin remote auto-connect settings) |
|
||||||
|
|
||||||
|
Defaults warm everything (`true` for all toggles, `lowPowerMode: false`). Setting a warmup toggle to `false` defers that work until first usage.
|
||||||
|
|
||||||
### Immersion Tracking
|
### Immersion Tracking
|
||||||
|
|
||||||
Enable or disable local immersion analytics stored in SQLite for mined subtitles and media sessions:
|
Enable or disable local immersion analytics stored in SQLite for mined subtitles and media sessions:
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
# Feature Demos
|
|
||||||
|
|
||||||
Short recordings of SubMiner's key features and integrations from real playback sessions.
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
const v = '20260301-1';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
## Anki Card Mining & Enrichment
|
|
||||||
|
|
||||||
Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner automatically attaches the sentence, a timing-accurate audio clip, a screenshot, and a translation.
|
|
||||||
|
|
||||||
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`">
|
|
||||||
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
|
|
||||||
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
|
|
||||||
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
|
|
||||||
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
|
||||||
</a>
|
|
||||||
</video>
|
|
||||||
|
|
||||||
::: info VIDEO COMING SOON
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Subtitle Download & Sync
|
|
||||||
|
|
||||||
Search and download subtitles from Jimaku, then automatically synchronize them with alass or ffsubsync — all from within SubMiner.
|
|
||||||
|
|
||||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/subtitle-sync-poster.jpg?v=${v}`">
|
|
||||||
<source :src="`/assets/demos/subtitle-sync.webm?v=${v}`" type="video/webm" />
|
|
||||||
<source :src="`/assets/demos/subtitle-sync.mp4?v=${v}`" type="video/mp4" />
|
|
||||||
</video> -->
|
|
||||||
|
|
||||||
::: info VIDEO COMING SOON
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Jellyfin Integration
|
|
||||||
|
|
||||||
Browse your Jellyfin library, cast to devices, and launch playback directly from SubMiner. Watch progress syncs back to your Jellyfin server.
|
|
||||||
|
|
||||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/jellyfin-poster.jpg?v=${v}`">
|
|
||||||
<source :src="`/assets/demos/jellyfin.webm?v=${v}`" type="video/webm" />
|
|
||||||
<source :src="`/assets/demos/jellyfin.mp4?v=${v}`" type="video/mp4" />
|
|
||||||
</video> -->
|
|
||||||
|
|
||||||
::: info VIDEO COMING SOON
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Texthooker
|
|
||||||
|
|
||||||
Open subtitles in an external texthooker page for use with browser-based tools and extensions alongside the overlay.
|
|
||||||
|
|
||||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/texthooker-poster.jpg?v=${v}`">
|
|
||||||
<source :src="`/assets/demos/texthooker.webm?v=${v}`" type="video/webm" />
|
|
||||||
<source :src="`/assets/demos/texthooker.mp4?v=${v}`" type="video/mp4" />
|
|
||||||
</video> -->
|
|
||||||
|
|
||||||
::: info VIDEO COMING SOON
|
|
||||||
:::
|
|
||||||
|
|
||||||
<style>
|
|
||||||
video {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--vp-c-divider);
|
|
||||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
|
|
||||||
margin: 0.75rem 0 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-top: 2.5rem !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -6,14 +6,11 @@ SubMiner stores immersion analytics in local SQLite (`immersion.sqlite`) by defa
|
|||||||
|
|
||||||
- Write path is asynchronous and queue-backed.
|
- Write path is asynchronous and queue-backed.
|
||||||
- Hot paths (subtitle parsing/render/token flows) enqueue telemetry/events and never await SQLite writes.
|
- Hot paths (subtitle parsing/render/token flows) enqueue telemetry/events and never await SQLite writes.
|
||||||
- Background line processing also upserts to `imm_words` and `imm_kanji`.
|
|
||||||
- Queue overflow policy is deterministic: drop oldest queued writes, keep newest.
|
- Queue overflow policy is deterministic: drop oldest queued writes, keep newest.
|
||||||
- Flush policy defaults to `25` writes or `500ms` max delay.
|
- Flush policy defaults to `25` writes or `500ms` max delay.
|
||||||
- SQLite pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=2500`.
|
- SQLite pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=2500`.
|
||||||
- Rollups now run incrementally from the last processed telemetry sample; startup performs a one-time bootstrap rebuild-equivalent pass.
|
|
||||||
- If retention pruning removes telemetry/session rows, maintenance triggers a full rollup rebuild to resync historical aggregates.
|
|
||||||
|
|
||||||
## Schema (v3)
|
## Schema (v1)
|
||||||
|
|
||||||
Schema versioning table:
|
Schema versioning table:
|
||||||
|
|
||||||
@@ -21,21 +18,15 @@ Schema versioning table:
|
|||||||
|
|
||||||
Core entities:
|
Core entities:
|
||||||
|
|
||||||
- `imm_videos`: video key/title/source metadata + optional media metadata fields, `CREATED_DATE`/`LAST_UPDATE_DATE`
|
- `imm_videos`: video key/title/source metadata + optional media metadata fields
|
||||||
- `imm_sessions`: session UUID, video reference, timing/status fields, `CREATED_DATE`/`LAST_UPDATE_DATE`
|
- `imm_sessions`: session UUID, video reference, timing/status fields
|
||||||
- `imm_session_telemetry`: high-frequency session aggregates over time, `CREATED_DATE`/`LAST_UPDATE_DATE`
|
- `imm_session_telemetry`: high-frequency session aggregates over time
|
||||||
- `imm_session_events`: event stream with compact numeric event types, `CREATED_DATE`/`LAST_UPDATE_DATE`
|
- `imm_session_events`: event stream with compact numeric event types
|
||||||
|
|
||||||
Rollups:
|
Rollups:
|
||||||
|
|
||||||
- `imm_daily_rollups`: includes `CREATED_DATE`/`LAST_UPDATE_DATE`
|
- `imm_daily_rollups`
|
||||||
- `imm_monthly_rollups`: includes `CREATED_DATE`/`LAST_UPDATE_DATE`
|
- `imm_monthly_rollups`
|
||||||
|
|
||||||
Vocabulary:
|
|
||||||
|
|
||||||
- `imm_words(id, headword, word, reading, first_seen, last_seen, frequency)`
|
|
||||||
- `imm_kanji(id, kanji, first_seen, last_seen, frequency)`
|
|
||||||
- `first_seen`/`last_seen` store Unix timestamps and are upserted with line ingestion
|
|
||||||
|
|
||||||
Primary index coverage:
|
Primary index coverage:
|
||||||
|
|
||||||
@@ -156,3 +147,4 @@ FROM imm_monthly_rollups
|
|||||||
ORDER BY rollup_month DESC, video_id DESC
|
ORDER BY rollup_month DESC, video_id DESC
|
||||||
LIMIT ?;
|
LIMIT ?;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -6,19 +6,9 @@ const docsIndexContents = readFileSync(docsIndexPath, 'utf8');
|
|||||||
|
|
||||||
test('docs demo media uses shared cache-busting asset version token', () => {
|
test('docs demo media uses shared cache-busting asset version token', () => {
|
||||||
expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/);
|
expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/);
|
||||||
expect(docsIndexContents).toContain(
|
expect(docsIndexContents).toContain(':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"');
|
||||||
':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"',
|
expect(docsIndexContents).toContain('<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />');
|
||||||
);
|
expect(docsIndexContents).toContain('<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />');
|
||||||
expect(docsIndexContents).toContain(
|
expect(docsIndexContents).toContain('<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">');
|
||||||
'<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />',
|
expect(docsIndexContents).toContain('<img :src="`/assets/minecard.gif?v=${demoAssetVersion}`" alt="SubMiner demo GIF fallback" style="width: 100%; height: auto;" />');
|
||||||
);
|
|
||||||
expect(docsIndexContents).toContain(
|
|
||||||
'<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />',
|
|
||||||
);
|
|
||||||
expect(docsIndexContents).toContain(
|
|
||||||
'<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">',
|
|
||||||
);
|
|
||||||
expect(docsIndexContents).toContain(
|
|
||||||
'<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ const demoAssetVersion = '20260223-2';
|
|||||||
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
|
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
|
||||||
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
|
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
|
||||||
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
|
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
|
||||||
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
<img :src="`/assets/minecard.gif?v=${demoAssetVersion}`" alt="SubMiner demo GIF fallback" style="width: 100%; height: auto;" />
|
||||||
</a>
|
</a>
|
||||||
</video>
|
</video>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -194,10 +194,7 @@ See [MPV Plugin](/mpv-plugin) for the full configuration reference, script messa
|
|||||||
After installing, confirm SubMiner is working:
|
After installing, confirm SubMiner is working:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Play a file (default plugin config auto-starts visible overlay and waits for annotation readiness)
|
# Start the overlay (connects to mpv IPC)
|
||||||
subminer video.mkv
|
|
||||||
|
|
||||||
# Optional explicit overlay start for setups with plugin auto_start=no
|
|
||||||
subminer --start video.mkv
|
subminer --start video.mkv
|
||||||
|
|
||||||
# Useful launch modes for troubleshooting
|
# Useful launch modes for troubleshooting
|
||||||
|
|||||||
@@ -60,18 +60,12 @@ Launcher wrapper equivalent for interactive playback flow:
|
|||||||
subminer jellyfin -p
|
subminer jellyfin -p
|
||||||
```
|
```
|
||||||
|
|
||||||
Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
|
Launcher wrapper for Jellyfin cast discovery mode (foreground app process):
|
||||||
|
|
||||||
```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:
|
||||||
@@ -86,17 +80,6 @@ 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
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ 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 |
|
||||||
|
|
||||||
### rofi
|
### rofi
|
||||||
|
|
||||||
@@ -53,49 +53,46 @@ SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
|
|||||||
## Common Commands
|
## Common Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer video.mkv # play a specific file (default plugin config auto-starts visible overlay)
|
subminer video.mkv # play a specific file
|
||||||
subminer --start video.mkv # optional explicit overlay start when plugin auto_start=no
|
subminer --start video.mkv # play + explicitly start overlay
|
||||||
subminer -S video.mkv # same as above via --start-overlay
|
|
||||||
subminer https://youtu.be/... # YouTube playback (requires yt-dlp)
|
subminer https://youtu.be/... # YouTube playback (requires yt-dlp)
|
||||||
subminer ytsearch:"jp news" # YouTube search
|
subminer ytsearch:"jp news" # YouTube search
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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 |
|
||||||
| `subminer config path` | Print active config file path |
|
| `subminer config path` | Print active config file path |
|
||||||
| `subminer config show` | Print active config contents |
|
| `subminer config show` | Print active config contents |
|
||||||
| `subminer mpv status` | Check mpv socket readiness |
|
| `subminer mpv status` | Check mpv socket readiness |
|
||||||
| `subminer mpv socket` | Print active socket path |
|
| `subminer mpv socket` | Print active socket path |
|
||||||
| `subminer mpv idle` | Launch detached idle mpv instance |
|
| `subminer mpv idle` | Launch detached idle mpv instance |
|
||||||
| `subminer texthooker` | Launch texthooker-only mode |
|
| `subminer texthooker` | Launch texthooker-only mode |
|
||||||
| `subminer app` | Pass arguments directly to SubMiner binary |
|
| `subminer app` | Pass arguments directly to SubMiner binary |
|
||||||
|
|
||||||
Use `subminer <subcommand> -h` for command-specific help.
|
Use `subminer <subcommand> -h` for command-specific help.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
| ----------------------- | --------------------------------------------------- |
|
| -------------------- | -------------------------------------------- |
|
||||||
| `-d, --directory` | Video search directory (default: cwd) |
|
| `-d, --directory` | Video search directory (default: cwd) |
|
||||||
| `-r, --recursive` | Search directories recursively |
|
| `-r, --recursive` | Search directories recursively |
|
||||||
| `-R, --rofi` | Use rofi instead of fzf |
|
| `-R, --rofi` | Use rofi instead of fzf |
|
||||||
| `--start` | Explicitly start overlay after mpv launches |
|
| `-S, --start` | Start overlay after mpv launches |
|
||||||
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
|
| `-T, --no-texthooker`| Disable texthooker server |
|
||||||
| `-T, --no-texthooker` | Disable texthooker server |
|
| `-p, --profile` | mpv profile name (default: `subminer`) |
|
||||||
| `-p, --profile` | mpv profile name (default: `subminer`) |
|
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
|
||||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
|
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
||||||
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
|
||||||
|
|
||||||
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
|
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
- Default log level is `info`
|
- Default log level is `info`
|
||||||
- `--background` mode defaults to `warn` unless `--log-level` is explicitly set
|
- `--background` mode defaults to `warn` unless `--log-level` is explicitly set
|
||||||
- `--dev` / `--debug` control app behavior, not logging verbosity — use `--log-level` for that
|
- `--dev` / `--debug` control app behavior, not logging verbosity — use `--log-level` for that
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ SubMiner uses one overlay window with modal surfaces.
|
|||||||
The visible overlay renders subtitles as tokenized, clickable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports:
|
The visible overlay renders subtitles as tokenized, clickable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports:
|
||||||
|
|
||||||
- Word-level click targets for Yomitan lookup
|
- Word-level click targets for Yomitan lookup
|
||||||
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
|
|
||||||
- Right-click to pause/resume
|
- Right-click to pause/resume
|
||||||
- Right-click + drag to reposition subtitles
|
- Right-click + drag to reposition subtitles
|
||||||
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
||||||
@@ -100,11 +99,11 @@ 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` |
|
||||||
|
|
||||||
### 3. Mine Sentence (Hotkey)
|
### 3. Mine Sentence (Hotkey)
|
||||||
|
|
||||||
|
|||||||
@@ -29,16 +29,16 @@ 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 |
|
||||||
| `y-t` | Toggle visible overlay |
|
| `y-t` | Toggle visible overlay |
|
||||||
| `y-o` | Open settings window |
|
| `y-o` | Open settings window |
|
||||||
| `y-r` | Restart overlay |
|
| `y-r` | Restart overlay |
|
||||||
| `y-c` | Check status |
|
| `y-c` | Check status |
|
||||||
| `y-k` | Skip intro (AniSkip) |
|
| `y-k` | Skip intro (AniSkip) |
|
||||||
|
|
||||||
## Menu
|
## Menu
|
||||||
|
|
||||||
@@ -78,15 +78,11 @@ backend=auto
|
|||||||
|
|
||||||
# Start the overlay automatically when a file is loaded.
|
# Start the overlay automatically when a file is loaded.
|
||||||
# Runs only when mpv input-ipc-server matches socket_path.
|
# Runs only when mpv input-ipc-server matches socket_path.
|
||||||
auto_start=yes
|
auto_start=no
|
||||||
|
|
||||||
# Show the visible overlay on auto-start.
|
# Show the visible overlay on auto-start.
|
||||||
# Runs only when mpv input-ipc-server matches socket_path.
|
# Runs only when mpv input-ipc-server matches socket_path.
|
||||||
auto_start_visible_overlay=yes
|
auto_start_visible_overlay=no
|
||||||
|
|
||||||
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
|
|
||||||
# Requires auto_start=yes and auto_start_visible_overlay=yes.
|
|
||||||
auto_start_pause_until_ready=yes
|
|
||||||
|
|
||||||
# Show OSD messages for overlay status changes.
|
# Show OSD messages for overlay status changes.
|
||||||
osd_messages=yes
|
osd_messages=yes
|
||||||
@@ -120,27 +116,26 @@ 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` | 1–65535 | Texthooker server port |
|
| `texthooker_port` | `5174` | 1–65535 | Texthooker server port |
|
||||||
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
|
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
|
||||||
| `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
|
| `auto_start` | `no` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
|
||||||
| `auto_start_visible_overlay` | `yes` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
|
| `auto_start_visible_overlay` | `no` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
|
||||||
| `auto_start_pause_until_ready` | `yes` | `yes` / `no` | Pause mpv on visible auto-start; resume when SubMiner signals tokenization-ready |
|
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
||||||
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
||||||
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
|
||||||
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
|
| `aniskip_title` | `""` | string | Override title used for lookup |
|
||||||
| `aniskip_title` | `""` | string | Override title used for lookup |
|
| `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_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) |
|
| `aniskip_button_duration` | `3` | float seconds | OSD hint duration |
|
||||||
| `aniskip_button_duration` | `3` | float seconds | OSD hint duration |
|
|
||||||
|
|
||||||
## Binary Auto-Detection
|
## Binary Auto-Detection
|
||||||
|
|
||||||
@@ -186,7 +181,6 @@ script-message subminer-menu
|
|||||||
script-message subminer-options
|
script-message subminer-options
|
||||||
script-message subminer-restart
|
script-message subminer-restart
|
||||||
script-message subminer-status
|
script-message subminer-status
|
||||||
script-message subminer-autoplay-ready
|
|
||||||
script-message subminer-aniskip-refresh
|
script-message subminer-aniskip-refresh
|
||||||
script-message subminer-skip-intro
|
script-message subminer-skip-intro
|
||||||
```
|
```
|
||||||
@@ -217,8 +211,6 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
|||||||
## Lifecycle
|
## Lifecycle
|
||||||
|
|
||||||
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
|
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
|
||||||
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
|
|
||||||
- **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts).
|
|
||||||
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
|
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
|
||||||
- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.
|
- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,10 @@
|
|||||||
### Task 1: Add Regression Tests For Main Overlay Secondary Rendering
|
### Task 1: Add Regression Tests For Main Overlay Secondary Rendering
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/renderer/subtitle-render.test.ts`
|
- Modify: `src/renderer/subtitle-render.test.ts`
|
||||||
- Modify: `src/renderer/error-recovery.test.ts`
|
- Modify: `src/renderer/error-recovery.test.ts`
|
||||||
|
|
||||||
**Step 1: Write failing tests**
|
**Step 1: Write failing tests**
|
||||||
|
|
||||||
- Assert stylesheet no longer hides secondary subtitles in `layer-visible`.
|
- Assert stylesheet no longer hides secondary subtitles in `layer-visible`.
|
||||||
- Assert renderer platform resolution ignores legacy `secondary` overlay layer.
|
- Assert renderer platform resolution ignores legacy `secondary` overlay layer.
|
||||||
|
|
||||||
@@ -30,14 +28,12 @@ Expected: FAIL on secondary subtitle hide rule + legacy secondary layer handling
|
|||||||
### Task 2: Remove Secondary-Window CSS/Routing Assumptions
|
### Task 2: Remove Secondary-Window CSS/Routing Assumptions
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/renderer/style.css`
|
- Modify: `src/renderer/style.css`
|
||||||
- Modify: `src/renderer/utils/platform.ts`
|
- Modify: `src/renderer/utils/platform.ts`
|
||||||
- Modify: `src/renderer/error-recovery.ts`
|
- Modify: `src/renderer/error-recovery.ts`
|
||||||
- Modify: `src/types.ts`
|
- Modify: `src/types.ts`
|
||||||
|
|
||||||
**Step 1: Implement minimal changes**
|
**Step 1: Implement minimal changes**
|
||||||
|
|
||||||
- Remove legacy forced hide on `#secondarySubContainer`.
|
- Remove legacy forced hide on `#secondarySubContainer`.
|
||||||
- Remove obsolete layer-specific secondary-subtitle CSS blocks.
|
- Remove obsolete layer-specific secondary-subtitle CSS blocks.
|
||||||
- Drop legacy `secondary` overlay-layer parsing path from renderer platform resolver.
|
- Drop legacy `secondary` overlay-layer parsing path from renderer platform resolver.
|
||||||
@@ -51,7 +47,6 @@ Expected: PASS.
|
|||||||
### Task 3: Validate Wider Related Surface
|
### Task 3: Validate Wider Related Surface
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- No additional code changes required.
|
- No additional code changes required.
|
||||||
|
|
||||||
**Step 1: Run broader related tests**
|
**Step 1: Run broader related tests**
|
||||||
|
|||||||
BIN
docs/public/assets/kiku-integration.gif
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 23 MiB After Width: | Height: | Size: 12 MiB |
|
Before Width: | Height: | Size: 21 MiB |
@@ -5,6 +5,7 @@
|
|||||||
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Overlay Auto-Start
|
// Overlay Auto-Start
|
||||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
||||||
@@ -16,7 +17,7 @@
|
|||||||
// Control whether browser opens automatically for texthooker.
|
// Control whether browser opens automatically for texthooker.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"texthooker": {
|
"texthooker": {
|
||||||
"openBrowser": true, // Open browser setting. Values: true | false
|
"openBrowser": true // Open browser setting. Values: true | false
|
||||||
}, // Control whether browser opens automatically for texthooker.
|
}, // Control whether browser opens automatically for texthooker.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
||||||
"port": 6677, // Built-in subtitle websocket server port.
|
"port": 6677 // Built-in subtitle websocket server port.
|
||||||
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
// Set to debug for full runtime diagnostics.
|
// Set to debug for full runtime diagnostics.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"logging": {
|
"logging": {
|
||||||
"level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error
|
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||||
}, // Controls logging verbosity.
|
}, // Controls logging verbosity.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
|
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
|
||||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -76,7 +77,7 @@
|
|||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
"secondarySubLanguages": [], // Secondary sub languages setting.
|
||||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
||||||
"defaultMode": "hover", // Default mode setting.
|
"defaultMode": "hover" // Default mode setting.
|
||||||
}, // Dual subtitle track options.
|
}, // Dual subtitle track options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
"ffmpeg_path": "" // Ffmpeg path setting.
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -95,7 +96,7 @@
|
|||||||
// Initial vertical subtitle position from the bottom.
|
// Initial vertical subtitle position from the bottom.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitlePosition": {
|
"subtitlePosition": {
|
||||||
"yPercent": 10, // Y percent setting.
|
"yPercent": 10 // Y percent setting.
|
||||||
}, // Initial vertical subtitle position from the bottom.
|
}, // Initial vertical subtitle position from the bottom.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -106,7 +107,6 @@
|
|||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
|
||||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||||
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
||||||
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
@@ -129,19 +129,24 @@
|
|||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
"N3": "#f9e2af", // N3 setting.
|
"N3": "#f9e2af", // N3 setting.
|
||||||
"N4": "#a6e3a1", // N4 setting.
|
"N4": "#a6e3a1", // N4 setting.
|
||||||
"N5": "#8aadf4", // N5 setting.
|
"N5": "#8aadf4" // N5 setting.
|
||||||
}, // Jlpt colors setting.
|
}, // Jlpt colors setting.
|
||||||
"frequencyDictionary": {
|
"frequencyDictionary": {
|
||||||
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
||||||
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, SubMiner searches installed/default frequency-dictionary locations.
|
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, SubMiner searches installed/default frequency-dictionary locations.
|
||||||
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
|
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
|
||||||
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
|
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
|
||||||
"matchMode": "headword", // Frequency lookup text selection mode. Values: headword | surface
|
|
||||||
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
||||||
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
"bandedColors": [
|
||||||
|
"#ed8796",
|
||||||
|
"#f5a97f",
|
||||||
|
"#f9e2af",
|
||||||
|
"#a6e3a1",
|
||||||
|
"#8aadf4"
|
||||||
|
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
}, // Frequency dictionary setting.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
"fontFamily": "Manrope, Inter", // Font family setting.
|
||||||
"fontSize": 24, // Font size setting.
|
"fontSize": 24, // Font size setting.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
"fontColor": "#cad3f5", // Font color setting.
|
||||||
"lineHeight": 1.35, // Line height setting.
|
"lineHeight": 1.35, // Line height setting.
|
||||||
@@ -153,8 +158,8 @@
|
|||||||
"backgroundColor": "transparent", // Background color setting.
|
"backgroundColor": "transparent", // Background color setting.
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||||
"fontWeight": "normal", // Font weight setting.
|
"fontWeight": "normal", // Font weight setting.
|
||||||
"fontStyle": "normal", // Font style setting.
|
"fontStyle": "normal" // Font style setting.
|
||||||
}, // Secondary setting.
|
} // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -171,15 +176,17 @@
|
|||||||
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
||||||
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
|
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
|
||||||
"port": 8766, // Bind port for local AnkiConnect proxy.
|
"port": 8766, // Bind port for local AnkiConnect proxy.
|
||||||
"upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
|
"upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
|
||||||
}, // Proxy setting.
|
}, // Proxy setting.
|
||||||
"tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
"tags": [
|
||||||
|
"SubMiner"
|
||||||
|
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
"fields": {
|
"fields": {
|
||||||
"audio": "ExpressionAudio", // Audio setting.
|
"audio": "ExpressionAudio", // Audio setting.
|
||||||
"image": "Picture", // Image setting.
|
"image": "Picture", // Image setting.
|
||||||
"sentence": "Sentence", // Sentence setting.
|
"sentence": "Sentence", // Sentence setting.
|
||||||
"miscInfo": "MiscInfo", // Misc info setting.
|
"miscInfo": "MiscInfo", // Misc info setting.
|
||||||
"translation": "SelectionText", // Translation setting.
|
"translation": "SelectionText" // Translation setting.
|
||||||
}, // Fields setting.
|
}, // Fields setting.
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
@@ -188,7 +195,7 @@
|
|||||||
"model": "openai/gpt-4o-mini", // Model setting.
|
"model": "openai/gpt-4o-mini", // Model setting.
|
||||||
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
||||||
"targetLanguage": "English", // Target language setting.
|
"targetLanguage": "English", // Target language setting.
|
||||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
|
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting.
|
||||||
}, // Ai setting.
|
}, // Ai setting.
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
"generateAudio": true, // Generate audio setting. Values: true | false
|
||||||
@@ -201,7 +208,7 @@
|
|||||||
"animatedCrf": 35, // Animated crf setting.
|
"animatedCrf": 35, // Animated crf setting.
|
||||||
"audioPadding": 0.5, // Audio padding setting.
|
"audioPadding": 0.5, // Audio padding setting.
|
||||||
"fallbackDuration": 3, // Fallback duration setting.
|
"fallbackDuration": 3, // Fallback duration setting.
|
||||||
"maxMediaDuration": 30, // Max media duration setting.
|
"maxMediaDuration": 30 // Max media duration setting.
|
||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
||||||
@@ -209,7 +216,7 @@
|
|||||||
"mediaInsertMode": "append", // Media insert mode setting.
|
"mediaInsertMode": "append", // Media insert mode setting.
|
||||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
"highlightWord": true, // Highlight word setting. Values: true | false
|
||||||
"notificationType": "osd", // Notification type setting.
|
"notificationType": "osd", // Notification type setting.
|
||||||
"autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false
|
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||||
}, // Behavior setting.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||||
@@ -218,20 +225,20 @@
|
|||||||
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
|
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
|
||||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||||
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
|
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
|
||||||
"knownWord": "#a6da95", // Color used for legacy known-word highlights.
|
"knownWord": "#a6da95" // Color used for legacy known-word highlights.
|
||||||
}, // N plus one setting.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)", // Pattern setting.
|
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
||||||
}, // Metadata setting.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
"sentenceCardModel": "Japanese sentences", // Sentence card model setting.
|
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
||||||
}, // Is lapis setting.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false
|
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
||||||
}, // Is kiku setting.
|
} // Is kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -241,7 +248,7 @@
|
|||||||
"jimaku": {
|
"jimaku": {
|
||||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||||
"maxEntryResults": 10, // Maximum Jimaku search results returned.
|
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||||
}, // Jimaku API configuration and defaults.
|
}, // Jimaku API configuration and defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -252,7 +259,10 @@
|
|||||||
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
|
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
|
||||||
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
|
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
|
||||||
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
||||||
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher.
|
"primarySubLanguages": [
|
||||||
|
"ja",
|
||||||
|
"jpn"
|
||||||
|
] // Comma-separated primary subtitle language priority used by the launcher.
|
||||||
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -261,7 +271,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"anilist": {
|
"anilist": {
|
||||||
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
||||||
"accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
"accessToken": "" // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
||||||
}, // Anilist API credentials and update behavior.
|
}, // Anilist API credentials and update behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -285,8 +295,16 @@
|
|||||||
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
||||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
||||||
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||||
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
|
"directPlayContainers": [
|
||||||
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
|
"mkv",
|
||||||
|
"mp4",
|
||||||
|
"webm",
|
||||||
|
"mov",
|
||||||
|
"flac",
|
||||||
|
"mp3",
|
||||||
|
"aac"
|
||||||
|
], // Container allowlist for direct play decisions.
|
||||||
|
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
|
||||||
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -297,7 +315,7 @@
|
|||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750, // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -319,7 +337,7 @@
|
|||||||
"telemetryDays": 30, // Telemetry retention window in days.
|
"telemetryDays": 30, // Telemetry retention window in days.
|
||||||
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
||||||
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
||||||
"vacuumIntervalDays": 7, // Minimum days between VACUUM runs.
|
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
|
||||||
}, // Retention setting.
|
} // Retention setting.
|
||||||
}, // Enable/disable immersion tracking.
|
} // Enable/disable immersion tracking.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ 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) |
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
Global shortcuts are registered with the OS. If they conflict with another application, update them in `shortcuts` config and restart SubMiner.
|
Global shortcuts are registered with the OS. If they conflict with another application, update them in `shortcuts` config and restart SubMiner.
|
||||||
@@ -38,8 +38,6 @@ These control playback and subtitle display. They require overlay window focus.
|
|||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| -------------------- | -------------------------------------------------- |
|
| -------------------- | -------------------------------------------------- |
|
||||||
| `Space` | Toggle mpv pause |
|
| `Space` | Toggle mpv pause |
|
||||||
| `J` | Cycle primary subtitle track |
|
|
||||||
| `Shift+J` | Cycle secondary subtitle track |
|
|
||||||
| `ArrowRight` | Seek forward 5 seconds |
|
| `ArrowRight` | Seek forward 5 seconds |
|
||||||
| `ArrowLeft` | Seek backward 5 seconds |
|
| `ArrowLeft` | Seek backward 5 seconds |
|
||||||
| `ArrowUp` | Seek forward 60 seconds |
|
| `ArrowUp` | Seek forward 60 seconds |
|
||||||
@@ -56,8 +54,6 @@ These control playback and subtitle display. They require overlay window focus.
|
|||||||
|
|
||||||
These keybindings can be overridden or disabled via the `keybindings` config array.
|
These keybindings can be overridden or disabled via the `keybindings` config array.
|
||||||
|
|
||||||
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
|
|
||||||
|
|
||||||
## Subtitle & Feature Shortcuts
|
## Subtitle & Feature Shortcuts
|
||||||
|
|
||||||
| Shortcut | Action | Config key |
|
| Shortcut | Action | Config key |
|
||||||
@@ -67,19 +63,31 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
|||||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||||
|
|
||||||
|
## Subtitle Position Edit Mode
|
||||||
|
|
||||||
|
Enter edit mode to fine-tune subtitle alignment.
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| --------------------- | -------------------------------- |
|
||||||
|
| `Ctrl/Cmd+Shift+P` | Toggle position edit mode |
|
||||||
|
| `ArrowKeys` or `hjkl` | Nudge position by 1 px |
|
||||||
|
| `Shift+Arrow` | Nudge position by 4 px |
|
||||||
|
| `Enter` or `Ctrl+S` | Save position and exit edit mode |
|
||||||
|
| `Esc` | Cancel and discard changes |
|
||||||
|
|
||||||
## MPV Plugin Chords
|
## MPV Plugin Chords
|
||||||
|
|
||||||
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
||||||
|
|
||||||
| Chord | Action |
|
| Chord | Action |
|
||||||
| ----- | ------------------------ |
|
| ----- | --------------------------------------- |
|
||||||
| `y-y` | Open SubMiner menu (OSD) |
|
| `y-y` | Open SubMiner menu (OSD) |
|
||||||
| `y-s` | Start overlay |
|
| `y-s` | Start overlay |
|
||||||
| `y-S` | Stop overlay |
|
| `y-S` | Stop overlay |
|
||||||
| `y-t` | Toggle visible overlay |
|
| `y-t` | Toggle visible overlay |
|
||||||
| `y-o` | Open Yomitan settings |
|
| `y-o` | Open Yomitan settings |
|
||||||
| `y-r` | Restart overlay |
|
| `y-r` | Restart overlay |
|
||||||
| `y-c` | Check overlay status |
|
| `y-c` | Check overlay status |
|
||||||
|
|
||||||
When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper).
|
When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper).
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ SubMiner positions the overlay by tracking the mpv window. If tracking fails:
|
|||||||
- Sway: Ensure `swaymsg` is available.
|
- Sway: Ensure `swaymsg` is available.
|
||||||
- X11: Ensure `xdotool` and `xwininfo` are installed.
|
- X11: Ensure `xdotool` and `xwininfo` are installed.
|
||||||
|
|
||||||
If the overlay position is slightly off, right-click and drag on subtitle text to fine-tune the overlay subtitle offset.
|
If the overlay position is slightly off, use subtitle position edit mode (`Ctrl/Cmd+Shift+P`) to fine-tune the offset with arrow keys, then save with `Enter` or `Ctrl+S`.
|
||||||
|
|
||||||
## Yomitan
|
## Yomitan
|
||||||
|
|
||||||
@@ -279,5 +279,5 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati
|
|||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
- **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility.
|
- **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility.
|
||||||
- **Font rendering**: macOS uses a 0.87x font compensation factor for subtitle alignment between mpv and the overlay. If text alignment looks off, adjust subtitle offset by right-click dragging subtitle text.
|
- **Font rendering**: macOS uses a 0.87x font compensation factor for subtitle alignment between mpv and the overlay. If text alignment looks off, adjust subtitle offset in position edit mode.
|
||||||
- **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app`
|
- **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app`
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
|
|
||||||
> See [Yomitan setup](#yomitan-setup) for details.
|
|
||||||
|
|
||||||
There are two ways to use SubMiner — the `subminer` wrapper script or the mpv plugin:
|
There are two ways to use SubMiner — the `subminer` wrapper script or the mpv plugin:
|
||||||
|
|
||||||
| Approach | Best For |
|
| Approach | Best For |
|
||||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. With default plugin settings, overlay auto-starts visible and playback resumes after annotation readiness. |
|
| **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. Overlay start is explicit (`--start`, `-S`, or `y-s`). |
|
||||||
| **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control overlay visibility. Requires `--input-ipc-server=/tmp/subminer-socket`. |
|
| **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control overlay visibility. Requires `--input-ipc-server=/tmp/subminer-socket`. |
|
||||||
|
|
||||||
You can use both together—install the plugin for on-demand control, but use `subminer` when you want the streamlined workflow.
|
You can use both together—install the plugin for on-demand control, but use `subminer` when you want the streamlined workflow.
|
||||||
|
|
||||||
`subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer video.mkv`.
|
`subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer --start video.mkv`.
|
||||||
|
|
||||||
## Live Config Reload
|
## Live Config Reload
|
||||||
|
|
||||||
@@ -38,9 +34,8 @@ subminer # Current directory (uses fzf)
|
|||||||
subminer -R # Use rofi instead of fzf
|
subminer -R # Use rofi instead of fzf
|
||||||
subminer -d ~/Videos # Specific directory
|
subminer -d ~/Videos # Specific directory
|
||||||
subminer -r -d ~/Anime # Recursive search
|
subminer -r -d ~/Anime # Recursive search
|
||||||
subminer video.mkv # Play specific file (default plugin config auto-starts visible overlay)
|
subminer video.mkv # Play specific file
|
||||||
subminer --start video.mkv # Optional explicit overlay start (use when plugin auto_start=no)
|
subminer --start video.mkv # Play + explicitly start overlay
|
||||||
subminer -S video.mkv # Same as above via --start-overlay
|
|
||||||
subminer https://youtu.be/... # Play a YouTube URL
|
subminer https://youtu.be/... # Play a YouTube URL
|
||||||
subminer ytsearch:"jp news" # Play first YouTube search result
|
subminer ytsearch:"jp news" # Play first YouTube search result
|
||||||
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
|
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
|
||||||
@@ -55,8 +50,7 @@ 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 (background tray app)
|
subminer jellyfin -d # Jellyfin cast-discovery mode (foreground 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
|
||||||
@@ -152,14 +146,6 @@ secondary-sub-visibility=no
|
|||||||
|
|
||||||
`secondary-slang` is not an mpv option; use `slang` with `sid=auto` / `secondary-sid=auto` instead.
|
`secondary-slang` is not an mpv option; use `slang` with `sid=auto` / `secondary-sid=auto` instead.
|
||||||
|
|
||||||
### Yomitan setup
|
|
||||||
|
|
||||||
SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
|
|
||||||
|
|
||||||
For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --settings` or `SubMiner.AppImage --settings`) and import at least one dictionary in the bundled Yomitan instance.
|
|
||||||
|
|
||||||
If you also use Yomitan in a browser, configure that browser profile separately; it does not inherit dictionaries or settings from the bundled instance.
|
|
||||||
|
|
||||||
### YouTube Playback
|
### YouTube Playback
|
||||||
|
|
||||||
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets, and forwards them to mpv.
|
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets, and forwards them to mpv.
|
||||||
@@ -181,10 +167,10 @@ 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 |
|
||||||
|
|
||||||
`Alt+Shift+Y` is a fixed global shortcut; it is not part of `shortcuts` config.
|
`Alt+Shift+Y` is a fixed global shortcut; it is not part of `shortcuts` config.
|
||||||
|
|
||||||
@@ -205,12 +191,14 @@ Notes:
|
|||||||
| `Ctrl+W` | Quit mpv |
|
| `Ctrl+W` | Quit mpv |
|
||||||
| `Right-click` | Toggle MPV pause (outside subtitle area) |
|
| `Right-click` | Toggle MPV pause (outside subtitle area) |
|
||||||
| `Right-click + drag` | Move subtitle position (on subtitle) |
|
| `Right-click + drag` | Move subtitle position (on subtitle) |
|
||||||
|
| `Ctrl/Cmd+Shift+P` | Toggle subtitle position edit mode |
|
||||||
|
| `Arrow keys` | Move subtitles while edit mode is active |
|
||||||
|
| `Enter` / `Ctrl+S` | Save subtitle position in edit mode |
|
||||||
|
| `Esc` | Cancel subtitle position edit mode |
|
||||||
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist |
|
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist |
|
||||||
|
|
||||||
These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization.
|
These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization.
|
||||||
|
|
||||||
By default, hovering over subtitle text pauses mpv playback and leaving the subtitle area resumes playback. Set `subtitleStyle.autoPauseVideoOnHover` to `false` to disable this behavior.
|
|
||||||
|
|
||||||
### Drag-and-drop Queueing
|
### Drag-and-drop Queueing
|
||||||
|
|
||||||
- Drag and drop one or more video files onto the overlay to replace current playback (`loadfile ... replace` for first file, then append remainder).
|
- Drag and drop one or more video files onto the overlay to replace current playback (`loadfile ... replace` for first file, then append remainder).
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ test('inferAniSkipMetadataForFile falls back to anime directory title when filen
|
|||||||
|
|
||||||
test('buildSubminerScriptOpts includes aniskip metadata fields', () => {
|
test('buildSubminerScriptOpts includes aniskip metadata fields', () => {
|
||||||
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
|
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
|
||||||
title: "Frieren: Beyond Journey's End",
|
title: 'Frieren: Beyond Journey\'s End',
|
||||||
season: 1,
|
season: 1,
|
||||||
episode: 5,
|
episode: 5,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
|
|||||||
@@ -28,11 +28,7 @@ function toPositiveInt(value: unknown): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detectEpisodeFromName(baseName: string): number | null {
|
function detectEpisodeFromName(baseName: string): number | null {
|
||||||
const patterns = [
|
const patterns = [/[Ss]\d+[Ee](\d{1,3})/, /(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/, /[-\s](\d{1,3})$/];
|
||||||
/[Ss]\d+[Ee](\d{1,3})/,
|
|
||||||
/(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/,
|
|
||||||
/[-\s](\d{1,3})$/,
|
|
||||||
];
|
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const match = baseName.match(pattern);
|
const match = baseName.match(pattern);
|
||||||
if (!match || !match[1]) continue;
|
if (!match || !match[1]) continue;
|
||||||
@@ -175,11 +171,7 @@ export function inferAniSkipMetadataForFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeScriptOptValue(value: string): string {
|
function sanitizeScriptOptValue(value: string): string {
|
||||||
return value
|
return value.replace(/,/g, ' ').replace(/[\r\n]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
.replace(/,/g, ' ')
|
|
||||||
.replace(/[\r\n]/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSubminerScriptOpts(
|
export function buildSubminerScriptOpts(
|
||||||
|
|||||||
@@ -33,12 +33,6 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
|
|||||||
scriptPath: '/tmp/subminer',
|
scriptPath: '/tmp/subminer',
|
||||||
scriptName: 'subminer',
|
scriptName: 'subminer',
|
||||||
mpvSocketPath: '/tmp/subminer.sock',
|
mpvSocketPath: '/tmp/subminer.sock',
|
||||||
pluginRuntimeConfig: {
|
|
||||||
socketPath: '/tmp/subminer.sock',
|
|
||||||
autoStart: true,
|
|
||||||
autoStartVisibleOverlay: true,
|
|
||||||
autoStartPauseUntilReady: true,
|
|
||||||
},
|
|
||||||
appPath: '/tmp/subminer.app',
|
appPath: '/tmp/subminer.app',
|
||||||
launcherJellyfinConfig: {},
|
launcherJellyfinConfig: {},
|
||||||
processAdapter: adapter,
|
processAdapter: adapter,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Args, LauncherJellyfinConfig, PluginRuntimeConfig } from '../types.js';
|
import type { Args, LauncherJellyfinConfig } from '../types.js';
|
||||||
import type { ProcessAdapter } from '../process-adapter.js';
|
import type { ProcessAdapter } from '../process-adapter.js';
|
||||||
|
|
||||||
export interface LauncherCommandContext {
|
export interface LauncherCommandContext {
|
||||||
@@ -6,7 +6,6 @@ export interface LauncherCommandContext {
|
|||||||
scriptPath: string;
|
scriptPath: string;
|
||||||
scriptName: string;
|
scriptName: string;
|
||||||
mpvSocketPath: string;
|
mpvSocketPath: string;
|
||||||
pluginRuntimeConfig: PluginRuntimeConfig;
|
|
||||||
appPath: string | null;
|
appPath: string | null;
|
||||||
launcherJellyfinConfig: LauncherJellyfinConfig;
|
launcherJellyfinConfig: LauncherJellyfinConfig;
|
||||||
processAdapter: ProcessAdapter;
|
processAdapter: ProcessAdapter;
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.jellyfinDiscovery) {
|
if (args.jellyfinDiscovery) {
|
||||||
const forwarded = ['--background', '--jellyfin-remote-announce'];
|
const forwarded = ['--start'];
|
||||||
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);
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function registerCleanup(context: LauncherCommandContext): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
||||||
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context;
|
const { args, appPath, scriptPath, mpvSocketPath, processAdapter } = context;
|
||||||
if (!appPath) {
|
if (!appPath) {
|
||||||
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||||
}
|
}
|
||||||
@@ -137,19 +137,6 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
log('info', args.logLevel, 'YouTube subtitle mode: off');
|
log('info', args.logLevel, 'YouTube subtitle mode: off');
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldPauseUntilOverlayReady =
|
|
||||||
pluginRuntimeConfig.autoStart &&
|
|
||||||
pluginRuntimeConfig.autoStartVisibleOverlay &&
|
|
||||||
pluginRuntimeConfig.autoStartPauseUntilReady;
|
|
||||||
|
|
||||||
if (shouldPauseUntilOverlayReady) {
|
|
||||||
log(
|
|
||||||
'info',
|
|
||||||
args.logLevel,
|
|
||||||
'Configured to pause mpv until overlay and tokenization are ready',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
startMpv(
|
startMpv(
|
||||||
selectedTarget.target,
|
selectedTarget.target,
|
||||||
selectedTarget.kind,
|
selectedTarget.kind,
|
||||||
@@ -157,7 +144,6 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
mpvSocketPath,
|
mpvSocketPath,
|
||||||
appPath,
|
appPath,
|
||||||
preloadedSubtitles,
|
preloadedSubtitles,
|
||||||
{ startPaused: shouldPauseUntilOverlayReady },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
|
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
|
||||||
@@ -181,7 +167,6 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
|
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
|
||||||
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
|
||||||
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
|
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
|
||||||
if (shouldStartOverlay) {
|
if (shouldStartOverlay) {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
@@ -194,16 +179,6 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await startOverlay(appPath, args, mpvSocketPath);
|
await startOverlay(appPath, args, mpvSocketPath);
|
||||||
} else if (pluginAutoStartEnabled) {
|
|
||||||
if (ready) {
|
|
||||||
log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
|
||||||
} else {
|
|
||||||
log(
|
|
||||||
'info',
|
|
||||||
args.logLevel,
|
|
||||||
'MPV IPC socket not ready yet, relying on mpv plugin auto-start',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (ready) {
|
} else if (ready) {
|
||||||
log(
|
log(
|
||||||
'info',
|
'info',
|
||||||
|
|||||||
@@ -51,27 +51,10 @@ test('parseLauncherJellyfinConfig omits legacy token and user id fields', () =>
|
|||||||
assert.equal('userId' in parsed, false);
|
assert.equal('userId' in parsed, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => {
|
test('parsePluginRuntimeConfigContent reads socket_path and ignores inline comments', () => {
|
||||||
const parsed = parsePluginRuntimeConfigContent(`
|
const parsed = parsePluginRuntimeConfigContent(`
|
||||||
# comment
|
# comment
|
||||||
socket_path = /tmp/custom.sock # trailing comment
|
socket_path = /tmp/custom.sock # trailing comment
|
||||||
auto_start = yes
|
|
||||||
auto_start_visible_overlay = true
|
|
||||||
auto_start_pause_until_ready = 1
|
|
||||||
`);
|
`);
|
||||||
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
||||||
assert.equal(parsed.autoStart, true);
|
|
||||||
assert.equal(parsed.autoStartVisibleOverlay, true);
|
|
||||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parsePluginRuntimeConfigContent falls back to disabled startup gate options', () => {
|
|
||||||
const parsed = parsePluginRuntimeConfigContent(`
|
|
||||||
auto_start = maybe
|
|
||||||
auto_start_visible_overlay = no
|
|
||||||
auto_start_pause_until_ready = off
|
|
||||||
`);
|
|
||||||
assert.equal(parsed.autoStart, false);
|
|
||||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
|
||||||
assert.equal(parsed.autoStartPauseUntilReady, false);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { execFileSync } from 'node:child_process';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
test('launcher root help lists subcommands', () => {
|
test('launcher root help lists subcommands', () => {
|
||||||
const output = execFileSync('bun', ['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'], {
|
const output = execFileSync(
|
||||||
encoding: 'utf8',
|
'bun',
|
||||||
});
|
['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
|
||||||
assert.match(output, /Commands:/);
|
assert.match(output, /Commands:/);
|
||||||
assert.match(output, /jellyfin\|jf/);
|
assert.match(output, /jellyfin\|jf/);
|
||||||
|
|||||||
@@ -182,8 +182,7 @@ export function parseCliPrograms(
|
|||||||
server: typeof options.server === 'string' ? options.server : undefined,
|
server: typeof options.server === 'string' ? options.server : undefined,
|
||||||
username: typeof options.username === 'string' ? options.username : undefined,
|
username: typeof options.username === 'string' ? options.username : undefined,
|
||||||
password: typeof options.password === 'string' ? options.password : undefined,
|
password: typeof options.password === 'string' ? options.password : undefined,
|
||||||
passwordStore:
|
passwordStore: typeof options.passwordStore === 'string' ? options.passwordStore : undefined,
|
||||||
typeof options.passwordStore === 'string' ? options.passwordStore : undefined,
|
|
||||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,64 +15,22 @@ export function getPluginConfigCandidates(): string[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePluginRuntimeConfigContent(
|
export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeConfig {
|
||||||
content: string,
|
const runtimeConfig: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
|
||||||
logLevel: LogLevel = 'warn',
|
|
||||||
): PluginRuntimeConfig {
|
|
||||||
const runtimeConfig: PluginRuntimeConfig = {
|
|
||||||
socketPath: DEFAULT_SOCKET_PATH,
|
|
||||||
autoStart: true,
|
|
||||||
autoStartVisibleOverlay: true,
|
|
||||||
autoStartPauseUntilReady: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseBooleanValue = (key: string, value: string): boolean => {
|
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
if (['yes', 'true', '1', 'on'].includes(normalized)) return true;
|
|
||||||
if (['no', 'false', '0', 'off'].includes(normalized)) return false;
|
|
||||||
log('warn', logLevel, `Invalid boolean value for ${key}: "${value}". Using false.`);
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const line of content.split(/\r?\n/)) {
|
for (const line of content.split(/\r?\n/)) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
||||||
const keyValueMatch = trimmed.match(/^([a-z0-9_-]+)\s*=\s*(.+)$/i);
|
const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i);
|
||||||
if (!keyValueMatch) continue;
|
if (!socketMatch) continue;
|
||||||
const key = (keyValueMatch[1] || '').toLowerCase();
|
const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || '';
|
||||||
const value = (keyValueMatch[2] || '').split('#', 1)[0]?.trim() || '';
|
if (value) runtimeConfig.socketPath = value;
|
||||||
if (!value) continue;
|
|
||||||
|
|
||||||
if (key === 'socket_path') {
|
|
||||||
runtimeConfig.socketPath = value;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (key === 'auto_start') {
|
|
||||||
runtimeConfig.autoStart = parseBooleanValue('auto_start', value);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (key === 'auto_start_visible_overlay') {
|
|
||||||
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue('auto_start_visible_overlay', value);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (key === 'auto_start_pause_until_ready') {
|
|
||||||
runtimeConfig.autoStartPauseUntilReady = parseBooleanValue(
|
|
||||||
'auto_start_pause_until_ready',
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return runtimeConfig;
|
return runtimeConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||||
const candidates = getPluginConfigCandidates();
|
const candidates = getPluginConfigCandidates();
|
||||||
const defaults: PluginRuntimeConfig = {
|
const defaults: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
|
||||||
socketPath: DEFAULT_SOCKET_PATH,
|
|
||||||
autoStart: true,
|
|
||||||
autoStartVisibleOverlay: true,
|
|
||||||
autoStartPauseUntilReady: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const configPath of candidates) {
|
for (const configPath of candidates) {
|
||||||
if (!fs.existsSync(configPath)) continue;
|
if (!fs.existsSync(configPath)) continue;
|
||||||
@@ -81,7 +39,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
|
|||||||
log(
|
log(
|
||||||
'debug',
|
'debug',
|
||||||
logLevel,
|
logLevel,
|
||||||
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}`,
|
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}`,
|
||||||
);
|
);
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -93,7 +51,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
|
|||||||
log(
|
log(
|
||||||
'debug',
|
'debug',
|
||||||
logLevel,
|
logLevel,
|
||||||
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath}, auto_start=${defaults.autoStart}, auto_start_visible_overlay=${defaults.autoStartVisibleOverlay}, auto_start_pause_until_ready=${defaults.autoStartPauseUntilReady})`,
|
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath})`,
|
||||||
);
|
);
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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,
|
||||||
@@ -9,8 +8,8 @@ import type {
|
|||||||
JellyfinItemEntry,
|
JellyfinItemEntry,
|
||||||
JellyfinGroupEntry,
|
JellyfinGroupEntry,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { log, fail, getMpvLogPath } from './log.js';
|
import { log, fail } from './log.js';
|
||||||
import { commandExists, resolvePathMaybe, sleep } from './util.js';
|
import { commandExists, resolvePathMaybe } from './util.js';
|
||||||
import {
|
import {
|
||||||
pickLibrary,
|
pickLibrary,
|
||||||
pickItem,
|
pickItem,
|
||||||
@@ -19,17 +18,12 @@ 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(/\/+$/, '');
|
||||||
}
|
}
|
||||||
@@ -120,605 +114,6 @@ 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,
|
||||||
@@ -972,37 +367,18 @@ 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 hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
|
const itemId = await resolveJellyfinSelection(args, session, rofiTheme);
|
||||||
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;
|
||||||
@@ -1017,7 +393,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');
|
||||||
|
|||||||
@@ -5,19 +5,6 @@ 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;
|
||||||
@@ -35,14 +22,10 @@ function withTempDir<T>(fn: (dir: string) => T): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult {
|
function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult {
|
||||||
const result = spawnSync(
|
const result = spawnSync(process.execPath, ['run', path.join(process.cwd(), 'launcher/main.ts'), ...argv], {
|
||||||
process.execPath,
|
env,
|
||||||
['run', path.join(process.cwd(), 'launcher/main.ts'), ...argv],
|
encoding: 'utf8',
|
||||||
{
|
});
|
||||||
env,
|
|
||||||
encoding: 'utf8',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
status: result.status,
|
status: result.status,
|
||||||
stdout: result.stdout || '',
|
stdout: result.stdout || '',
|
||||||
@@ -162,7 +145,7 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
|
test('jellyfin discovery routes to app --start 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');
|
||||||
@@ -182,37 +165,7 @@ test('jellyfin discovery routes to app --background and remote announce with log
|
|||||||
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(
|
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--start\n--log-level\ndebug\n');
|
||||||
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',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,7 +225,10 @@ test('jellyfin setup forwards password-store to app command', () => {
|
|||||||
SUBMINER_APPIMAGE_PATH: appPath,
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
SUBMINER_TEST_CAPTURE: capturePath,
|
SUBMINER_TEST_CAPTURE: capturePath,
|
||||||
};
|
};
|
||||||
const result = runLauncher(['jf', 'setup', '--password-store', 'gnome-libsecret'], env);
|
const result = runLauncher(
|
||||||
|
['jf', 'setup', '--password-store', 'gnome-libsecret'],
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(result.status, 0);
|
assert.equal(result.status, 0);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -281,174 +237,3 @@ 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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -19,15 +19,14 @@ import { runPlaybackCommand } from './commands/playback-command.js';
|
|||||||
function createCommandContext(
|
function createCommandContext(
|
||||||
args: ReturnType<typeof parseArgs>,
|
args: ReturnType<typeof parseArgs>,
|
||||||
scriptPath: string,
|
scriptPath: string,
|
||||||
pluginRuntimeConfig: ReturnType<typeof readPluginRuntimeConfig>,
|
mpvSocketPath: string,
|
||||||
appPath: string | null,
|
appPath: string | null,
|
||||||
): LauncherCommandContext {
|
): LauncherCommandContext {
|
||||||
return {
|
return {
|
||||||
args,
|
args,
|
||||||
scriptPath,
|
scriptPath,
|
||||||
scriptName: path.basename(scriptPath),
|
scriptName: path.basename(scriptPath),
|
||||||
mpvSocketPath: pluginRuntimeConfig.socketPath,
|
mpvSocketPath,
|
||||||
pluginRuntimeConfig,
|
|
||||||
appPath,
|
appPath,
|
||||||
launcherJellyfinConfig: loadLauncherJellyfinConfig(),
|
launcherJellyfinConfig: loadLauncherJellyfinConfig(),
|
||||||
processAdapter: nodeProcessAdapter,
|
processAdapter: nodeProcessAdapter,
|
||||||
@@ -56,7 +55,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
|
log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
|
||||||
|
|
||||||
const context = createCommandContext(args, scriptPath, pluginRuntimeConfig, appPath);
|
const context = createCommandContext(args, scriptPath, pluginRuntimeConfig.socketPath, appPath);
|
||||||
|
|
||||||
if (runDoctorCommand(context)) {
|
if (runDoctorCommand(context)) {
|
||||||
return;
|
return;
|
||||||
@@ -72,7 +71,6 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
const resolvedAppPath = ensureAppPath(context);
|
const resolvedAppPath = ensureAppPath(context);
|
||||||
state.appPath = resolvedAppPath;
|
state.appPath = resolvedAppPath;
|
||||||
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
|
|
||||||
const appContext: LauncherCommandContext = {
|
const appContext: LauncherCommandContext = {
|
||||||
...context,
|
...context,
|
||||||
appPath: resolvedAppPath,
|
appPath: resolvedAppPath,
|
||||||
|
|||||||
@@ -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 { runAppCommandCaptureOutput, startOverlay, state, waitForUnixSocketReady } from './mpv';
|
import { 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,18 +19,6 @@ 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 {
|
||||||
|
|||||||
@@ -426,7 +426,6 @@ export function startMpv(
|
|||||||
socketPath: string,
|
socketPath: string,
|
||||||
appPath: string,
|
appPath: string,
|
||||||
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
||||||
options?: { startPaused?: boolean },
|
|
||||||
): void {
|
): void {
|
||||||
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
||||||
fail(`Video file not found: ${target}`);
|
fail(`Video file not found: ${target}`);
|
||||||
@@ -476,10 +475,8 @@ export function startMpv(
|
|||||||
if (preloadedSubtitles?.secondaryPath) {
|
if (preloadedSubtitles?.secondaryPath) {
|
||||||
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
|
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
|
||||||
}
|
}
|
||||||
if (options?.startPaused) {
|
const aniSkipMetadata =
|
||||||
mpvArgs.push('--pause=yes');
|
targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
|
||||||
}
|
|
||||||
const aniSkipMetadata = targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
|
|
||||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
|
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
|
||||||
if (aniSkipMetadata) {
|
if (aniSkipMetadata) {
|
||||||
log(
|
log(
|
||||||
@@ -658,28 +655,6 @@ 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[],
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ test('parseArgs maps jellyfin play action and log-level override', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs forwards jellyfin password-store option', () => {
|
test('parseArgs forwards jellyfin password-store option', () => {
|
||||||
const parsed = parseArgs(['jf', 'setup', '--password-store', 'gnome-libsecret'], 'subminer', {});
|
const parsed = parseArgs(
|
||||||
|
['jf', 'setup', '--password-store', 'gnome-libsecret'],
|
||||||
|
'subminer',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(parsed.jellyfin, true);
|
assert.equal(parsed.jellyfin, true);
|
||||||
assert.equal(parsed.passwordStore, 'gnome-libsecret');
|
assert.equal(parsed.passwordStore, 'gnome-libsecret');
|
||||||
|
|||||||
@@ -319,43 +319,3 @@ test(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test(
|
|
||||||
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
|
|
||||||
{ timeout: 20000 },
|
|
||||||
async () => {
|
|
||||||
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(smokeCase.xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
|
||||||
[
|
|
||||||
`socket_path=${smokeCase.socketPath}`,
|
|
||||||
'auto_start=yes',
|
|
||||||
'auto_start_visible_overlay=yes',
|
|
||||||
'auto_start_pause_until_ready=yes',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const env = makeTestEnv(smokeCase);
|
|
||||||
const result = runLauncher(
|
|
||||||
smokeCase,
|
|
||||||
[smokeCase.videoPath, '--log-level', 'debug'],
|
|
||||||
env,
|
|
||||||
'autoplay-ready-gate',
|
|
||||||
);
|
|
||||||
|
|
||||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
|
||||||
const mpvError = mpvEntries.find(
|
|
||||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
|
||||||
)?.error;
|
|
||||||
const unixSocketDenied =
|
|
||||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
|
||||||
const mpvFirstArgs = mpvEntries[0]?.argv;
|
|
||||||
|
|
||||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
|
||||||
assert.equal(Array.isArray(mpvFirstArgs), true);
|
|
||||||
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
|
|
||||||
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -129,9 +129,6 @@ export interface LauncherJellyfinConfig {
|
|||||||
|
|
||||||
export interface PluginRuntimeConfig {
|
export interface PluginRuntimeConfig {
|
||||||
socketPath: string;
|
socketPath: string;
|
||||||
autoStart: boolean;
|
|
||||||
autoStartVisibleOverlay: boolean;
|
|
||||||
autoStartPauseUntilReady: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandExecOptions {
|
export interface CommandExecOptions {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.2.2",
|
"version": "0.1.2",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
"build": "tsc && bun run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && cp -r src/renderer/fonts dist/renderer/ && bash scripts/build-macos-helper.sh",
|
"build": "tsc && bun run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && cp -r src/renderer/fonts dist/renderer/ && bash scripts/build-macos-helper.sh",
|
||||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||||
"docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev docs --host 0.0.0.0 --port 5173 --strictPort",
|
"docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev docs --host 0.0.0.0 --port 5173 --strictPort",
|
||||||
"docs:watch": "bunx concurrently -n docs,backlog \"bun run docs:dev\" \"backlog browser\"",
|
|
||||||
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs",
|
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs",
|
||||||
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort",
|
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
@@ -23,8 +22,8 @@
|
|||||||
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
|
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||||
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/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/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:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
|
||||||
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/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/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:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||||
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ auto_start=yes
|
|||||||
# Runs only when mpv input-ipc-server matches socket_path.
|
# Runs only when mpv input-ipc-server matches socket_path.
|
||||||
auto_start_visible_overlay=yes
|
auto_start_visible_overlay=yes
|
||||||
|
|
||||||
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
|
|
||||||
# Requires auto_start=yes and auto_start_visible_overlay=yes.
|
|
||||||
auto_start_pause_until_ready=yes
|
|
||||||
|
|
||||||
# Show OSD messages for overlay status
|
# Show OSD messages for overlay status
|
||||||
osd_messages=yes
|
osd_messages=yes
|
||||||
|
|
||||||
|
|||||||
@@ -249,22 +249,36 @@ function M.create(ctx)
|
|||||||
raw_close_idx = #raw_ass + 1
|
raw_close_idx = #raw_ass + 1
|
||||||
end
|
end
|
||||||
|
|
||||||
local before = raw_ass:sub(1, raw_open_idx - 1)
|
|
||||||
local hovered = raw_ass:sub(raw_open_idx, raw_close_idx - 1)
|
|
||||||
local after = raw_ass:sub(raw_close_idx)
|
|
||||||
local hover_suffix = string.format("\\1c&H%s&", hover_color)
|
|
||||||
|
|
||||||
-- Keep hover foreground stable even when inline ASS override tags (\1c/\c/\r) appear inside token.
|
|
||||||
hovered = hovered:gsub("{([^}]*)}", function(inner)
|
|
||||||
if inner:find("\\1c&H", 1, true) or inner:find("\\c&H", 1, true) or inner:find("\\r", 1, true) then
|
|
||||||
return "{" .. inner .. hover_suffix .. "}"
|
|
||||||
end
|
|
||||||
return "{" .. inner .. "}"
|
|
||||||
end)
|
|
||||||
|
|
||||||
local open_tag = string.format("{\\1c&H%s&}", hover_color)
|
local open_tag = string.format("{\\1c&H%s&}", hover_color)
|
||||||
local close_tag = string.format("{\\1c&H%s&}", base_color)
|
local close_tag = string.format("{\\1c&H%s&}", base_color)
|
||||||
return before .. open_tag .. hovered .. close_tag .. after
|
local changes = {
|
||||||
|
{ idx = raw_open_idx, tag = open_tag },
|
||||||
|
{ idx = raw_close_idx, tag = close_tag },
|
||||||
|
}
|
||||||
|
table.sort(changes, function(a, b)
|
||||||
|
return a.idx < b.idx
|
||||||
|
end)
|
||||||
|
|
||||||
|
local output = {}
|
||||||
|
local cursor = 1
|
||||||
|
for _, change in ipairs(changes) do
|
||||||
|
if change.idx > #raw_ass + 1 then
|
||||||
|
change.idx = #raw_ass + 1
|
||||||
|
end
|
||||||
|
if change.idx < 1 then
|
||||||
|
change.idx = 1
|
||||||
|
end
|
||||||
|
if change.idx > cursor then
|
||||||
|
output[#output + 1] = raw_ass:sub(cursor, change.idx - 1)
|
||||||
|
end
|
||||||
|
output[#output + 1] = change.tag
|
||||||
|
cursor = change.idx
|
||||||
|
end
|
||||||
|
if cursor <= #raw_ass then
|
||||||
|
output[#output + 1] = raw_ass:sub(cursor)
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(output)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function build_hover_subtitle_content(payload)
|
local function build_hover_subtitle_content(payload)
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ function M.create(ctx)
|
|||||||
|
|
||||||
local function on_file_loaded()
|
local function on_file_loaded()
|
||||||
aniskip.clear_aniskip_state()
|
aniskip.clear_aniskip_state()
|
||||||
process.disarm_auto_play_ready_gate()
|
|
||||||
|
|
||||||
local should_auto_start = resolve_auto_start_enabled()
|
local should_auto_start = resolve_auto_start_enabled()
|
||||||
if should_auto_start then
|
if should_auto_start then
|
||||||
@@ -60,7 +59,6 @@ function M.create(ctx)
|
|||||||
local function on_shutdown()
|
local function on_shutdown()
|
||||||
aniskip.clear_aniskip_state()
|
aniskip.clear_aniskip_state()
|
||||||
hover.clear_hover_overlay()
|
hover.clear_hover_overlay()
|
||||||
process.disarm_auto_play_ready_gate()
|
|
||||||
if state.overlay_running or state.texthooker_running then
|
if state.overlay_running or state.texthooker_running then
|
||||||
subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process")
|
subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process")
|
||||||
show_osd("Shutting down...")
|
show_osd("Shutting down...")
|
||||||
@@ -75,7 +73,6 @@ function M.create(ctx)
|
|||||||
hover.clear_hover_overlay()
|
hover.clear_hover_overlay()
|
||||||
end)
|
end)
|
||||||
mp.register_event("end-file", function()
|
mp.register_event("end-file", function()
|
||||||
process.disarm_auto_play_ready_gate()
|
|
||||||
hover.clear_hover_overlay()
|
hover.clear_hover_overlay()
|
||||||
end)
|
end)
|
||||||
mp.register_event("shutdown", function()
|
mp.register_event("shutdown", function()
|
||||||
|
|||||||
@@ -45,14 +45,7 @@ function M.create(ctx)
|
|||||||
|
|
||||||
local function show_osd(message)
|
local function show_osd(message)
|
||||||
if opts.osd_messages then
|
if opts.osd_messages then
|
||||||
local payload = "SubMiner: " .. message
|
mp.osd_message("SubMiner: " .. message, 3)
|
||||||
local sent = false
|
|
||||||
if type(mp.osd_message) == "function" then
|
|
||||||
sent = pcall(mp.osd_message, payload, 3)
|
|
||||||
end
|
|
||||||
if not sent and type(mp.commandv) == "function" then
|
|
||||||
pcall(mp.commandv, "show-text", payload, "3000")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ function M.create(ctx)
|
|||||||
mp.register_script_message("subminer-status", function()
|
mp.register_script_message("subminer-status", function()
|
||||||
process.check_status()
|
process.check_status()
|
||||||
end)
|
end)
|
||||||
mp.register_script_message("subminer-autoplay-ready", function()
|
|
||||||
process.notify_auto_play_ready()
|
|
||||||
end)
|
|
||||||
mp.register_script_message("subminer-aniskip-refresh", function()
|
mp.register_script_message("subminer-aniskip-refresh", function()
|
||||||
aniskip.fetch_aniskip_for_current_media("script-message")
|
aniskip.fetch_aniskip_for_current_media("script-message")
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ function M.load(options_lib, default_socket_path)
|
|||||||
texthooker_port = 5174,
|
texthooker_port = 5174,
|
||||||
backend = "auto",
|
backend = "auto",
|
||||||
auto_start = true,
|
auto_start = true,
|
||||||
auto_start_visible_overlay = true,
|
auto_start_visible_overlay = false,
|
||||||
auto_start_pause_until_ready = true,
|
|
||||||
osd_messages = true,
|
osd_messages = true,
|
||||||
log_level = "info",
|
log_level = "info",
|
||||||
aniskip_enabled = true,
|
aniskip_enabled = true,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ local M = {}
|
|||||||
|
|
||||||
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
||||||
local OVERLAY_START_MAX_ATTEMPTS = 6
|
local OVERLAY_START_MAX_ATTEMPTS = 6
|
||||||
local AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
|
||||||
|
|
||||||
function M.create(ctx)
|
function M.create(ctx)
|
||||||
local mp = ctx.mp
|
local mp = ctx.mp
|
||||||
@@ -23,14 +22,6 @@ function M.create(ctx)
|
|||||||
return options_helper.coerce_bool(raw_visible_overlay, false)
|
return options_helper.coerce_bool(raw_visible_overlay, false)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function resolve_pause_until_ready()
|
|
||||||
local raw_pause_until_ready = opts.auto_start_pause_until_ready
|
|
||||||
if raw_pause_until_ready == nil then
|
|
||||||
raw_pause_until_ready = opts["auto-start-pause-until-ready"]
|
|
||||||
end
|
|
||||||
return options_helper.coerce_bool(raw_pause_until_ready, false)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function normalize_socket_path(path)
|
local function normalize_socket_path(path)
|
||||||
if type(path) ~= "string" then
|
if type(path) ~= "string" then
|
||||||
return nil
|
return nil
|
||||||
@@ -62,54 +53,6 @@ function M.create(ctx)
|
|||||||
return selected
|
return selected
|
||||||
end
|
end
|
||||||
|
|
||||||
local function clear_auto_play_ready_timeout()
|
|
||||||
local timeout = state.auto_play_ready_timeout
|
|
||||||
if timeout and timeout.kill then
|
|
||||||
timeout:kill()
|
|
||||||
end
|
|
||||||
state.auto_play_ready_timeout = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local function disarm_auto_play_ready_gate()
|
|
||||||
clear_auto_play_ready_timeout()
|
|
||||||
state.auto_play_ready_gate_armed = false
|
|
||||||
end
|
|
||||||
|
|
||||||
local function release_auto_play_ready_gate(reason)
|
|
||||||
if not state.auto_play_ready_gate_armed then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
disarm_auto_play_ready_gate()
|
|
||||||
mp.set_property_native("pause", false)
|
|
||||||
show_osd("Subtitle annotations loaded")
|
|
||||||
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
|
|
||||||
end
|
|
||||||
|
|
||||||
local function arm_auto_play_ready_gate()
|
|
||||||
if state.auto_play_ready_gate_armed then
|
|
||||||
clear_auto_play_ready_timeout()
|
|
||||||
end
|
|
||||||
state.auto_play_ready_gate_armed = true
|
|
||||||
mp.set_property_native("pause", true)
|
|
||||||
show_osd("Loading subtitle annotations...")
|
|
||||||
subminer_log("info", "process", "Pausing playback until SubMiner overlay/tokenization readiness signal")
|
|
||||||
state.auto_play_ready_timeout = mp.add_timeout(AUTO_PLAY_READY_TIMEOUT_SECONDS, function()
|
|
||||||
if not state.auto_play_ready_gate_armed then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
subminer_log(
|
|
||||||
"warn",
|
|
||||||
"process",
|
|
||||||
"Startup readiness signal timed out; resuming playback to avoid stalled pause"
|
|
||||||
)
|
|
||||||
release_auto_play_ready_gate("timeout")
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function notify_auto_play_ready()
|
|
||||||
release_auto_play_ready_gate("tokenization-ready")
|
|
||||||
end
|
|
||||||
|
|
||||||
local function build_command_args(action, overrides)
|
local function build_command_args(action, overrides)
|
||||||
overrides = overrides or {}
|
overrides = overrides or {}
|
||||||
local args = { state.binary_path }
|
local args = { state.binary_path }
|
||||||
@@ -132,15 +75,14 @@ function M.create(ctx)
|
|||||||
table.insert(args, "--socket")
|
table.insert(args, "--socket")
|
||||||
table.insert(args, socket_path)
|
table.insert(args, socket_path)
|
||||||
|
|
||||||
-- Keep auto-start --start requests idempotent for second-instance handling.
|
local should_show_visible = resolve_visible_overlay_startup()
|
||||||
-- Visibility is applied as a separate control command after startup.
|
if should_show_visible and overrides.auto_start_trigger == true then
|
||||||
if overrides.auto_start_trigger ~= true then
|
should_show_visible = has_matching_mpv_ipc_socket(socket_path)
|
||||||
local should_show_visible = resolve_visible_overlay_startup()
|
end
|
||||||
if should_show_visible then
|
if should_show_visible then
|
||||||
table.insert(args, "--show-visible-overlay")
|
table.insert(args, "--show-visible-overlay")
|
||||||
else
|
else
|
||||||
table.insert(args, "--hide-visible-overlay")
|
table.insert(args, "--hide-visible-overlay")
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -240,8 +182,6 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function start_overlay(overrides)
|
local function start_overlay(overrides)
|
||||||
overrides = overrides or {}
|
|
||||||
|
|
||||||
if not binary.ensure_binary_available() then
|
if not binary.ensure_binary_available() then
|
||||||
subminer_log("error", "binary", "SubMiner binary not found")
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
show_osd("Error: binary not found")
|
show_osd("Error: binary not found")
|
||||||
@@ -249,31 +189,16 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if state.overlay_running then
|
if state.overlay_running then
|
||||||
if overrides.auto_start_trigger == true then
|
|
||||||
subminer_log("debug", "process", "Auto-start ignored because overlay is already running")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
subminer_log("info", "process", "Overlay already running")
|
subminer_log("info", "process", "Overlay already running")
|
||||||
show_osd("Already running")
|
show_osd("Already running")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
overrides = overrides or {}
|
||||||
local texthooker_enabled = overrides.texthooker_enabled
|
local texthooker_enabled = overrides.texthooker_enabled
|
||||||
if texthooker_enabled == nil then
|
if texthooker_enabled == nil then
|
||||||
texthooker_enabled = opts.texthooker_enabled
|
texthooker_enabled = opts.texthooker_enabled
|
||||||
end
|
end
|
||||||
local socket_path = overrides.socket_path or opts.socket_path
|
|
||||||
local should_pause_until_ready = (
|
|
||||||
overrides.auto_start_trigger == true
|
|
||||||
and resolve_visible_overlay_startup()
|
|
||||||
and resolve_pause_until_ready()
|
|
||||||
and has_matching_mpv_ipc_socket(socket_path)
|
|
||||||
)
|
|
||||||
if should_pause_until_ready then
|
|
||||||
arm_auto_play_ready_gate()
|
|
||||||
else
|
|
||||||
disarm_auto_play_ready_gate()
|
|
||||||
end
|
|
||||||
|
|
||||||
local function launch_overlay_with_retry(attempt)
|
local function launch_overlay_with_retry(attempt)
|
||||||
local args = build_command_args("start", overrides)
|
local args = build_command_args("start", overrides)
|
||||||
@@ -311,19 +236,9 @@ function M.create(ctx)
|
|||||||
state.overlay_running = false
|
state.overlay_running = false
|
||||||
subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
|
subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
|
||||||
show_osd("Overlay start failed")
|
show_osd("Overlay start failed")
|
||||||
release_auto_play_ready_gate("overlay-start-failed")
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if overrides.auto_start_trigger == true then
|
|
||||||
local visibility_action = resolve_visible_overlay_startup()
|
|
||||||
and "show-visible-overlay"
|
|
||||||
or "hide-visible-overlay"
|
|
||||||
run_control_command_async(visibility_action, {
|
|
||||||
log_level = overrides.log_level,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -362,7 +277,6 @@ function M.create(ctx)
|
|||||||
|
|
||||||
state.overlay_running = false
|
state.overlay_running = false
|
||||||
state.texthooker_running = false
|
state.texthooker_running = false
|
||||||
disarm_auto_play_ready_gate()
|
|
||||||
show_osd("Stopped")
|
show_osd("Stopped")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -412,7 +326,6 @@ function M.create(ctx)
|
|||||||
run_control_command_async("stop", nil, function()
|
run_control_command_async("stop", nil, function()
|
||||||
state.overlay_running = false
|
state.overlay_running = false
|
||||||
state.texthooker_running = false
|
state.texthooker_running = false
|
||||||
disarm_auto_play_ready_gate()
|
|
||||||
|
|
||||||
ensure_texthooker_running(function()
|
ensure_texthooker_running(function()
|
||||||
local start_args = build_command_args("start")
|
local start_args = build_command_args("start")
|
||||||
@@ -471,8 +384,6 @@ function M.create(ctx)
|
|||||||
restart_overlay = restart_overlay,
|
restart_overlay = restart_overlay,
|
||||||
check_status = check_status,
|
check_status = check_status,
|
||||||
check_binary_available = check_binary_available,
|
check_binary_available = check_binary_available,
|
||||||
notify_auto_play_ready = notify_auto_play_ready,
|
|
||||||
disarm_auto_play_ready_gate = disarm_auto_play_ready_gate,
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ function M.new()
|
|||||||
found = false,
|
found = false,
|
||||||
prompt_shown = false,
|
prompt_shown = false,
|
||||||
},
|
},
|
||||||
auto_play_ready_gate_armed = false,
|
|
||||||
auto_play_ready_timeout = nil,
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,11 @@ Description:
|
|||||||
Generates two browser-friendly files next to the input file:
|
Generates two browser-friendly files next to the input file:
|
||||||
- <name>.mp4 (H.264 + AAC, prefers NVIDIA GPU if available)
|
- <name>.mp4 (H.264 + AAC, prefers NVIDIA GPU if available)
|
||||||
- <name>.webm (AV1/VP9 + Opus, prefers NVIDIA GPU if available)
|
- <name>.webm (AV1/VP9 + Opus, prefers NVIDIA GPU if available)
|
||||||
|
- <name>.gif (palette-optimised, 15 fps)
|
||||||
- <name>-poster.jpg (single frame for video poster fallback)
|
- <name>-poster.jpg (single frame for video poster fallback)
|
||||||
- <name>.webp (animated, only when --webp is provided)
|
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-f, --force Overwrite existing output files
|
-f, --force Overwrite existing output files
|
||||||
-w, --webp Generate animated WebP preview
|
|
||||||
|
|
||||||
Encoding profile:
|
Encoding profile:
|
||||||
- Crop: 1920x1080 at x=760 y=200
|
- Crop: 1920x1080 at x=760 y=200
|
||||||
@@ -26,7 +25,6 @@ USAGE
|
|||||||
}
|
}
|
||||||
|
|
||||||
force=0
|
force=0
|
||||||
generate_webp=0
|
|
||||||
input=""
|
input=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
@@ -38,9 +36,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
-f | --force)
|
-f | --force)
|
||||||
force=1
|
force=1
|
||||||
;;
|
;;
|
||||||
-w | --webp)
|
|
||||||
generate_webp=1
|
|
||||||
;;
|
|
||||||
-*)
|
-*)
|
||||||
echo "Error: unknown option: $1" >&2
|
echo "Error: unknown option: $1" >&2
|
||||||
usage
|
usage
|
||||||
@@ -79,7 +74,7 @@ base="${filename%.*}"
|
|||||||
|
|
||||||
mp4_out="$dir/$base.mp4"
|
mp4_out="$dir/$base.mp4"
|
||||||
webm_out="$dir/$base.webm"
|
webm_out="$dir/$base.webm"
|
||||||
webp_out="$dir/$base.webp"
|
gif_out="$dir/$base.gif"
|
||||||
poster_out="$dir/$base-poster.jpg"
|
poster_out="$dir/$base-poster.jpg"
|
||||||
|
|
||||||
overwrite_flag="-n"
|
overwrite_flag="-n"
|
||||||
@@ -88,11 +83,7 @@ if [[ "$force" -eq 1 ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$force" -eq 0 ]]; then
|
if [[ "$force" -eq 0 ]]; then
|
||||||
outputs=("$mp4_out" "$webm_out" "$poster_out")
|
for output in "$mp4_out" "$webm_out" "$gif_out" "$poster_out"; do
|
||||||
if [[ "$generate_webp" -eq 1 ]]; then
|
|
||||||
outputs+=("$webp_out")
|
|
||||||
fi
|
|
||||||
for output in "${outputs[@]}"; do
|
|
||||||
if [[ -e "$output" ]]; then
|
if [[ -e "$output" ]]; then
|
||||||
echo "Error: output exists: $output (use --force to overwrite)" >&2
|
echo "Error: output exists: $output (use --force to overwrite)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -107,6 +98,7 @@ has_encoder() {
|
|||||||
|
|
||||||
crop_vf="crop=1920:1080:760:205"
|
crop_vf="crop=1920:1080:760:205"
|
||||||
webm_vf="${crop_vf},fps=30"
|
webm_vf="${crop_vf},fps=30"
|
||||||
|
gif_vf="${crop_vf},fps=15,scale=960:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3"
|
||||||
|
|
||||||
echo "Generating MP4: $mp4_out"
|
echo "Generating MP4: $mp4_out"
|
||||||
if has_encoder "h264_nvenc"; then
|
if has_encoder "h264_nvenc"; then
|
||||||
@@ -167,20 +159,10 @@ else
|
|||||||
"$webm_out"
|
"$webm_out"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$generate_webp" -eq 1 ]]; then
|
echo "Generating GIF: $gif_out"
|
||||||
if ! has_encoder "libwebp"; then
|
ffmpeg "$overwrite_flag" -i "$input" \
|
||||||
echo "Error: encoder not found: libwebp" >&2
|
-vf "$gif_vf" \
|
||||||
exit 1
|
"$gif_out"
|
||||||
fi
|
|
||||||
echo "Generating animated WebP: $webp_out"
|
|
||||||
ffmpeg "$overwrite_flag" -i "$input" \
|
|
||||||
-vf "${crop_vf},fps=24,scale=960:-1:flags=lanczos" \
|
|
||||||
-c:v libwebp \
|
|
||||||
-q:v 80 \
|
|
||||||
-loop 0 \
|
|
||||||
-an \
|
|
||||||
"$webp_out"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Generating poster: $poster_out"
|
echo "Generating poster: $poster_out"
|
||||||
ffmpeg "$overwrite_flag" -ss 00:00:05 -i "$input" \
|
ffmpeg "$overwrite_flag" -ss 00:00:05 -i "$input" \
|
||||||
@@ -192,7 +174,5 @@ ffmpeg "$overwrite_flag" -ss 00:00:05 -i "$input" \
|
|||||||
echo "Done."
|
echo "Done."
|
||||||
echo "MP4: $mp4_out"
|
echo "MP4: $mp4_out"
|
||||||
echo "WebM: $webm_out"
|
echo "WebM: $webm_out"
|
||||||
if [[ "$generate_webp" -eq 1 ]]; then
|
echo "GIF: $gif_out"
|
||||||
echo "WebP: $webp_out"
|
|
||||||
fi
|
|
||||||
echo "Poster: $poster_out"
|
echo "Poster: $poster_out"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ local function run_plugin_scenario(config)
|
|||||||
events = {},
|
events = {},
|
||||||
osd = {},
|
osd = {},
|
||||||
logs = {},
|
logs = {},
|
||||||
property_sets = {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
local function make_mp_stub()
|
local function make_mp_stub()
|
||||||
@@ -117,12 +116,7 @@ local function run_plugin_scenario(config)
|
|||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
function mp.commandv(...) end
|
function mp.commandv(...) end
|
||||||
function mp.set_property_native(name, value)
|
function mp.set_property_native(...) end
|
||||||
recorded.property_sets[#recorded.property_sets + 1] = {
|
|
||||||
name = name,
|
|
||||||
value = value,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
function mp.get_script_name()
|
function mp.get_script_name()
|
||||||
return "subminer"
|
return "subminer"
|
||||||
end
|
end
|
||||||
@@ -248,39 +242,6 @@ local function find_start_call(async_calls)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function count_start_calls(async_calls)
|
|
||||||
local count = 0
|
|
||||||
for _, call in ipairs(async_calls) do
|
|
||||||
local args = call.args or {}
|
|
||||||
for _, value in ipairs(args) do
|
|
||||||
if value == "--start" then
|
|
||||||
count = count + 1
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return count
|
|
||||||
end
|
|
||||||
|
|
||||||
local function find_control_call(async_calls, flag)
|
|
||||||
for _, call in ipairs(async_calls) do
|
|
||||||
local args = call.args or {}
|
|
||||||
local has_flag = false
|
|
||||||
local has_start = false
|
|
||||||
for _, value in ipairs(args) do
|
|
||||||
if value == flag then
|
|
||||||
has_flag = true
|
|
||||||
elseif value == "--start" then
|
|
||||||
has_start = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if has_flag and not has_start then
|
|
||||||
return call
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local function call_has_arg(call, target)
|
local function call_has_arg(call, target)
|
||||||
local args = (call and call.args) or {}
|
local args = (call and call.args) or {}
|
||||||
for _, value in ipairs(args) do
|
for _, value in ipairs(args) do
|
||||||
@@ -324,34 +285,6 @@ local function has_async_curl_for(async_calls, needle)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
local function has_property_set(property_sets, name, value)
|
|
||||||
for _, call in ipairs(property_sets) do
|
|
||||||
if call.name == name and call.value == value then
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
local function has_osd_message(messages, target)
|
|
||||||
for _, message in ipairs(messages) do
|
|
||||||
if message == target then
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
local function count_osd_message(messages, target)
|
|
||||||
local count = 0
|
|
||||||
for _, message in ipairs(messages) do
|
|
||||||
if message == target then
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return count
|
|
||||||
end
|
|
||||||
|
|
||||||
local function fire_event(recorded, name)
|
local function fire_event(recorded, name)
|
||||||
local listeners = recorded.events[name] or {}
|
local listeners = recorded.events[name] or {}
|
||||||
for _, listener in ipairs(listeners) do
|
for _, listener in ipairs(listeners) do
|
||||||
@@ -440,7 +373,6 @@ do
|
|||||||
binary_path = binary_path,
|
binary_path = binary_path,
|
||||||
auto_start = "yes",
|
auto_start = "yes",
|
||||||
auto_start_visible_overlay = "yes",
|
auto_start_visible_overlay = "yes",
|
||||||
auto_start_pause_until_ready = "no",
|
|
||||||
socket_path = "/tmp/subminer-socket",
|
socket_path = "/tmp/subminer-socket",
|
||||||
},
|
},
|
||||||
input_ipc_server = "/tmp/subminer-socket",
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
@@ -454,86 +386,12 @@ do
|
|||||||
local start_call = find_start_call(recorded.async_calls)
|
local start_call = find_start_call(recorded.async_calls)
|
||||||
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
||||||
assert_true(
|
assert_true(
|
||||||
not call_has_arg(start_call, "--show-visible-overlay"),
|
call_has_arg(start_call, "--show-visible-overlay"),
|
||||||
"auto-start should keep --start command free of --show-visible-overlay"
|
"auto-start with visible overlay enabled should pass --show-visible-overlay"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
not call_has_arg(start_call, "--hide-visible-overlay"),
|
not call_has_arg(start_call, "--hide-visible-overlay"),
|
||||||
"auto-start should keep --start command free of --hide-visible-overlay"
|
"auto-start with visible overlay enabled should not pass --hide-visible-overlay"
|
||||||
)
|
|
||||||
assert_true(
|
|
||||||
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil,
|
|
||||||
"auto-start with visible overlay enabled should issue a separate --show-visible-overlay command"
|
|
||||||
)
|
|
||||||
assert_true(
|
|
||||||
not has_property_set(recorded.property_sets, "pause", true),
|
|
||||||
"auto-start visible overlay should not force pause without explicit pause-until-ready option"
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
do
|
|
||||||
local recorded, err = run_plugin_scenario({
|
|
||||||
process_list = "",
|
|
||||||
option_overrides = {
|
|
||||||
binary_path = binary_path,
|
|
||||||
auto_start = "yes",
|
|
||||||
auto_start_visible_overlay = "yes",
|
|
||||||
socket_path = "/tmp/subminer-socket",
|
|
||||||
},
|
|
||||||
input_ipc_server = "/tmp/subminer-socket",
|
|
||||||
media_title = "Random Movie",
|
|
||||||
files = {
|
|
||||||
[binary_path] = true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
assert_true(recorded ~= nil, "plugin failed to load for duplicate auto-start scenario: " .. tostring(err))
|
|
||||||
fire_event(recorded, "file-loaded")
|
|
||||||
fire_event(recorded, "file-loaded")
|
|
||||||
assert_true(
|
|
||||||
count_start_calls(recorded.async_calls) == 1,
|
|
||||||
"duplicate file-loaded events should not issue duplicate --start commands while overlay is already running"
|
|
||||||
)
|
|
||||||
assert_true(
|
|
||||||
count_osd_message(recorded.osd, "SubMiner: Already running") == 0,
|
|
||||||
"duplicate auto-start events should not show Already running OSD"
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
do
|
|
||||||
local recorded, err = run_plugin_scenario({
|
|
||||||
process_list = "",
|
|
||||||
option_overrides = {
|
|
||||||
binary_path = binary_path,
|
|
||||||
auto_start = "yes",
|
|
||||||
auto_start_visible_overlay = "yes",
|
|
||||||
auto_start_pause_until_ready = "yes",
|
|
||||||
socket_path = "/tmp/subminer-socket",
|
|
||||||
},
|
|
||||||
input_ipc_server = "/tmp/subminer-socket",
|
|
||||||
media_title = "Random Movie",
|
|
||||||
files = {
|
|
||||||
[binary_path] = true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
assert_true(recorded ~= nil, "plugin failed to load for pause-until-ready scenario: " .. tostring(err))
|
|
||||||
fire_event(recorded, "file-loaded")
|
|
||||||
assert_true(
|
|
||||||
has_property_set(recorded.property_sets, "pause", true),
|
|
||||||
"pause-until-ready auto-start should pause mpv before overlay ready"
|
|
||||||
)
|
|
||||||
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
|
|
||||||
recorded.script_messages["subminer-autoplay-ready"]()
|
|
||||||
assert_true(
|
|
||||||
has_property_set(recorded.property_sets, "pause", false),
|
|
||||||
"autoplay-ready script message should resume mpv playback"
|
|
||||||
)
|
|
||||||
assert_true(
|
|
||||||
has_osd_message(recorded.osd, "SubMiner: Loading subtitle annotations..."),
|
|
||||||
"pause-until-ready auto-start should show loading OSD message"
|
|
||||||
)
|
|
||||||
assert_true(
|
|
||||||
has_osd_message(recorded.osd, "SubMiner: Subtitle annotations loaded"),
|
|
||||||
"autoplay-ready should show loaded OSD message"
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -557,16 +415,12 @@ do
|
|||||||
local start_call = find_start_call(recorded.async_calls)
|
local start_call = find_start_call(recorded.async_calls)
|
||||||
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
||||||
assert_true(
|
assert_true(
|
||||||
not call_has_arg(start_call, "--hide-visible-overlay"),
|
call_has_arg(start_call, "--hide-visible-overlay"),
|
||||||
"auto-start should keep --start command free of --hide-visible-overlay"
|
"auto-start with visible overlay disabled should pass --hide-visible-overlay"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
not call_has_arg(start_call, "--show-visible-overlay"),
|
not call_has_arg(start_call, "--show-visible-overlay"),
|
||||||
"auto-start should keep --start command free of --show-visible-overlay"
|
"auto-start with visible overlay disabled should not pass --show-visible-overlay"
|
||||||
)
|
|
||||||
assert_true(
|
|
||||||
find_control_call(recorded.async_calls, "--hide-visible-overlay") ~= nil,
|
|
||||||
"auto-start with visible overlay disabled should issue a separate --hide-visible-overlay command"
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -592,10 +446,6 @@ do
|
|||||||
start_call == nil,
|
start_call == nil,
|
||||||
"auto-start should be skipped when mpv input-ipc-server does not match configured socket_path"
|
"auto-start should be skipped when mpv input-ipc-server does not match configured socket_path"
|
||||||
)
|
)
|
||||||
assert_true(
|
|
||||||
not has_property_set(recorded.property_sets, "pause", true),
|
|
||||||
"pause-until-ready gate should not arm when socket_path does not match"
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
print("plugin start gate regression tests: OK")
|
print("plugin start gate regression tests: OK")
|
||||||
|
|||||||
@@ -316,33 +316,3 @@ 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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -58,56 +58,6 @@ 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;
|
||||||
@@ -289,8 +239,7 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createProxyServer(): AnkiConnectProxyServer {
|
private createProxyServer(): AnkiConnectProxyServer {
|
||||||
const { AnkiConnectProxyServer } =
|
const { AnkiConnectProxyServer } = require('./anki-integration/anki-connect-proxy') as typeof import('./anki-integration/anki-connect-proxy');
|
||||||
require('./anki-integration/anki-connect-proxy') as typeof import('./anki-integration/anki-connect-proxy');
|
|
||||||
return new AnkiConnectProxyServer({
|
return new AnkiConnectProxyServer({
|
||||||
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
|
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
|
||||||
processNewCard: (noteId: number) => this.processNewCard(noteId),
|
processNewCard: (noteId: number) => this.processNewCard(noteId),
|
||||||
@@ -779,12 +728,8 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentVideoPath = this.mpvClient.currentVideoPath || '';
|
const currentVideoPath = this.mpvClient.currentVideoPath || '';
|
||||||
const videoFilename = extractFilenameFromMediaPath(currentVideoPath);
|
const videoFilename = currentVideoPath ? path.basename(currentVideoPath) : '';
|
||||||
const mediaTitle = trimToNonEmptyString(this.mpvClient.currentMediaTitle);
|
const filenameWithExt = videoFilename || fallbackFilename;
|
||||||
const filenameWithExt =
|
|
||||||
(shouldPreferMediaTitleForMiscInfo(currentVideoPath, videoFilename)
|
|
||||||
? mediaTitle || videoFilename
|
|
||||||
: videoFilename || mediaTitle) || fallbackFilename;
|
|
||||||
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
|
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
|
||||||
|
|
||||||
const currentTimePos =
|
const currentTimePos =
|
||||||
|
|||||||
@@ -27,11 +27,9 @@ test('proxy enqueues addNote result for enrichment', async () => {
|
|||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
(
|
(proxy as unknown as {
|
||||||
proxy as unknown as {
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
}).maybeEnqueueFromRequest(
|
||||||
}
|
|
||||||
).maybeEnqueueFromRequest(
|
|
||||||
{ action: 'addNote' },
|
{ action: 'addNote' },
|
||||||
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
|
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
|
||||||
);
|
);
|
||||||
@@ -52,11 +50,9 @@ test('proxy enqueues addNote bare numeric response for enrichment', async () =>
|
|||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
(
|
(proxy as unknown as {
|
||||||
proxy as unknown as {
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
}).maybeEnqueueFromRequest({ action: 'addNote' }, Buffer.from('42', 'utf8'));
|
||||||
}
|
|
||||||
).maybeEnqueueFromRequest({ action: 'addNote' }, Buffer.from('42', 'utf8'));
|
|
||||||
|
|
||||||
await waitForCondition(() => processed.length === 1);
|
await waitForCondition(() => processed.length === 1);
|
||||||
assert.deepEqual(processed, [42]);
|
assert.deepEqual(processed, [42]);
|
||||||
@@ -75,11 +71,9 @@ test('proxy de-duplicates addNotes IDs within the same response', async () => {
|
|||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
(
|
(proxy as unknown as {
|
||||||
proxy as unknown as {
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
}).maybeEnqueueFromRequest(
|
||||||
}
|
|
||||||
).maybeEnqueueFromRequest(
|
|
||||||
{ action: 'addNotes' },
|
{ action: 'addNotes' },
|
||||||
Buffer.from(JSON.stringify({ result: [101, 102, 101, null], error: null }), 'utf8'),
|
Buffer.from(JSON.stringify({ result: [101, 102, 101, null], error: null }), 'utf8'),
|
||||||
);
|
);
|
||||||
@@ -100,15 +94,17 @@ test('proxy enqueues note IDs from multi action addNote/addNotes results', async
|
|||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
(
|
(proxy as unknown as {
|
||||||
proxy as unknown as {
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
}).maybeEnqueueFromRequest(
|
||||||
}
|
|
||||||
).maybeEnqueueFromRequest(
|
|
||||||
{
|
{
|
||||||
action: 'multi',
|
action: 'multi',
|
||||||
params: {
|
params: {
|
||||||
actions: [{ action: 'version' }, { action: 'addNote' }, { action: 'addNotes' }],
|
actions: [
|
||||||
|
{ action: 'version' },
|
||||||
|
{ action: 'addNote' },
|
||||||
|
{ action: 'addNotes' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Buffer.from(JSON.stringify({ result: [6, 777, [888, 777, null]], error: null }), 'utf8'),
|
Buffer.from(JSON.stringify({ result: [6, 777, [888, 777, null]], error: null }), 'utf8'),
|
||||||
@@ -130,11 +126,9 @@ test('proxy enqueues note IDs from bare multi action results', async () => {
|
|||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
(
|
(proxy as unknown as {
|
||||||
proxy as unknown as {
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
}).maybeEnqueueFromRequest(
|
||||||
}
|
|
||||||
).maybeEnqueueFromRequest(
|
|
||||||
{
|
{
|
||||||
action: 'multi',
|
action: 'multi',
|
||||||
params: {
|
params: {
|
||||||
@@ -160,15 +154,17 @@ test('proxy enqueues note IDs from multi action envelope results', async () => {
|
|||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
(
|
(proxy as unknown as {
|
||||||
proxy as unknown as {
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
}).maybeEnqueueFromRequest(
|
||||||
}
|
|
||||||
).maybeEnqueueFromRequest(
|
|
||||||
{
|
{
|
||||||
action: 'multi',
|
action: 'multi',
|
||||||
params: {
|
params: {
|
||||||
actions: [{ action: 'version' }, { action: 'addNote' }, { action: 'addNotes' }],
|
actions: [
|
||||||
|
{ action: 'version' },
|
||||||
|
{ action: 'addNote' },
|
||||||
|
{ action: 'addNotes' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Buffer.from(
|
Buffer.from(
|
||||||
@@ -200,11 +196,9 @@ test('proxy skips auto-enrichment when auto-update is disabled', async () => {
|
|||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
(
|
(proxy as unknown as {
|
||||||
proxy as unknown as {
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
}).maybeEnqueueFromRequest(
|
||||||
}
|
|
||||||
).maybeEnqueueFromRequest(
|
|
||||||
{ action: 'addNote' },
|
{ action: 'addNote' },
|
||||||
Buffer.from(JSON.stringify({ result: 303, error: null }), 'utf8'),
|
Buffer.from(JSON.stringify({ result: 303, error: null }), 'utf8'),
|
||||||
);
|
);
|
||||||
@@ -225,11 +219,9 @@ test('proxy ignores addNote when upstream response reports error', async () => {
|
|||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
(
|
(proxy as unknown as {
|
||||||
proxy as unknown as {
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
}).maybeEnqueueFromRequest(
|
||||||
}
|
|
||||||
).maybeEnqueueFromRequest(
|
|
||||||
{ action: 'addNote' },
|
{ action: 'addNote' },
|
||||||
Buffer.from(JSON.stringify({ result: 123, error: 'duplicate' }), 'utf8'),
|
Buffer.from(JSON.stringify({ result: 123, error: 'duplicate' }), 'utf8'),
|
||||||
);
|
);
|
||||||
@@ -256,11 +248,9 @@ test('proxy does not fallback-enqueue latest note for multi requests without add
|
|||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
(
|
(proxy as unknown as {
|
||||||
proxy as unknown as {
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
}).maybeEnqueueFromRequest(
|
||||||
}
|
|
||||||
).maybeEnqueueFromRequest(
|
|
||||||
{
|
{
|
||||||
action: 'multi',
|
action: 'multi',
|
||||||
params: {
|
params: {
|
||||||
@@ -275,38 +265,6 @@ test('proxy does not fallback-enqueue latest note for multi requests without add
|
|||||||
assert.deepEqual(processed, []);
|
assert.deepEqual(processed, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('proxy fallback-enqueues latest note for addNote responses without note IDs and escapes deck quotes', async () => {
|
|
||||||
const processed: number[] = [];
|
|
||||||
const findNotesQueries: string[] = [];
|
|
||||||
const proxy = new AnkiConnectProxyServer({
|
|
||||||
shouldAutoUpdateNewCards: () => true,
|
|
||||||
processNewCard: async (noteId) => {
|
|
||||||
processed.push(noteId);
|
|
||||||
},
|
|
||||||
getDeck: () => 'My "Japanese" Deck',
|
|
||||||
findNotes: async (query) => {
|
|
||||||
findNotesQueries.push(query);
|
|
||||||
return [500, 501];
|
|
||||||
},
|
|
||||||
logInfo: () => undefined,
|
|
||||||
logWarn: () => undefined,
|
|
||||||
logError: () => undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
(
|
|
||||||
proxy as unknown as {
|
|
||||||
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
|
||||||
}
|
|
||||||
).maybeEnqueueFromRequest(
|
|
||||||
{ action: 'addNote' },
|
|
||||||
Buffer.from(JSON.stringify({ result: 0, error: null }), 'utf8'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitForCondition(() => processed.length === 1);
|
|
||||||
assert.deepEqual(findNotesQueries, ['"deck:My \\"Japanese\\" Deck" added:1']);
|
|
||||||
assert.deepEqual(processed, [501]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('proxy detects self-referential loop configuration', () => {
|
test('proxy detects self-referential loop configuration', () => {
|
||||||
const proxy = new AnkiConnectProxyServer({
|
const proxy = new AnkiConnectProxyServer({
|
||||||
shouldAutoUpdateNewCards: () => true,
|
shouldAutoUpdateNewCards: () => true,
|
||||||
@@ -316,15 +274,13 @@ test('proxy detects self-referential loop configuration', () => {
|
|||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = (
|
const result = (proxy as unknown as {
|
||||||
proxy as unknown as {
|
isSelfReferentialProxy: (options: {
|
||||||
isSelfReferentialProxy: (options: {
|
host: string;
|
||||||
host: string;
|
port: number;
|
||||||
port: number;
|
upstreamUrl: string;
|
||||||
upstreamUrl: string;
|
}) => boolean;
|
||||||
}) => boolean;
|
}).isSelfReferentialProxy({
|
||||||
}
|
|
||||||
).isSelfReferentialProxy({
|
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 8766,
|
port: 8766,
|
||||||
upstreamUrl: 'http://localhost:8766',
|
upstreamUrl: 'http://localhost:8766',
|
||||||
|
|||||||
@@ -175,9 +175,7 @@ export class AnkiConnectProxyServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const action =
|
const action =
|
||||||
typeof requestJson.action === 'string'
|
typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? '');
|
||||||
? requestJson.action
|
|
||||||
: String(requestJson.action ?? '');
|
|
||||||
if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') {
|
if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -235,8 +233,7 @@ export class AnkiConnectProxyServer {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const deck = this.deps.getDeck ? this.deps.getDeck() : undefined;
|
const deck = this.deps.getDeck ? this.deps.getDeck() : undefined;
|
||||||
const escapedDeck = deck ? deck.replace(/"/g, '\\"') : undefined;
|
const query = deck ? `"deck:${deck}" added:1` : 'added:1';
|
||||||
const query = escapedDeck ? `"deck:${escapedDeck}" added:1` : 'added:1';
|
|
||||||
const noteIds = await findNotes(query, { maxRetries: 0 });
|
const noteIds = await findNotes(query, { maxRetries: 0 });
|
||||||
if (!noteIds || noteIds.length === 0) {
|
if (!noteIds || noteIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -89,11 +89,7 @@ test('findDuplicateNote checks both source expression/word values when both fiel
|
|||||||
if (query.includes('昨日は雨だった。')) {
|
if (query.includes('昨日は雨だった。')) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (
|
if (query.includes('"Word:雨"') || query.includes('"word:雨"') || query.includes('"Expression:雨"')) {
|
||||||
query.includes('"Word:雨"') ||
|
|
||||||
query.includes('"word:雨"') ||
|
|
||||||
query.includes('"Expression:雨"')
|
|
||||||
) {
|
|
||||||
return [200];
|
return [200];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export async function findDuplicateNote(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const deckValue = deps.getDeck();
|
const deckValue = deps.getDeck();
|
||||||
const queryPrefixes = deckValue ? [`"deck:${escapeAnkiSearchValue(deckValue)}" `, ''] : [''];
|
const queryPrefixes = deckValue
|
||||||
|
? [`"deck:${escapeAnkiSearchValue(deckValue)}" `, '']
|
||||||
|
: [''];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const noteIds = new Set<number>();
|
const noteIds = new Set<number>();
|
||||||
|
|||||||
@@ -112,7 +112,12 @@ export class FieldGroupingWorkflow {
|
|||||||
const keepNoteId = choice.keepNoteId;
|
const keepNoteId = choice.keepNoteId;
|
||||||
const deleteNoteId = choice.deleteNoteId;
|
const deleteNoteId = choice.deleteNoteId;
|
||||||
|
|
||||||
await this.performMerge(keepNoteId, deleteNoteId, expression, choice.deleteDuplicate);
|
await this.performMerge(
|
||||||
|
keepNoteId,
|
||||||
|
deleteNoteId,
|
||||||
|
expression,
|
||||||
|
choice.deleteDuplicate,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.deps.logError('Field grouping manual merge failed:', (error as Error).message);
|
this.deps.logError('Field grouping manual merge failed:', (error as Error).message);
|
||||||
|
|||||||
@@ -51,10 +51,18 @@ function createWorkflowHarness() {
|
|||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
findDuplicateNote: async (_expression, _excludeNoteId, _noteInfo) => null,
|
findDuplicateNote: async (_expression, _excludeNoteId, _noteInfo) => null,
|
||||||
handleFieldGroupingAuto: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
handleFieldGroupingAuto: async (
|
||||||
undefined,
|
_originalNoteId,
|
||||||
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
_newNoteId,
|
||||||
false,
|
_newNoteInfo,
|
||||||
|
_expression,
|
||||||
|
) => undefined,
|
||||||
|
handleFieldGroupingManual: async (
|
||||||
|
_originalNoteId,
|
||||||
|
_newNoteId,
|
||||||
|
_newNoteInfo,
|
||||||
|
_expression,
|
||||||
|
) => false,
|
||||||
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
||||||
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
||||||
if (!preferred) return null;
|
if (!preferred) return null;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { hasExplicitCommand, parseArgs, shouldRunSettingsOnlyStartup, shouldStartApp } from './args';
|
import { hasExplicitCommand, parseArgs, shouldStartApp } from './args';
|
||||||
|
|
||||||
test('parseArgs parses booleans and value flags', () => {
|
test('parseArgs parses booleans and value flags', () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
@@ -42,30 +42,6 @@ test('parseArgs ignores missing value after --log-level', () => {
|
|||||||
assert.equal(args.start, true);
|
assert.equal(args.start, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs handles jellyfin item listing controls', () => {
|
|
||||||
const args = parseArgs([
|
|
||||||
'--jellyfin-items',
|
|
||||||
'--jellyfin-recursive=false',
|
|
||||||
'--jellyfin-include-item-types',
|
|
||||||
'Series,Movie,Folder',
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(args.jellyfinItems, true);
|
|
||||||
assert.equal(args.jellyfinRecursive, false);
|
|
||||||
assert.equal(args.jellyfinIncludeItemTypes, 'Series,Movie,Folder');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseArgs handles space-separated jellyfin recursive control', () => {
|
|
||||||
const args = parseArgs(['--jellyfin-items', '--jellyfin-recursive', 'false']);
|
|
||||||
assert.equal(args.jellyfinRecursive, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseArgs ignores unrecognized space-separated jellyfin recursive values', () => {
|
|
||||||
const args = parseArgs(['--jellyfin-items', '--jellyfin-recursive', '--start']);
|
|
||||||
assert.equal(args.jellyfinRecursive, undefined);
|
|
||||||
assert.equal(args.start, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||||
const stopOnly = parseArgs(['--stop']);
|
const stopOnly = parseArgs(['--stop']);
|
||||||
assert.equal(hasExplicitCommand(stopOnly), true);
|
assert.equal(hasExplicitCommand(stopOnly), true);
|
||||||
@@ -84,28 +60,6 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(hasExplicitCommand(refreshKnownWords), true);
|
assert.equal(hasExplicitCommand(refreshKnownWords), true);
|
||||||
assert.equal(shouldStartApp(refreshKnownWords), false);
|
assert.equal(shouldStartApp(refreshKnownWords), false);
|
||||||
|
|
||||||
const settings = parseArgs(['--settings']);
|
|
||||||
assert.equal(settings.settings, true);
|
|
||||||
assert.equal(hasExplicitCommand(settings), true);
|
|
||||||
assert.equal(shouldStartApp(settings), true);
|
|
||||||
assert.equal(shouldRunSettingsOnlyStartup(settings), true);
|
|
||||||
|
|
||||||
const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']);
|
|
||||||
assert.equal(settingsWithOverlay.settings, true);
|
|
||||||
assert.equal(settingsWithOverlay.toggleVisibleOverlay, true);
|
|
||||||
assert.equal(shouldRunSettingsOnlyStartup(settingsWithOverlay), false);
|
|
||||||
|
|
||||||
const yomitanAlias = parseArgs(['--yomitan']);
|
|
||||||
assert.equal(yomitanAlias.settings, true);
|
|
||||||
assert.equal(hasExplicitCommand(yomitanAlias), true);
|
|
||||||
assert.equal(shouldStartApp(yomitanAlias), true);
|
|
||||||
|
|
||||||
const help = parseArgs(['--help']);
|
|
||||||
assert.equal(help.help, true);
|
|
||||||
assert.equal(hasExplicitCommand(help), true);
|
|
||||||
assert.equal(shouldStartApp(help), false);
|
|
||||||
assert.equal(shouldRunSettingsOnlyStartup(help), false);
|
|
||||||
|
|
||||||
const anilistStatus = parseArgs(['--anilist-status']);
|
const anilistStatus = parseArgs(['--anilist-status']);
|
||||||
assert.equal(anilistStatus.anilistStatus, true);
|
assert.equal(anilistStatus.anilistStatus, true);
|
||||||
assert.equal(hasExplicitCommand(anilistStatus), true);
|
assert.equal(hasExplicitCommand(anilistStatus), true);
|
||||||
@@ -142,19 +96,6 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(hasExplicitCommand(jellyfinRemoteAnnounce), true);
|
assert.equal(hasExplicitCommand(jellyfinRemoteAnnounce), true);
|
||||||
assert.equal(shouldStartApp(jellyfinRemoteAnnounce), false);
|
assert.equal(shouldStartApp(jellyfinRemoteAnnounce), false);
|
||||||
|
|
||||||
const jellyfinPreviewAuth = parseArgs([
|
|
||||||
'--jellyfin-preview-auth',
|
|
||||||
'--jellyfin-response-path',
|
|
||||||
'/tmp/subminer-jf-response.json',
|
|
||||||
]);
|
|
||||||
assert.equal(jellyfinPreviewAuth.jellyfinPreviewAuth, true);
|
|
||||||
assert.equal(
|
|
||||||
jellyfinPreviewAuth.jellyfinResponsePath,
|
|
||||||
'/tmp/subminer-jf-response.json',
|
|
||||||
);
|
|
||||||
assert.equal(hasExplicitCommand(jellyfinPreviewAuth), true);
|
|
||||||
assert.equal(shouldStartApp(jellyfinPreviewAuth), false);
|
|
||||||
|
|
||||||
const background = parseArgs(['--background']);
|
const background = parseArgs(['--background']);
|
||||||
assert.equal(background.background, true);
|
assert.equal(background.background, true);
|
||||||
assert.equal(hasExplicitCommand(background), true);
|
assert.equal(hasExplicitCommand(background), true);
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export interface CliArgs {
|
|||||||
jellyfinSubtitleUrlsOnly: boolean;
|
jellyfinSubtitleUrlsOnly: boolean;
|
||||||
jellyfinPlay: boolean;
|
jellyfinPlay: boolean;
|
||||||
jellyfinRemoteAnnounce: boolean;
|
jellyfinRemoteAnnounce: boolean;
|
||||||
jellyfinPreviewAuth: boolean;
|
|
||||||
texthooker: boolean;
|
texthooker: boolean;
|
||||||
help: boolean;
|
help: boolean;
|
||||||
autoStartOverlay: boolean;
|
autoStartOverlay: boolean;
|
||||||
@@ -50,11 +49,8 @@ export interface CliArgs {
|
|||||||
jellyfinItemId?: string;
|
jellyfinItemId?: string;
|
||||||
jellyfinSearch?: string;
|
jellyfinSearch?: string;
|
||||||
jellyfinLimit?: number;
|
jellyfinLimit?: number;
|
||||||
jellyfinRecursive?: boolean;
|
|
||||||
jellyfinIncludeItemTypes?: string;
|
|
||||||
jellyfinAudioStreamIndex?: number;
|
jellyfinAudioStreamIndex?: number;
|
||||||
jellyfinSubtitleStreamIndex?: number;
|
jellyfinSubtitleStreamIndex?: number;
|
||||||
jellyfinResponsePath?: string;
|
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||||
}
|
}
|
||||||
@@ -97,7 +93,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
jellyfinSubtitleUrlsOnly: false,
|
jellyfinSubtitleUrlsOnly: false,
|
||||||
jellyfinPlay: false,
|
jellyfinPlay: false,
|
||||||
jellyfinRemoteAnnounce: false,
|
jellyfinRemoteAnnounce: false,
|
||||||
jellyfinPreviewAuth: false,
|
|
||||||
texthooker: false,
|
texthooker: false,
|
||||||
help: false,
|
help: false,
|
||||||
autoStartOverlay: false,
|
autoStartOverlay: false,
|
||||||
@@ -152,7 +147,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
args.jellyfinSubtitleUrlsOnly = true;
|
args.jellyfinSubtitleUrlsOnly = true;
|
||||||
} else if (arg === '--jellyfin-play') args.jellyfinPlay = true;
|
} else if (arg === '--jellyfin-play') args.jellyfinPlay = true;
|
||||||
else if (arg === '--jellyfin-remote-announce') args.jellyfinRemoteAnnounce = true;
|
else if (arg === '--jellyfin-remote-announce') args.jellyfinRemoteAnnounce = true;
|
||||||
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
|
|
||||||
else if (arg === '--texthooker') args.texthooker = true;
|
else if (arg === '--texthooker') args.texthooker = true;
|
||||||
else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
|
else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
|
||||||
else if (arg === '--generate-config') args.generateConfig = true;
|
else if (arg === '--generate-config') args.generateConfig = true;
|
||||||
@@ -235,25 +229,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
} else if (arg === '--jellyfin-limit') {
|
} else if (arg === '--jellyfin-limit') {
|
||||||
const value = Number(readValue(argv[i + 1]));
|
const value = Number(readValue(argv[i + 1]));
|
||||||
if (Number.isFinite(value) && value > 0) args.jellyfinLimit = Math.floor(value);
|
if (Number.isFinite(value) && value > 0) args.jellyfinLimit = Math.floor(value);
|
||||||
} else if (arg.startsWith('--jellyfin-recursive=')) {
|
|
||||||
const value = arg.split('=', 2)[1]?.trim().toLowerCase();
|
|
||||||
if (value === 'true' || value === '1' || value === 'yes') args.jellyfinRecursive = true;
|
|
||||||
if (value === 'false' || value === '0' || value === 'no') args.jellyfinRecursive = false;
|
|
||||||
} else if (arg === '--jellyfin-recursive') {
|
|
||||||
const value = readValue(argv[i + 1])?.trim().toLowerCase();
|
|
||||||
if (value === 'false' || value === '0' || value === 'no') {
|
|
||||||
args.jellyfinRecursive = false;
|
|
||||||
} else if (value === 'true' || value === '1' || value === 'yes') {
|
|
||||||
args.jellyfinRecursive = true;
|
|
||||||
}
|
|
||||||
} else if (arg === '--jellyfin-non-recursive') {
|
|
||||||
args.jellyfinRecursive = false;
|
|
||||||
} else if (arg.startsWith('--jellyfin-include-item-types=')) {
|
|
||||||
const value = arg.split('=', 2)[1];
|
|
||||||
if (value) args.jellyfinIncludeItemTypes = value;
|
|
||||||
} else if (arg === '--jellyfin-include-item-types') {
|
|
||||||
const value = readValue(argv[i + 1]);
|
|
||||||
if (value) args.jellyfinIncludeItemTypes = value;
|
|
||||||
} else if (arg.startsWith('--jellyfin-audio-stream-index=')) {
|
} else if (arg.startsWith('--jellyfin-audio-stream-index=')) {
|
||||||
const value = Number(arg.split('=', 2)[1]);
|
const value = Number(arg.split('=', 2)[1]);
|
||||||
if (Number.isInteger(value) && value >= 0) args.jellyfinAudioStreamIndex = value;
|
if (Number.isInteger(value) && value >= 0) args.jellyfinAudioStreamIndex = value;
|
||||||
@@ -266,12 +241,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
} else if (arg === '--jellyfin-subtitle-stream-index') {
|
} else if (arg === '--jellyfin-subtitle-stream-index') {
|
||||||
const value = Number(readValue(argv[i + 1]));
|
const value = Number(readValue(argv[i + 1]));
|
||||||
if (Number.isInteger(value) && value >= 0) args.jellyfinSubtitleStreamIndex = value;
|
if (Number.isInteger(value) && value >= 0) args.jellyfinSubtitleStreamIndex = value;
|
||||||
} else if (arg.startsWith('--jellyfin-response-path=')) {
|
|
||||||
const value = arg.split('=', 2)[1];
|
|
||||||
if (value) args.jellyfinResponsePath = value;
|
|
||||||
} else if (arg === '--jellyfin-response-path') {
|
|
||||||
const value = readValue(argv[i + 1]);
|
|
||||||
if (value) args.jellyfinResponsePath = value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +282,6 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.jellyfinSubtitles ||
|
args.jellyfinSubtitles ||
|
||||||
args.jellyfinPlay ||
|
args.jellyfinPlay ||
|
||||||
args.jellyfinRemoteAnnounce ||
|
args.jellyfinRemoteAnnounce ||
|
||||||
args.jellyfinPreviewAuth ||
|
|
||||||
args.texthooker ||
|
args.texthooker ||
|
||||||
args.generateConfig ||
|
args.generateConfig ||
|
||||||
args.help
|
args.help
|
||||||
@@ -327,7 +295,6 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.start ||
|
args.start ||
|
||||||
args.toggle ||
|
args.toggle ||
|
||||||
args.toggleVisibleOverlay ||
|
args.toggleVisibleOverlay ||
|
||||||
args.settings ||
|
|
||||||
args.copySubtitle ||
|
args.copySubtitle ||
|
||||||
args.copySubtitleMultiple ||
|
args.copySubtitleMultiple ||
|
||||||
args.mineSentence ||
|
args.mineSentence ||
|
||||||
@@ -347,51 +314,6 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
|
||||||
return (
|
|
||||||
args.settings &&
|
|
||||||
!args.background &&
|
|
||||||
!args.start &&
|
|
||||||
!args.stop &&
|
|
||||||
!args.toggle &&
|
|
||||||
!args.toggleVisibleOverlay &&
|
|
||||||
!args.show &&
|
|
||||||
!args.hide &&
|
|
||||||
!args.showVisibleOverlay &&
|
|
||||||
!args.hideVisibleOverlay &&
|
|
||||||
!args.copySubtitle &&
|
|
||||||
!args.copySubtitleMultiple &&
|
|
||||||
!args.mineSentence &&
|
|
||||||
!args.mineSentenceMultiple &&
|
|
||||||
!args.updateLastCardFromClipboard &&
|
|
||||||
!args.refreshKnownWords &&
|
|
||||||
!args.toggleSecondarySub &&
|
|
||||||
!args.triggerFieldGrouping &&
|
|
||||||
!args.triggerSubsync &&
|
|
||||||
!args.markAudioCard &&
|
|
||||||
!args.openRuntimeOptions &&
|
|
||||||
!args.anilistStatus &&
|
|
||||||
!args.anilistLogout &&
|
|
||||||
!args.anilistSetup &&
|
|
||||||
!args.anilistRetryQueue &&
|
|
||||||
!args.jellyfin &&
|
|
||||||
!args.jellyfinLogin &&
|
|
||||||
!args.jellyfinLogout &&
|
|
||||||
!args.jellyfinLibraries &&
|
|
||||||
!args.jellyfinItems &&
|
|
||||||
!args.jellyfinSubtitles &&
|
|
||||||
!args.jellyfinPlay &&
|
|
||||||
!args.jellyfinRemoteAnnounce &&
|
|
||||||
!args.jellyfinPreviewAuth &&
|
|
||||||
!args.texthooker &&
|
|
||||||
!args.help &&
|
|
||||||
!args.autoStartOverlay &&
|
|
||||||
!args.generateConfig &&
|
|
||||||
!args.backupOverwrite &&
|
|
||||||
!args.debug
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||||
return (
|
return (
|
||||||
args.toggle ||
|
args.toggle ||
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
||||||
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
|
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
|
||||||
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
|
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
|
||||||
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
|
||||||
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||||
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
|
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -47,7 +46,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
|
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
|
||||||
assert.equal(config.subtitleStyle.textShadow, '0 3px 10px rgba(0,0,0,0.69)');
|
assert.equal(config.subtitleStyle.textShadow, '0 3px 10px rgba(0,0,0,0.69)');
|
||||||
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
|
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
|
||||||
assert.equal(config.subtitleStyle.secondary.fontFamily, 'Inter, Noto Sans, Helvetica Neue, sans-serif');
|
assert.equal(config.subtitleStyle.secondary.fontFamily, 'Manrope, Inter');
|
||||||
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
|
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
|
||||||
assert.equal(config.immersionTracking.enabled, true);
|
assert.equal(config.immersionTracking.enabled, true);
|
||||||
assert.equal(config.immersionTracking.dbPath, '');
|
assert.equal(config.immersionTracking.dbPath, '');
|
||||||
@@ -119,44 +118,6 @@ test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parses subtitleStyle.autoPauseVideoOnHover and warns on invalid values', () => {
|
|
||||||
const validDir = makeTempDir();
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(validDir, 'config.jsonc'),
|
|
||||||
`{
|
|
||||||
"subtitleStyle": {
|
|
||||||
"autoPauseVideoOnHover": true
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const validService = new ConfigService(validDir);
|
|
||||||
assert.equal(validService.getConfig().subtitleStyle.autoPauseVideoOnHover, true);
|
|
||||||
|
|
||||||
const invalidDir = makeTempDir();
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(invalidDir, 'config.jsonc'),
|
|
||||||
`{
|
|
||||||
"subtitleStyle": {
|
|
||||||
"autoPauseVideoOnHover": "yes"
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const invalidService = new ConfigService(invalidDir);
|
|
||||||
assert.equal(
|
|
||||||
invalidService.getConfig().subtitleStyle.autoPauseVideoOnHover,
|
|
||||||
DEFAULT_CONFIG.subtitleStyle.autoPauseVideoOnHover,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
invalidService
|
|
||||||
.getWarnings()
|
|
||||||
.some((warning) => warning.path === 'subtitleStyle.autoPauseVideoOnHover'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
|
test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
|
||||||
const validDir = makeTempDir();
|
const validDir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -787,9 +748,6 @@ test('runtime options registry is centralized', () => {
|
|||||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||||
assert.deepEqual(ids, [
|
assert.deepEqual(ids, [
|
||||||
'anki.autoUpdateNewCards',
|
'anki.autoUpdateNewCards',
|
||||||
'subtitle.annotation.nPlusOne',
|
|
||||||
'subtitle.annotation.jlpt',
|
|
||||||
'subtitle.annotation.frequency',
|
|
||||||
'anki.nPlusOneMatchMode',
|
'anki.nPlusOneMatchMode',
|
||||||
'anki.kikuFieldGrouping',
|
'anki.kikuFieldGrouping',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
|||||||
subtitleStyle: {
|
subtitleStyle: {
|
||||||
enableJlpt: false,
|
enableJlpt: false,
|
||||||
preserveLineBreaks: false,
|
preserveLineBreaks: false,
|
||||||
autoPauseVideoOnHover: true,
|
|
||||||
hoverTokenColor: '#f4dbd6',
|
hoverTokenColor: '#f4dbd6',
|
||||||
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
|
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
|
||||||
@@ -34,12 +33,11 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
|||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
topX: 1000,
|
topX: 1000,
|
||||||
mode: 'single',
|
mode: 'single',
|
||||||
matchMode: 'headword',
|
|
||||||
singleColor: '#f5a97f',
|
singleColor: '#f5a97f',
|
||||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'],
|
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'],
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
|
fontFamily: 'Manrope, Inter',
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontColor: '#cad3f5',
|
fontColor: '#cad3f5',
|
||||||
lineHeight: 1.35,
|
lineHeight: 1.35,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
CONFIG_OPTION_REGISTRY,
|
CONFIG_OPTION_REGISTRY,
|
||||||
CONFIG_TEMPLATE_SECTIONS,
|
CONFIG_TEMPLATE_SECTIONS,
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
DEFAULT_KEYBINDINGS,
|
|
||||||
RUNTIME_OPTION_REGISTRY,
|
RUNTIME_OPTION_REGISTRY,
|
||||||
} from '../definitions';
|
} from '../definitions';
|
||||||
import { buildCoreConfigOptionRegistry } from './options-core';
|
import { buildCoreConfigOptionRegistry } from './options-core';
|
||||||
@@ -60,11 +59,3 @@ test('domain registry builders each contribute entries to composed registry', ()
|
|||||||
assert.ok(entries.some((entry) => composedPaths.has(entry.path)));
|
assert.ok(entries.some((entry) => composedPaths.has(entry.path)));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('default keybindings include primary and secondary subtitle track cycling on J keys', () => {
|
|
||||||
const keybindingMap = new Map(
|
|
||||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
|
||||||
);
|
|
||||||
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
|
|
||||||
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultConfig: ResolvedConfig,
|
defaultConfig: ResolvedConfig,
|
||||||
runtimeOptionRegistry: RuntimeOptionRegistryEntry[],
|
runtimeOptionRegistry: RuntimeOptionRegistryEntry[],
|
||||||
): ConfigOptionRegistryEntry[] {
|
): ConfigOptionRegistryEntry[] {
|
||||||
const runtimeOptionById = new Map(runtimeOptionRegistry.map((entry) => [entry.id, entry]));
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.enabled',
|
path: 'ankiConnect.enabled',
|
||||||
@@ -56,7 +54,7 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
defaultValue: defaultConfig.ankiConnect.behavior.autoUpdateNewCards,
|
defaultValue: defaultConfig.ankiConnect.behavior.autoUpdateNewCards,
|
||||||
description: 'Automatically update newly added cards.',
|
description: 'Automatically update newly added cards.',
|
||||||
runtime: runtimeOptionById.get('anki.autoUpdateNewCards'),
|
runtime: runtimeOptionRegistry[0],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.nPlusOne.matchMode',
|
path: 'ankiConnect.nPlusOne.matchMode',
|
||||||
@@ -107,7 +105,7 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
enumValues: ['auto', 'manual', 'disabled'],
|
enumValues: ['auto', 'manual', 'disabled'],
|
||||||
defaultValue: defaultConfig.ankiConnect.isKiku.fieldGrouping,
|
defaultValue: defaultConfig.ankiConnect.isKiku.fieldGrouping,
|
||||||
description: 'Kiku duplicate-card field grouping mode.',
|
description: 'Kiku duplicate-card field grouping mode.',
|
||||||
runtime: runtimeOptionById.get('anki.kikuFieldGrouping'),
|
runtime: runtimeOptionRegistry[1],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'jimaku.languagePreference',
|
path: 'jimaku.languagePreference',
|
||||||
|
|||||||
@@ -21,13 +21,6 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
'Preserve line breaks in visible overlay subtitle rendering. ' +
|
'Preserve line breaks in visible overlay subtitle rendering. ' +
|
||||||
'When false, line breaks are flattened to spaces for a single-line flow.',
|
'When false, line breaks are flattened to spaces for a single-line flow.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'subtitleStyle.autoPauseVideoOnHover',
|
|
||||||
kind: 'boolean',
|
|
||||||
defaultValue: defaultConfig.subtitleStyle.autoPauseVideoOnHover,
|
|
||||||
description:
|
|
||||||
'Automatically pause mpv playback while hovering subtitle text, then resume on leave.',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'subtitleStyle.hoverTokenColor',
|
path: 'subtitleStyle.hoverTokenColor',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
@@ -68,14 +61,6 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'single: use one color for all matching tokens. banded: use color ramp by frequency band.',
|
'single: use one color for all matching tokens. banded: use color ramp by frequency band.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'subtitleStyle.frequencyDictionary.matchMode',
|
|
||||||
kind: 'enum',
|
|
||||||
enumValues: ['headword', 'surface'],
|
|
||||||
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.matchMode,
|
|
||||||
description:
|
|
||||||
'headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text.',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'subtitleStyle.frequencyDictionary.singleColor',
|
path: 'subtitleStyle.frequencyDictionary.singleColor',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -19,42 +19,6 @@ export function buildRuntimeOptionRegistry(
|
|||||||
behavior: { autoUpdateNewCards: value === true },
|
behavior: { autoUpdateNewCards: value === true },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'subtitle.annotation.nPlusOne',
|
|
||||||
path: 'ankiConnect.nPlusOne.highlightEnabled',
|
|
||||||
label: 'N+1 Annotation',
|
|
||||||
scope: 'subtitle',
|
|
||||||
valueType: 'boolean',
|
|
||||||
allowedValues: [true, false],
|
|
||||||
defaultValue: defaultConfig.ankiConnect.nPlusOne.highlightEnabled,
|
|
||||||
requiresRestart: false,
|
|
||||||
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
|
|
||||||
toAnkiPatch: () => ({}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'subtitle.annotation.jlpt',
|
|
||||||
path: 'subtitleStyle.enableJlpt',
|
|
||||||
label: 'JLPT Annotation',
|
|
||||||
scope: 'subtitle',
|
|
||||||
valueType: 'boolean',
|
|
||||||
allowedValues: [true, false],
|
|
||||||
defaultValue: defaultConfig.subtitleStyle.enableJlpt,
|
|
||||||
requiresRestart: false,
|
|
||||||
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
|
|
||||||
toAnkiPatch: () => ({}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'subtitle.annotation.frequency',
|
|
||||||
path: 'subtitleStyle.frequencyDictionary.enabled',
|
|
||||||
label: 'Frequency Annotation',
|
|
||||||
scope: 'subtitle',
|
|
||||||
valueType: 'boolean',
|
|
||||||
allowedValues: [true, false],
|
|
||||||
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.enabled,
|
|
||||||
requiresRestart: false,
|
|
||||||
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
|
|
||||||
toAnkiPatch: () => ({}),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'anki.nPlusOneMatchMode',
|
id: 'anki.nPlusOneMatchMode',
|
||||||
path: 'ankiConnect.nPlusOne.matchMode',
|
path: 'ankiConnect.nPlusOne.matchMode',
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ export const SPECIAL_COMMANDS = {
|
|||||||
|
|
||||||
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||||
{ key: 'Space', command: ['cycle', 'pause'] },
|
{ key: 'Space', command: ['cycle', 'pause'] },
|
||||||
{ key: 'KeyJ', command: ['cycle', 'sid'] },
|
|
||||||
{ key: 'Shift+KeyJ', command: ['cycle', 'secondary-sid'] },
|
|
||||||
{ key: 'ArrowRight', command: ['seek', 5] },
|
{ key: 'ArrowRight', command: ['seek', 5] },
|
||||||
{ key: 'ArrowLeft', command: ['seek', -5] },
|
{ key: 'ArrowLeft', command: ['seek', -5] },
|
||||||
{ key: 'ArrowUp', command: ['seek', 60] },
|
{ key: 'ArrowUp', command: ['seek', 60] },
|
||||||
|
|||||||
@@ -99,24 +99,12 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
if (isObject(src.subtitleStyle)) {
|
if (isObject(src.subtitleStyle)) {
|
||||||
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
|
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
|
||||||
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
|
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
|
||||||
const fallbackSubtitleStyleAutoPauseVideoOnHover =
|
|
||||||
resolved.subtitleStyle.autoPauseVideoOnHover;
|
|
||||||
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
||||||
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
||||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||||
const fallbackFrequencyDictionary = {
|
|
||||||
...resolved.subtitleStyle.frequencyDictionary,
|
|
||||||
};
|
|
||||||
resolved.subtitleStyle = {
|
resolved.subtitleStyle = {
|
||||||
...resolved.subtitleStyle,
|
...resolved.subtitleStyle,
|
||||||
...(src.subtitleStyle as ResolvedConfig['subtitleStyle']),
|
...(src.subtitleStyle as ResolvedConfig['subtitleStyle']),
|
||||||
frequencyDictionary: {
|
|
||||||
...resolved.subtitleStyle.frequencyDictionary,
|
|
||||||
...(isObject((src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary)
|
|
||||||
? ((src.subtitleStyle as { frequencyDictionary?: unknown })
|
|
||||||
.frequencyDictionary as ResolvedConfig['subtitleStyle']['frequencyDictionary'])
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
secondary: {
|
secondary: {
|
||||||
...resolved.subtitleStyle.secondary,
|
...resolved.subtitleStyle.secondary,
|
||||||
...(isObject(src.subtitleStyle.secondary)
|
...(isObject(src.subtitleStyle.secondary)
|
||||||
@@ -155,27 +143,7 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoPauseVideoOnHover = asBoolean(
|
const hoverTokenColor = asColor((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor);
|
||||||
(src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover,
|
|
||||||
);
|
|
||||||
if (autoPauseVideoOnHover !== undefined) {
|
|
||||||
resolved.subtitleStyle.autoPauseVideoOnHover = autoPauseVideoOnHover;
|
|
||||||
} else if (
|
|
||||||
(src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover !==
|
|
||||||
undefined
|
|
||||||
) {
|
|
||||||
resolved.subtitleStyle.autoPauseVideoOnHover = fallbackSubtitleStyleAutoPauseVideoOnHover;
|
|
||||||
warn(
|
|
||||||
'subtitleStyle.autoPauseVideoOnHover',
|
|
||||||
(src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover,
|
|
||||||
resolved.subtitleStyle.autoPauseVideoOnHover,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hoverTokenColor = asColor(
|
|
||||||
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
|
|
||||||
);
|
|
||||||
if (hoverTokenColor !== undefined) {
|
if (hoverTokenColor !== undefined) {
|
||||||
resolved.subtitleStyle.hoverTokenColor = hoverTokenColor;
|
resolved.subtitleStyle.hoverTokenColor = hoverTokenColor;
|
||||||
} else if ((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor !== undefined) {
|
} else if ((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor !== undefined) {
|
||||||
@@ -197,8 +165,7 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor !==
|
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor !==
|
||||||
undefined
|
undefined
|
||||||
) {
|
) {
|
||||||
resolved.subtitleStyle.hoverTokenBackgroundColor =
|
resolved.subtitleStyle.hoverTokenBackgroundColor = fallbackSubtitleStyleHoverTokenBackgroundColor;
|
||||||
fallbackSubtitleStyleHoverTokenBackgroundColor;
|
|
||||||
warn(
|
warn(
|
||||||
'subtitleStyle.hoverTokenBackgroundColor',
|
'subtitleStyle.hoverTokenBackgroundColor',
|
||||||
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
|
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
|
||||||
@@ -219,7 +186,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
if (frequencyEnabled !== undefined) {
|
if (frequencyEnabled !== undefined) {
|
||||||
resolved.subtitleStyle.frequencyDictionary.enabled = frequencyEnabled;
|
resolved.subtitleStyle.frequencyDictionary.enabled = frequencyEnabled;
|
||||||
} else if ((frequencyDictionary as { enabled?: unknown }).enabled !== undefined) {
|
} else if ((frequencyDictionary as { enabled?: unknown }).enabled !== undefined) {
|
||||||
resolved.subtitleStyle.frequencyDictionary.enabled = fallbackFrequencyDictionary.enabled;
|
|
||||||
warn(
|
warn(
|
||||||
'subtitleStyle.frequencyDictionary.enabled',
|
'subtitleStyle.frequencyDictionary.enabled',
|
||||||
(frequencyDictionary as { enabled?: unknown }).enabled,
|
(frequencyDictionary as { enabled?: unknown }).enabled,
|
||||||
@@ -232,8 +198,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
if (sourcePath !== undefined) {
|
if (sourcePath !== undefined) {
|
||||||
resolved.subtitleStyle.frequencyDictionary.sourcePath = sourcePath;
|
resolved.subtitleStyle.frequencyDictionary.sourcePath = sourcePath;
|
||||||
} else if ((frequencyDictionary as { sourcePath?: unknown }).sourcePath !== undefined) {
|
} else if ((frequencyDictionary as { sourcePath?: unknown }).sourcePath !== undefined) {
|
||||||
resolved.subtitleStyle.frequencyDictionary.sourcePath =
|
|
||||||
fallbackFrequencyDictionary.sourcePath;
|
|
||||||
warn(
|
warn(
|
||||||
'subtitleStyle.frequencyDictionary.sourcePath',
|
'subtitleStyle.frequencyDictionary.sourcePath',
|
||||||
(frequencyDictionary as { sourcePath?: unknown }).sourcePath,
|
(frequencyDictionary as { sourcePath?: unknown }).sourcePath,
|
||||||
@@ -246,7 +210,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
if (topX !== undefined && Number.isInteger(topX) && topX > 0) {
|
if (topX !== undefined && Number.isInteger(topX) && topX > 0) {
|
||||||
resolved.subtitleStyle.frequencyDictionary.topX = Math.floor(topX);
|
resolved.subtitleStyle.frequencyDictionary.topX = Math.floor(topX);
|
||||||
} else if ((frequencyDictionary as { topX?: unknown }).topX !== undefined) {
|
} else if ((frequencyDictionary as { topX?: unknown }).topX !== undefined) {
|
||||||
resolved.subtitleStyle.frequencyDictionary.topX = fallbackFrequencyDictionary.topX;
|
|
||||||
warn(
|
warn(
|
||||||
'subtitleStyle.frequencyDictionary.topX',
|
'subtitleStyle.frequencyDictionary.topX',
|
||||||
(frequencyDictionary as { topX?: unknown }).topX,
|
(frequencyDictionary as { topX?: unknown }).topX,
|
||||||
@@ -259,7 +222,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
if (frequencyMode === 'single' || frequencyMode === 'banded') {
|
if (frequencyMode === 'single' || frequencyMode === 'banded') {
|
||||||
resolved.subtitleStyle.frequencyDictionary.mode = frequencyMode;
|
resolved.subtitleStyle.frequencyDictionary.mode = frequencyMode;
|
||||||
} else if (frequencyMode !== undefined) {
|
} else if (frequencyMode !== undefined) {
|
||||||
resolved.subtitleStyle.frequencyDictionary.mode = fallbackFrequencyDictionary.mode;
|
|
||||||
warn(
|
warn(
|
||||||
'subtitleStyle.frequencyDictionary.mode',
|
'subtitleStyle.frequencyDictionary.mode',
|
||||||
frequencyDictionary.mode,
|
frequencyDictionary.mode,
|
||||||
@@ -268,25 +230,10 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const frequencyMatchMode = (frequencyDictionary as { matchMode?: unknown }).matchMode;
|
|
||||||
if (frequencyMatchMode === 'headword' || frequencyMatchMode === 'surface') {
|
|
||||||
resolved.subtitleStyle.frequencyDictionary.matchMode = frequencyMatchMode;
|
|
||||||
} else if (frequencyMatchMode !== undefined) {
|
|
||||||
resolved.subtitleStyle.frequencyDictionary.matchMode = fallbackFrequencyDictionary.matchMode;
|
|
||||||
warn(
|
|
||||||
'subtitleStyle.frequencyDictionary.matchMode',
|
|
||||||
frequencyMatchMode,
|
|
||||||
resolved.subtitleStyle.frequencyDictionary.matchMode,
|
|
||||||
"Expected 'headword' or 'surface'.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const singleColor = asColor((frequencyDictionary as { singleColor?: unknown }).singleColor);
|
const singleColor = asColor((frequencyDictionary as { singleColor?: unknown }).singleColor);
|
||||||
if (singleColor !== undefined) {
|
if (singleColor !== undefined) {
|
||||||
resolved.subtitleStyle.frequencyDictionary.singleColor = singleColor;
|
resolved.subtitleStyle.frequencyDictionary.singleColor = singleColor;
|
||||||
} else if ((frequencyDictionary as { singleColor?: unknown }).singleColor !== undefined) {
|
} else if ((frequencyDictionary as { singleColor?: unknown }).singleColor !== undefined) {
|
||||||
resolved.subtitleStyle.frequencyDictionary.singleColor =
|
|
||||||
fallbackFrequencyDictionary.singleColor;
|
|
||||||
warn(
|
warn(
|
||||||
'subtitleStyle.frequencyDictionary.singleColor',
|
'subtitleStyle.frequencyDictionary.singleColor',
|
||||||
(frequencyDictionary as { singleColor?: unknown }).singleColor,
|
(frequencyDictionary as { singleColor?: unknown }).singleColor,
|
||||||
@@ -301,8 +248,6 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
if (bandedColors !== undefined) {
|
if (bandedColors !== undefined) {
|
||||||
resolved.subtitleStyle.frequencyDictionary.bandedColors = bandedColors;
|
resolved.subtitleStyle.frequencyDictionary.bandedColors = bandedColors;
|
||||||
} else if ((frequencyDictionary as { bandedColors?: unknown }).bandedColors !== undefined) {
|
} else if ((frequencyDictionary as { bandedColors?: unknown }).bandedColors !== undefined) {
|
||||||
resolved.subtitleStyle.frequencyDictionary.bandedColors =
|
|
||||||
fallbackFrequencyDictionary.bandedColors;
|
|
||||||
warn(
|
warn(
|
||||||
'subtitleStyle.frequencyDictionary.bandedColors',
|
'subtitleStyle.frequencyDictionary.bandedColors',
|
||||||
(frequencyDictionary as { bandedColors?: unknown }).bandedColors,
|
(frequencyDictionary as { bandedColors?: unknown }).bandedColors,
|
||||||
|
|||||||
@@ -27,51 +27,3 @@ test('subtitleStyle preserveLineBreaks falls back while merge is preserved', ()
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
|
|
||||||
const { context, warnings } = createResolveContext({
|
|
||||||
subtitleStyle: {
|
|
||||||
autoPauseVideoOnHover: 'invalid' as unknown as boolean,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
applySubtitleDomainConfig(context);
|
|
||||||
|
|
||||||
assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnHover, true);
|
|
||||||
assert.ok(
|
|
||||||
warnings.some(
|
|
||||||
(warning) =>
|
|
||||||
warning.path === 'subtitleStyle.autoPauseVideoOnHover' &&
|
|
||||||
warning.message === 'Expected boolean.',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
|
|
||||||
const valid = createResolveContext({
|
|
||||||
subtitleStyle: {
|
|
||||||
frequencyDictionary: {
|
|
||||||
matchMode: 'surface',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
applySubtitleDomainConfig(valid.context);
|
|
||||||
assert.equal(valid.context.resolved.subtitleStyle.frequencyDictionary.matchMode, 'surface');
|
|
||||||
|
|
||||||
const invalid = createResolveContext({
|
|
||||||
subtitleStyle: {
|
|
||||||
frequencyDictionary: {
|
|
||||||
matchMode: 'reading' as unknown as 'headword' | 'surface',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
applySubtitleDomainConfig(invalid.context);
|
|
||||||
assert.equal(invalid.context.resolved.subtitleStyle.frequencyDictionary.matchMode, 'headword');
|
|
||||||
assert.ok(
|
|
||||||
invalid.warnings.some(
|
|
||||||
(warning) =>
|
|
||||||
warning.path === 'subtitleStyle.frequencyDictionary.matchMode' &&
|
|
||||||
warning.message === "Expected 'headword' or 'surface'.",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ function humanizeKey(key: string): string {
|
|||||||
|
|
||||||
function buildInlineOptionComment(path: string, value: unknown): string {
|
function buildInlineOptionComment(path: string, value: unknown): string {
|
||||||
const registryEntry = OPTION_REGISTRY_BY_PATH.get(path);
|
const registryEntry = OPTION_REGISTRY_BY_PATH.get(path);
|
||||||
const baseDescription =
|
const baseDescription = registryEntry?.description ?? TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY.get(path);
|
||||||
registryEntry?.description ?? TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY.get(path);
|
|
||||||
const description =
|
const description =
|
||||||
baseDescription && baseDescription.trim().length > 0
|
baseDescription && baseDescription.trim().length > 0
|
||||||
? normalizeCommentText(baseDescription)
|
? normalizeCommentText(baseDescription)
|
||||||
|
|||||||
@@ -132,15 +132,16 @@ export function createAnilistTokenStore(
|
|||||||
}
|
}
|
||||||
return decrypted;
|
return decrypted;
|
||||||
}
|
}
|
||||||
if (typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0) {
|
if (
|
||||||
|
typeof parsed.plaintextToken === 'string' &&
|
||||||
|
parsed.plaintextToken.trim().length > 0
|
||||||
|
) {
|
||||||
if (storage.isEncryptionAvailable()) {
|
if (storage.isEncryptionAvailable()) {
|
||||||
if (!isSafeStorageUsable()) {
|
if (!isSafeStorageUsable()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const plaintext = parsed.plaintextToken.trim();
|
const plaintext = parsed.plaintextToken.trim();
|
||||||
notifyUser(
|
notifyUser('AniList token plaintext fallback payload found. Migrating to encrypted storage.');
|
||||||
'AniList token plaintext fallback payload found. Migrating to encrypted storage.',
|
|
||||||
);
|
|
||||||
this.saveToken(plaintext);
|
this.saveToken(plaintext);
|
||||||
return plaintext;
|
return plaintext;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
parseKikuFieldGroupingChoice,
|
parseKikuFieldGroupingChoice,
|
||||||
parseKikuMergePreviewRequest,
|
parseKikuMergePreviewRequest,
|
||||||
} from '../../shared/ipc/validators';
|
} from '../../shared/ipc/validators';
|
||||||
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path';
|
|
||||||
|
|
||||||
const logger = createLogger('main:anki-jimaku-ipc');
|
const logger = createLogger('main:anki-jimaku-ipc');
|
||||||
|
|
||||||
@@ -149,11 +148,10 @@ export function registerAnkiJimakuIpcHandlers(
|
|||||||
if (!safeName) {
|
if (!safeName) {
|
||||||
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
|
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
|
||||||
}
|
}
|
||||||
const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath(currentMediaPath, safeName);
|
|
||||||
|
|
||||||
const ext = path.extname(subtitleFilename);
|
const ext = path.extname(safeName);
|
||||||
const baseName = ext ? subtitleFilename.slice(0, -ext.length) : subtitleFilename;
|
const baseName = ext ? safeName.slice(0, -ext.length) : safeName;
|
||||||
let targetPath = path.join(mediaDir, subtitleFilename);
|
let targetPath = path.join(mediaDir, safeName);
|
||||||
if (fs.existsSync(targetPath)) {
|
if (fs.existsSync(targetPath)) {
|
||||||
targetPath = path.join(mediaDir, `${baseName} (jimaku-${parsedQuery.entryId})${ext}`);
|
targetPath = path.join(mediaDir, `${baseName} (jimaku-${parsedQuery.entryId})${ext}`);
|
||||||
let counter = 2;
|
let counter = 2;
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import { CliArgs } from '../../cli/args';
|
|
||||||
import { AppLifecycleServiceDeps, startAppLifecycle } from './app-lifecycle';
|
|
||||||
|
|
||||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|
||||||
return {
|
|
||||||
background: false,
|
|
||||||
start: false,
|
|
||||||
stop: false,
|
|
||||||
toggle: false,
|
|
||||||
toggleVisibleOverlay: false,
|
|
||||||
settings: false,
|
|
||||||
show: false,
|
|
||||||
hide: false,
|
|
||||||
showVisibleOverlay: false,
|
|
||||||
hideVisibleOverlay: false,
|
|
||||||
copySubtitle: false,
|
|
||||||
copySubtitleMultiple: false,
|
|
||||||
mineSentence: false,
|
|
||||||
mineSentenceMultiple: false,
|
|
||||||
updateLastCardFromClipboard: false,
|
|
||||||
refreshKnownWords: false,
|
|
||||||
toggleSecondarySub: false,
|
|
||||||
triggerFieldGrouping: false,
|
|
||||||
triggerSubsync: false,
|
|
||||||
markAudioCard: false,
|
|
||||||
openRuntimeOptions: false,
|
|
||||||
anilistStatus: false,
|
|
||||||
anilistLogout: false,
|
|
||||||
anilistSetup: false,
|
|
||||||
anilistRetryQueue: false,
|
|
||||||
jellyfin: false,
|
|
||||||
jellyfinLogin: false,
|
|
||||||
jellyfinLogout: false,
|
|
||||||
jellyfinLibraries: false,
|
|
||||||
jellyfinItems: false,
|
|
||||||
jellyfinSubtitles: false,
|
|
||||||
jellyfinSubtitleUrlsOnly: false,
|
|
||||||
jellyfinPlay: false,
|
|
||||||
jellyfinRemoteAnnounce: false,
|
|
||||||
jellyfinPreviewAuth: false,
|
|
||||||
texthooker: false,
|
|
||||||
help: false,
|
|
||||||
autoStartOverlay: false,
|
|
||||||
generateConfig: false,
|
|
||||||
backupOverwrite: false,
|
|
||||||
debug: false,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDeps(overrides: Partial<AppLifecycleServiceDeps> = {}) {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let lockCalls = 0;
|
|
||||||
|
|
||||||
const deps: AppLifecycleServiceDeps = {
|
|
||||||
shouldStartApp: () => false,
|
|
||||||
parseArgs: () => makeArgs(),
|
|
||||||
requestSingleInstanceLock: () => {
|
|
||||||
lockCalls += 1;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
quitApp: () => {
|
|
||||||
calls.push('quitApp');
|
|
||||||
},
|
|
||||||
onSecondInstance: () => {},
|
|
||||||
handleCliCommand: () => {},
|
|
||||||
printHelp: () => {
|
|
||||||
calls.push('printHelp');
|
|
||||||
},
|
|
||||||
logNoRunningInstance: () => {
|
|
||||||
calls.push('logNoRunningInstance');
|
|
||||||
},
|
|
||||||
whenReady: () => {},
|
|
||||||
onWindowAllClosed: () => {},
|
|
||||||
onWillQuit: () => {},
|
|
||||||
onActivate: () => {},
|
|
||||||
isDarwinPlatform: () => false,
|
|
||||||
onReady: async () => {},
|
|
||||||
onWillQuitCleanup: () => {},
|
|
||||||
shouldRestoreWindowsOnActivate: () => false,
|
|
||||||
restoreWindowsOnActivate: () => {},
|
|
||||||
shouldQuitOnWindowAllClosed: () => true,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
|
|
||||||
return { deps, calls, getLockCalls: () => lockCalls };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('startAppLifecycle handles --help without acquiring single-instance lock', () => {
|
|
||||||
const { deps, calls, getLockCalls } = createDeps({
|
|
||||||
shouldStartApp: () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
startAppLifecycle(makeArgs({ help: true }), deps);
|
|
||||||
|
|
||||||
assert.equal(getLockCalls(), 0);
|
|
||||||
assert.deepEqual(calls, ['printHelp', 'quitApp']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('startAppLifecycle still acquires lock for startup commands', () => {
|
|
||||||
const { deps, getLockCalls } = createDeps({
|
|
||||||
shouldStartApp: () => true,
|
|
||||||
whenReady: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
startAppLifecycle(makeArgs({ start: true }), deps);
|
|
||||||
|
|
||||||
assert.equal(getLockCalls(), 1);
|
|
||||||
});
|
|
||||||
@@ -87,12 +87,6 @@ export function createAppLifecycleDepsRuntime(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServiceDeps): void {
|
export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServiceDeps): void {
|
||||||
if (initialArgs.help && !deps.shouldStartApp(initialArgs)) {
|
|
||||||
deps.printHelp();
|
|
||||||
deps.quitApp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const gotTheLock = deps.requestSingleInstanceLock();
|
const gotTheLock = deps.requestSingleInstanceLock();
|
||||||
if (!gotTheLock) {
|
if (!gotTheLock) {
|
||||||
deps.quitApp();
|
deps.quitApp();
|
||||||
@@ -107,6 +101,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (initialArgs.help && !deps.shouldStartApp(initialArgs)) {
|
||||||
|
deps.printHelp();
|
||||||
|
deps.quitApp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!deps.shouldStartApp(initialArgs)) {
|
if (!deps.shouldStartApp(initialArgs)) {
|
||||||
if (initialArgs.stop && !initialArgs.start) {
|
if (initialArgs.stop && !initialArgs.start) {
|
||||||
deps.quitApp();
|
deps.quitApp();
|
||||||
|
|||||||
@@ -66,54 +66,6 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns true', async () => {
|
|
||||||
const { deps, calls } = makeDeps({
|
|
||||||
shouldSkipHeavyStartup: () => true,
|
|
||||||
reloadConfig: () => calls.push('reloadConfig'),
|
|
||||||
getResolvedConfig: () => {
|
|
||||||
calls.push('getResolvedConfig');
|
|
||||||
return {
|
|
||||||
websocket: { enabled: 'auto' },
|
|
||||||
secondarySub: {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
getConfigWarnings: () => {
|
|
||||||
calls.push('getConfigWarnings');
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
setLogLevel: (level, source) => calls.push(`setLogLevel:${level}:${source}`),
|
|
||||||
initRuntimeOptionsManager: () => calls.push('initRuntimeOptionsManager'),
|
|
||||||
startBackgroundWarmups: () => calls.push('startBackgroundWarmups'),
|
|
||||||
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
|
||||||
resolveKeybindings: () => calls.push('resolveKeybindings'),
|
|
||||||
createMpvClient: () => calls.push('createMpvClient'),
|
|
||||||
logConfigWarning: () => calls.push('logConfigWarning'),
|
|
||||||
startJellyfinRemoteSession: async () => {
|
|
||||||
calls.push('startJellyfinRemoteSession');
|
|
||||||
},
|
|
||||||
createImmersionTracker: () => calls.push('createImmersionTracker'),
|
|
||||||
handleInitialArgs: () => calls.push('handleInitialArgs'),
|
|
||||||
});
|
|
||||||
|
|
||||||
await runAppReadyRuntime(deps);
|
|
||||||
|
|
||||||
assert.equal(calls.includes('reloadConfig'), false);
|
|
||||||
assert.equal(calls.includes('getResolvedConfig'), false);
|
|
||||||
assert.equal(calls.includes('getConfigWarnings'), false);
|
|
||||||
assert.equal(calls.includes('setLogLevel:warn:config'), false);
|
|
||||||
assert.equal(calls.includes('startBackgroundWarmups'), false);
|
|
||||||
assert.equal(calls.includes('loadSubtitlePosition'), false);
|
|
||||||
assert.equal(calls.includes('resolveKeybindings'), false);
|
|
||||||
assert.equal(calls.includes('createMpvClient'), false);
|
|
||||||
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
|
|
||||||
assert.equal(calls.includes('createImmersionTracker'), false);
|
|
||||||
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
|
|
||||||
assert.equal(calls.includes('logConfigWarning'), false);
|
|
||||||
assert.equal(calls.includes('handleInitialArgs'), true);
|
|
||||||
assert.equal(calls.includes('loadYomitanExtension'), true);
|
|
||||||
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
|
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
|
||||||
const { deps, calls } = makeDeps({
|
const { deps, calls } = makeDeps({
|
||||||
startJellyfinRemoteSession: undefined,
|
startJellyfinRemoteSession: undefined,
|
||||||
@@ -179,30 +131,12 @@ test('runAppReadyRuntime does not await background warmups', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await runAppReadyRuntime(deps);
|
await runAppReadyRuntime(deps);
|
||||||
assert.ok(calls.includes('startBackgroundWarmups'));
|
assert.deepEqual(calls.slice(0, 2), ['handleInitialArgs', 'startBackgroundWarmups']);
|
||||||
assert.ok(calls.includes('handleInitialArgs'));
|
|
||||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
|
|
||||||
assert.equal(calls.includes('warmupDone'), false);
|
assert.equal(calls.includes('warmupDone'), false);
|
||||||
assert.ok(releaseWarmup);
|
assert.ok(releaseWarmup);
|
||||||
releaseWarmup();
|
releaseWarmup();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runAppReadyRuntime starts background warmups before core runtime services', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const { deps } = makeDeps({
|
|
||||||
startBackgroundWarmups: () => {
|
|
||||||
calls.push('startBackgroundWarmups');
|
|
||||||
},
|
|
||||||
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
|
||||||
createMpvClient: () => calls.push('createMpvClient'),
|
|
||||||
});
|
|
||||||
|
|
||||||
await runAppReadyRuntime(deps);
|
|
||||||
|
|
||||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('loadSubtitlePosition'));
|
|
||||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('createMpvClient'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => {
|
test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => {
|
||||||
const capturedErrors: string[][] = [];
|
const capturedErrors: string[][] = [];
|
||||||
const { deps, calls } = makeDeps({
|
const { deps, calls } = makeDeps({
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
jellyfinSubtitleUrlsOnly: false,
|
jellyfinSubtitleUrlsOnly: false,
|
||||||
jellyfinPlay: false,
|
jellyfinPlay: false,
|
||||||
jellyfinRemoteAnnounce: false,
|
jellyfinRemoteAnnounce: false,
|
||||||
jellyfinPreviewAuth: false,
|
|
||||||
texthooker: false,
|
texthooker: false,
|
||||||
help: false,
|
help: false,
|
||||||
autoStartOverlay: false,
|
autoStartOverlay: false,
|
||||||
@@ -221,18 +220,6 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleCliCommand applies cli log level for second-instance commands', () => {
|
|
||||||
const { deps, calls } = createDeps({
|
|
||||||
setLogLevel: (level) => {
|
|
||||||
calls.push(`setLogLevel:${level}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
handleCliCommand(makeArgs({ start: true, logLevel: 'debug' }), 'second-instance', deps);
|
|
||||||
|
|
||||||
assert.ok(calls.includes('setLogLevel:debug'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleCliCommand runs texthooker flow with browser open', () => {
|
test('handleCliCommand runs texthooker flow with browser open', () => {
|
||||||
const { deps, calls } = createDeps();
|
const { deps, calls } = createDeps();
|
||||||
const args = makeArgs({ texthooker: true });
|
const args = makeArgs({ texthooker: true });
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
|
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
|
||||||
|
|
||||||
export interface CliCommandServiceDeps {
|
export interface CliCommandServiceDeps {
|
||||||
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
|
|
||||||
getMpvSocketPath: () => string;
|
getMpvSocketPath: () => string;
|
||||||
setMpvSocketPath: (socketPath: string) => void;
|
setMpvSocketPath: (socketPath: string) => void;
|
||||||
setMpvClientSocketPath: (socketPath: string) => void;
|
setMpvClientSocketPath: (socketPath: string) => void;
|
||||||
@@ -128,7 +127,6 @@ interface AppCliRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CliCommandDepsRuntimeOptions {
|
export interface CliCommandDepsRuntimeOptions {
|
||||||
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
|
|
||||||
mpv: MpvCliRuntime;
|
mpv: MpvCliRuntime;
|
||||||
texthooker: TexthookerCliRuntime;
|
texthooker: TexthookerCliRuntime;
|
||||||
overlay: OverlayCliRuntime;
|
overlay: OverlayCliRuntime;
|
||||||
@@ -151,7 +149,6 @@ export function createCliCommandDepsRuntime(
|
|||||||
options: CliCommandDepsRuntimeOptions,
|
options: CliCommandDepsRuntimeOptions,
|
||||||
): CliCommandServiceDeps {
|
): CliCommandServiceDeps {
|
||||||
return {
|
return {
|
||||||
setLogLevel: options.setLogLevel,
|
|
||||||
getMpvSocketPath: options.mpv.getSocketPath,
|
getMpvSocketPath: options.mpv.getSocketPath,
|
||||||
setMpvSocketPath: options.mpv.setSocketPath,
|
setMpvSocketPath: options.mpv.setSocketPath,
|
||||||
setMpvClientSocketPath: (socketPath) => {
|
setMpvClientSocketPath: (socketPath) => {
|
||||||
@@ -235,10 +232,6 @@ export function handleCliCommand(
|
|||||||
source: CliCommandSource = 'initial',
|
source: CliCommandSource = 'initial',
|
||||||
deps: CliCommandServiceDeps,
|
deps: CliCommandServiceDeps,
|
||||||
): void {
|
): void {
|
||||||
if (args.logLevel) {
|
|
||||||
deps.setLogLevel?.(args.logLevel);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasNonStartAction =
|
const hasNonStartAction =
|
||||||
args.stop ||
|
args.stop ||
|
||||||
args.toggle ||
|
args.toggle ||
|
||||||
@@ -283,7 +276,8 @@ export function handleCliCommand(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldStart = args.start || args.toggle || args.toggleVisibleOverlay;
|
const shouldStart =
|
||||||
|
args.start || args.toggle || args.toggleVisibleOverlay;
|
||||||
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
|
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
|
||||||
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
|
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
|
||||||
|
|
||||||
|
|||||||