Compare commits
43 Commits
v0.1.1
...
refactor-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
ceb064b804
|
|||
|
ba68a43689
|
|||
|
d8f5a6df8d
|
|||
|
a9f7ea0204
|
|||
|
d24283e82d
|
|||
|
3229485fa6
|
|||
|
f0c9c8b668
|
|||
|
9cd401cc48
|
|||
|
4c540b2a51
|
|||
|
80edc67bcc
|
|||
|
76c9b37cda
|
|||
|
d588a2154d
|
|||
|
6da2caabf7
|
|||
|
8aa2a45c7c
|
|||
|
1c70e486fe
|
|||
|
c5d4a67f39
|
|||
|
192672c051
|
|||
|
1d67b12028
|
|||
|
d5f938c4b6
|
|||
|
895401de51
|
|||
|
cc2f9ef325
|
|||
|
9e4e588f33
|
|||
|
dde51f8634
|
|||
|
77c698e00b
|
|||
|
edca554db1
|
|||
|
edcd5cddb6
|
|||
|
a2551016cd
|
|||
|
3e9db1f125
|
|||
|
bc6f581ea5
|
|||
|
d4805395fa
|
|||
|
10a92f100a
|
|||
|
a03388a38f
|
|||
|
75442a4648
|
|||
|
74554a30f0
|
|||
|
643f8eb958
|
|||
|
a14c9da139
|
|||
|
ad97948062
|
|||
|
efaf9a78cd
|
|||
|
058d359553
|
|||
|
6eda768261
|
|||
|
ceea10cba1
|
|||
|
9d73971f3b
|
|||
|
a2735eaedc
|
2
.gitignore
vendored
@@ -7,7 +7,7 @@ dist/
|
|||||||
release/
|
release/
|
||||||
|
|
||||||
# Launcher build artifact (produced by make build-launcher)
|
# Launcher build artifact (produced by make build-launcher)
|
||||||
subminer
|
/subminer
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
17
Makefile
@@ -1,10 +1,9 @@
|
|||||||
.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-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
|
||||||
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
|
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
|
||||||
THEME_FILE := subminer.rasi
|
THEME_FILE := subminer.rasi
|
||||||
PLUGIN_LUA := plugin/subminer.lua
|
|
||||||
PLUGIN_CONF := plugin/subminer.conf
|
PLUGIN_CONF := plugin/subminer.conf
|
||||||
|
|
||||||
# Default install prefix for the wrapper script.
|
# Default install prefix for the wrapper script.
|
||||||
@@ -53,6 +52,8 @@ help:
|
|||||||
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \
|
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \
|
||||||
" dev-start Build and launch local Electron app" \
|
" dev-start Build and launch local Electron app" \
|
||||||
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \
|
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \
|
||||||
|
" dev-watch Start fast watch loop (tsc + renderer + Electron dev app)" \
|
||||||
|
" dev-watch-macos Start watch loop with forced macOS tracker backend" \
|
||||||
" 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" \
|
||||||
@@ -173,6 +174,12 @@ dev-start-macos: ensure-bun
|
|||||||
@bun run build
|
@bun run build
|
||||||
@bun run electron . --start --backend macos
|
@bun run electron . --start --backend macos
|
||||||
|
|
||||||
|
dev-watch: ensure-bun
|
||||||
|
@bash scripts/dev-watch.sh
|
||||||
|
|
||||||
|
dev-watch-macos: ensure-bun
|
||||||
|
@bash scripts/dev-watch.sh --start --dev --backend macos
|
||||||
|
|
||||||
dev-toggle: ensure-bun
|
dev-toggle: ensure-bun
|
||||||
@bun run electron . --toggle
|
@bun run electron . --toggle
|
||||||
|
|
||||||
@@ -218,10 +225,12 @@ install-macos: build-launcher
|
|||||||
install-plugin:
|
install-plugin:
|
||||||
@printf '%s\n' "[INFO] Installing mpv plugin artifacts"
|
@printf '%s\n' "[INFO] Installing mpv plugin artifacts"
|
||||||
@install -d "$(MPV_SCRIPTS_DIR)"
|
@install -d "$(MPV_SCRIPTS_DIR)"
|
||||||
|
@rm -f "$(MPV_SCRIPTS_DIR)/subminer.lua"
|
||||||
|
@install -d "$(MPV_SCRIPTS_DIR)/subminer"
|
||||||
@install -d "$(MPV_SCRIPT_OPTS_DIR)"
|
@install -d "$(MPV_SCRIPT_OPTS_DIR)"
|
||||||
@install -m 0644 "./$(PLUGIN_LUA)" "$(MPV_SCRIPTS_DIR)/subminer.lua"
|
@cp -R ./plugin/subminer/. "$(MPV_SCRIPTS_DIR)/subminer/"
|
||||||
@install -m 0644 "./$(PLUGIN_CONF)" "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
@install -m 0644 "./$(PLUGIN_CONF)" "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
||||||
@printf '%s\n' "Installed to:" " $(MPV_SCRIPTS_DIR)/subminer.lua" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
@printf '%s\n' "Installed to:" " $(MPV_SCRIPTS_DIR)/subminer/main.lua" " $(MPV_SCRIPTS_DIR)/subminer/" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
||||||
|
|
||||||
# Uninstall behavior kept unchanged by default.
|
# Uninstall behavior kept unchanged by default.
|
||||||
uninstall: uninstall-linux
|
uninstall: uninstall-linux
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ SubMiner is an Electron overlay that sits on top of mpv. It turns your video pla
|
|||||||
|
|
||||||
- **Hover to look up** — Yomitan dictionary popups directly on subtitles
|
- **Hover to look up** — Yomitan dictionary popups directly on subtitles
|
||||||
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
|
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
|
||||||
- **N+1 highlighting** — Marks known words from your Anki deck so unknown ones jump out
|
- **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
|
||||||
- **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
|
||||||
@@ -57,7 +58,9 @@ chmod +x ~/.local/bin/subminer
|
|||||||
```bash
|
```bash
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
cp /tmp/plugin/subminer.lua ~/.config/mpv/scripts/
|
mkdir -p ~/.config/mpv/scripts/subminer
|
||||||
|
mkdir -p ~/.config/mpv/script-opts
|
||||||
|
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
|
||||||
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
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
|
||||||
```
|
```
|
||||||
@@ -73,7 +76,7 @@ subminer app --start --yomitan
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer app --start --background
|
subminer app --start --background
|
||||||
subminer video.mkv # toggle invisible overlay with y-i and visible overlay with y-t
|
subminer video.mkv # y-t toggles overlay visibility
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 141 KiB |
BIN
assets/kiku-integration.gif
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
assets/kiku-integration.mkv
Normal file
BIN
assets/kiku-integration.mp4
Normal file
BIN
assets/minecard-poster.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 13 MiB After Width: | Height: | Size: 12 MiB |
BIN
assets/minecard.jpg
Normal file
|
After Width: | Height: | Size: 303 KiB |
@@ -2,9 +2,11 @@ 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: []
|
||||||
date_format: yyyy-mm-dd
|
date_format: yyyy-mm-dd
|
||||||
max_column_width: 20
|
max_column_width: 20
|
||||||
auto_open_browser: true
|
default_editor: "nvim"
|
||||||
|
auto_open_browser: false
|
||||||
default_port: 6420
|
default_port: 6420
|
||||||
remote_operations: true
|
remote_operations: true
|
||||||
auto_commit: false
|
auto_commit: false
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
id: TASK-70
|
||||||
|
title: >-
|
||||||
|
Overlay runtime refactor: remove invisible mode and bind visible overlay to
|
||||||
|
mpv subtitles
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-02-28 02:38'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- 'commit:a14c9da'
|
||||||
|
- 'commit:74554a3'
|
||||||
|
- 'commit:75442a4'
|
||||||
|
- 'commit:dde51f8'
|
||||||
|
- 'commit:9e4e588'
|
||||||
|
- src/main/overlay-runtime.ts
|
||||||
|
- src/main/runtime/overlay-mpv-sub-visibility.ts
|
||||||
|
- src/renderer/renderer.ts
|
||||||
|
- docs/plans/2026-02-26-secondary-subtitles-main-overlay.md
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- 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.
|
||||||
|
|
||||||
|
Delivered behavior:
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Risk/impact context:
|
||||||
|
- 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.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- 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.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
id: TASK-71
|
||||||
|
title: >-
|
||||||
|
Anki integration: add local AnkiConnect proxy transport for push-based
|
||||||
|
auto-enrichment
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-02-28 02:38'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- src/anki-integration/anki-connect-proxy.ts
|
||||||
|
- src/anki-integration/anki-connect-proxy.test.ts
|
||||||
|
- src/anki-integration.ts
|
||||||
|
- src/config/resolve/anki-connect.ts
|
||||||
|
- src/core/services/tokenizer/yomitan-parser-runtime.ts
|
||||||
|
- src/core/services/tokenizer/yomitan-parser-runtime.test.ts
|
||||||
|
- docs/anki-integration.md
|
||||||
|
- config.example.jsonc
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Scope: Current unmerged working-tree changes implement an optional local AnkiConnect-compatible proxy and transport switching for card enrichment.
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
- Runtime now supports transport switching (polling vs proxy) and restarts transport when runtime config patches change transport keys.
|
||||||
|
- Added Yomitan default-profile server sync helper to keep bundled parser profile aligned with configured Anki endpoint.
|
||||||
|
- Updated user docs/config examples for proxy mode setup, troubleshooting, and mining workflow behavior.
|
||||||
|
|
||||||
|
Risk/impact context:
|
||||||
|
- 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.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Completed implementation in branch working tree; ready to merge once local changes are committed and test gate passes.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
id: TASK-72
|
||||||
|
title: 'macOS config validation UX: show full warning details in native dialog'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-02-28 02:38'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- 'commit:cc2f9ef'
|
||||||
|
- src/main/config-validation.ts
|
||||||
|
- src/main/runtime/startup-config.ts
|
||||||
|
- docs/configuration.md
|
||||||
|
priority: low
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- 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.
|
||||||
|
|
||||||
|
Delivered behavior:
|
||||||
|
- 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.
|
||||||
|
- Updated configuration docs to clarify platform-specific warning presentation behavior.
|
||||||
|
|
||||||
|
Risk/impact context:
|
||||||
|
- Low runtime risk; primarily user-facing diagnostics clarity improvement.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Completed small follow-up fix to reduce config-debug friction on macOS.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
id: TASK-73
|
||||||
|
title: 'MPV plugin: split into modules and optimize startup/command runtime'
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-02-28 20:50'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- plugin/subminer/main.lua
|
||||||
|
- plugin/subminer/bootstrap.lua
|
||||||
|
- plugin/subminer/process.lua
|
||||||
|
- plugin/subminer/aniskip.lua
|
||||||
|
- plugin/subminer/environment.lua
|
||||||
|
- plugin/subminer/lifecycle.lua
|
||||||
|
- plugin/subminer/messages.lua
|
||||||
|
- plugin/subminer/ui.lua
|
||||||
|
- plugin/subminer/hover.lua
|
||||||
|
- plugin/subminer/options.lua
|
||||||
|
- plugin/subminer/state.lua
|
||||||
|
- plugin/subminer.conf
|
||||||
|
- scripts/test-plugin-start-gate.lua
|
||||||
|
- scripts/test-plugin-process-start-retries.lua
|
||||||
|
- launcher/commands/playback-command.ts
|
||||||
|
- launcher/mpv.ts
|
||||||
|
- launcher/mpv.test.ts
|
||||||
|
- launcher/smoke.e2e.test.ts
|
||||||
|
- Makefile
|
||||||
|
- package.json
|
||||||
|
- docs/mpv-plugin.md
|
||||||
|
- docs/installation.md
|
||||||
|
- docs/architecture.md
|
||||||
|
- README.md
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- 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.
|
||||||
|
|
||||||
|
Delivered behavior:
|
||||||
|
- 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.
|
||||||
|
- 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 lookup pipeline moved to async subprocess calls (no sync `ps`/`curl` on `file-loaded`) with deferred fetch after auto-start and session-level MAL/title/payload caching.
|
||||||
|
- Startup/runtime loading updated with lazy module initialization via bootstrap proxies.
|
||||||
|
- Plugin install flow updated to copy `plugin/subminer/` directory and remove legacy `~/.config/mpv/scripts/subminer.lua` file.
|
||||||
|
- Added plugin gate script wiring to package scripts (`test:plugin:src`) and launcher test flow.
|
||||||
|
- Smoke tests stabilized across sandbox environments where UNIX socket bind can return `EPERM` while preserving normal-path assertions.
|
||||||
|
- Playback command cleanup race fixed when mpv exits before exit-listener registration.
|
||||||
|
|
||||||
|
Risk/impact context:
|
||||||
|
- 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.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
AniSkip gate/async update delivered in plugin runtime:
|
||||||
|
- `plugin/subminer/lifecycle.lua`: deferred AniSkip fetch and overlay-start trigger.
|
||||||
|
- `plugin/subminer/aniskip.lua`: async lookup pipeline + context guard + session caches.
|
||||||
|
- `plugin/subminer/environment.lua`: async app-running detection with short cache.
|
||||||
|
- `plugin/subminer/messages.lua`: explicit script-message trigger wiring.
|
||||||
|
|
||||||
|
Regression coverage updated:
|
||||||
|
- `scripts/test-plugin-start-gate.lua` now verifies:
|
||||||
|
- no sync `ps`/`curl` on non-context file load
|
||||||
|
- no AniSkip network lookup on non-context file load
|
||||||
|
- script-message refresh forces async AniSkip lookup
|
||||||
|
|
||||||
|
Validation run:
|
||||||
|
- `bun run test:plugin:src` pass.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
id: TASK-74
|
||||||
|
title: 'Startup warmups: configurable warmup vs defer with low-power mode'
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-02-27 21:05'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- src/types.ts
|
||||||
|
- src/config/definitions/defaults-core.ts
|
||||||
|
- src/config/definitions/options-core.ts
|
||||||
|
- src/config/definitions/template-sections.ts
|
||||||
|
- src/config/resolve/core-domains.ts
|
||||||
|
- src/main/runtime/startup-warmups.ts
|
||||||
|
- src/main/runtime/startup-warmups-main-deps.ts
|
||||||
|
- src/main/runtime/composers/mpv-runtime-composer.ts
|
||||||
|
- src/main.ts
|
||||||
|
- src/config/config.test.ts
|
||||||
|
- src/main/runtime/startup-warmups.test.ts
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Add startup warmup controls to allow per-integration warmup or deferred first-use loading.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- New config section `startupWarmups` with toggles for `mecab`, `yomitanExtension`, `subtitleDictionaries`, and `jellyfinRemoteSession`.
|
||||||
|
- New `startupWarmups.lowPowerMode` policy: defer everything except Yomitan extension.
|
||||||
|
- Keep default behavior as full warmup.
|
||||||
|
- Ensure deferred integrations lazy-load on first real usage path.
|
||||||
|
- Add test coverage for config parsing/defaults and warmup scheduling behavior.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Implemented:
|
||||||
|
- Added `startupWarmups` to config types/defaults/options/template/resolve.
|
||||||
|
- 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.
|
||||||
|
- Tokenization path guarantees lazy first-use init for deferred dependencies (Yomitan extension, MeCab when missing, subtitle dictionaries).
|
||||||
|
- Added/updated tests across config and runtime warmup modules.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `bun run test:config:src`
|
||||||
|
- `bun run test:core:src`
|
||||||
|
- `tsc --noEmit`
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -12,13 +12,6 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Visible Overlay Subtitle Binding
|
|
||||||
// Control whether visible overlay toggles also toggle MPV subtitle visibility.
|
|
||||||
// When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.
|
|
||||||
// ==========================================
|
|
||||||
"bind_visible_overlay_to_mpv_sub_visibility": true, // Link visible overlay toggles to MPV subtitle visibility (primary and secondary). Values: true | false
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Texthooker Server
|
// Texthooker Server
|
||||||
// Control whether browser opens automatically for texthooker.
|
// Control whether browser opens automatically for texthooker.
|
||||||
@@ -44,7 +37,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"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. Keep this as an object; do not replace with a bare string.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Keyboard Shortcuts
|
// Keyboard Shortcuts
|
||||||
@@ -53,7 +46,6 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
||||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
|
|
||||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
||||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
||||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
||||||
@@ -68,16 +60,6 @@
|
|||||||
"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.
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Invisible Overlay
|
|
||||||
// Startup behavior for the invisible interactive subtitle mining layer.
|
|
||||||
// Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.
|
|
||||||
// This edit-mode shortcut is fixed and is not currently configurable.
|
|
||||||
// ==========================================
|
|
||||||
"invisibleOverlay": {
|
|
||||||
"startupVisibility": "platform-default" // Startup visibility setting.
|
|
||||||
}, // Startup behavior for the invisible interactive subtitle mining layer.
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Keybindings (MPV Commands)
|
// Keybindings (MPV Commands)
|
||||||
// Extra keybindings that are merged with built-in defaults.
|
// Extra keybindings that are merged with built-in defaults.
|
||||||
@@ -125,13 +107,21 @@
|
|||||||
"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
|
||||||
"hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv.
|
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||||
"fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif", // Font family setting.
|
"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.
|
||||||
"fontSize": 35, // Font size setting.
|
"fontSize": 35, // Font size setting.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
"fontColor": "#cad3f5", // Font color setting.
|
||||||
"fontWeight": "normal", // Font weight setting.
|
"fontWeight": "600", // Font weight setting.
|
||||||
|
"lineHeight": 1.35, // Line height setting.
|
||||||
|
"letterSpacing": "-0.01em", // Letter spacing setting.
|
||||||
|
"wordSpacing": 0, // Word spacing setting.
|
||||||
|
"fontKerning": "normal", // Font kerning setting.
|
||||||
|
"textRendering": "geometricPrecision", // Text rendering setting.
|
||||||
|
"textShadow": "0 3px 10px rgba(0,0,0,0.69)", // Text shadow setting.
|
||||||
"fontStyle": "normal", // Font style setting.
|
"fontStyle": "normal", // Font style setting.
|
||||||
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
|
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
|
||||||
|
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
||||||
"knownWordColor": "#a6da95", // Known word color setting.
|
"knownWordColor": "#a6da95", // Known word color setting.
|
||||||
"jlptColors": {
|
"jlptColors": {
|
||||||
@@ -143,7 +133,7 @@
|
|||||||
}, // 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, built-in discovery search paths are used.
|
"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
|
||||||
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
||||||
@@ -156,12 +146,19 @@
|
|||||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
}, // Frequency dictionary setting.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
|
"fontFamily": "Manrope, Inter", // Font family setting.
|
||||||
"fontSize": 24, // Font size setting.
|
"fontSize": 24, // Font size setting.
|
||||||
"fontColor": "#ffffff", // Font color setting.
|
"fontColor": "#cad3f5", // Font color setting.
|
||||||
|
"lineHeight": 1.35, // Line height setting.
|
||||||
|
"letterSpacing": "-0.01em", // Letter spacing setting.
|
||||||
|
"wordSpacing": 0, // Word spacing setting.
|
||||||
|
"fontKerning": "normal", // Font kerning setting.
|
||||||
|
"textRendering": "geometricPrecision", // Text rendering setting.
|
||||||
|
"textShadow": "0 3px 10px rgba(0,0,0,0.69)", // Text shadow setting.
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
"backgroundColor": "transparent", // Background color 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.
|
||||||
"fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif" // Font family setting.
|
|
||||||
} // Secondary setting.
|
} // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
@@ -175,6 +172,12 @@
|
|||||||
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
||||||
"url": "http://127.0.0.1:8765", // Url setting.
|
"url": "http://127.0.0.1:8765", // Url setting.
|
||||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||||
|
"proxy": {
|
||||||
|
"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.
|
||||||
|
"port": 8766, // Bind port for local AnkiConnect proxy.
|
||||||
|
"upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
|
||||||
|
}, // Proxy setting.
|
||||||
"tags": [
|
"tags": [
|
||||||
"SubMiner"
|
"SubMiner"
|
||||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export default {
|
|||||||
],
|
],
|
||||||
appearance: 'dark',
|
appearance: 'dark',
|
||||||
cleanUrls: true,
|
cleanUrls: true,
|
||||||
|
metaChunk: true,
|
||||||
|
sitemap: { hostname: 'https://docs.subminer.moe' },
|
||||||
lastUpdated: true,
|
lastUpdated: true,
|
||||||
srcExclude: ['subagents/**'],
|
srcExclude: ['subagents/**'],
|
||||||
markdown: {
|
markdown: {
|
||||||
@@ -94,6 +96,18 @@ export default {
|
|||||||
search: {
|
search: {
|
||||||
provider: 'local',
|
provider: 'local',
|
||||||
},
|
},
|
||||||
|
footer: {
|
||||||
|
message: 'Released under the GPL-3.0 License.',
|
||||||
|
copyright: 'Copyright © 2026-present sudacode',
|
||||||
|
},
|
||||||
|
editLink: {
|
||||||
|
pattern: 'https://github.com/ksyasuda/SubMiner/edit/main/docs/:path',
|
||||||
|
text: 'Edit this page on GitHub',
|
||||||
|
},
|
||||||
|
outline: { level: [2, 3], label: 'On this page' },
|
||||||
|
externalLinkIcon: true,
|
||||||
|
docFooter: { prev: 'Previous', next: 'Next' },
|
||||||
|
returnToTopLabel: 'Back to top',
|
||||||
socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }],
|
socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ make docs-preview # Preview built site at http://localhost:4173
|
|||||||
|
|
||||||
- [Installation](/installation) — Requirements, Linux/macOS/Windows install, mpv plugin setup
|
- [Installation](/installation) — Requirements, Linux/macOS/Windows install, mpv plugin setup
|
||||||
- [Usage](/usage) — `subminer` wrapper + subcommands (`jellyfin`, `yt`, `doctor`, `config`, `mpv`, `texthooker`, `app`), mpv plugin, keybindings
|
- [Usage](/usage) — `subminer` wrapper + subcommands (`jellyfin`, `yt`, `doctor`, `config`, `mpv`, `texthooker`, `app`), mpv plugin, keybindings
|
||||||
- [Mining Workflow](/mining-workflow) — End-to-end sentence mining guide, overlay layers, card creation
|
- [Mining Workflow](/mining-workflow) — End-to-end sentence mining guide, single overlay + modals, card creation
|
||||||
|
|
||||||
### Reference
|
### Reference
|
||||||
|
|
||||||
- [Configuration](/configuration) — Full config file reference and option details
|
- [Configuration](/configuration) — Full config file reference and option details
|
||||||
- [Keyboard Shortcuts](/shortcuts) — All global, overlay, mining, and plugin chord shortcuts in one place
|
- [Keyboard Shortcuts](/shortcuts) — All global, overlay, mining, and plugin chord shortcuts in one place
|
||||||
- [Anki Integration](/anki-integration) — AnkiConnect setup, field mapping, media generation, field grouping
|
- [Anki Integration](/anki-integration) — AnkiConnect setup, proxy/polling transport, field mapping, media generation, field grouping
|
||||||
- [Jellyfin Integration](/jellyfin-integration) — Optional Jellyfin auth, cast discovery, remote control, and playback launch
|
- [Jellyfin Integration](/jellyfin-integration) — Optional Jellyfin auth, cast discovery, remote control, and playback launch
|
||||||
- [Immersion Tracking](/immersion-tracking) — SQLite schema, retention/rollup policies, query templates, and extension points
|
- [Immersion Tracking](/immersion-tracking) — SQLite schema, retention/rollup policies, query templates, and extension points
|
||||||
- [Performance & Tuning](/troubleshooting#performance-and-resource-impact) — Resource usage and practical low-impact profile
|
- [Performance & Tuning](/troubleshooting#performance-and-resource-impact) — Resource usage and practical low-impact profile
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-
|
|||||||
|
|
||||||
AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config.
|
AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config.
|
||||||
|
|
||||||
## How Polling Works
|
## Auto-Enrichment Transport
|
||||||
|
|
||||||
SubMiner polls AnkiConnect at a regular interval (default: 3 seconds, configurable via `ankiConnect.pollingRate`) to detect new cards. When it finds a card that was added since the last poll:
|
SubMiner supports two auto-enrichment transport modes:
|
||||||
|
|
||||||
|
1. `proxy` (default): runs a local AnkiConnect-compatible proxy and enriches cards immediately after successful `addNote` / `addNotes` / `multi` responses.
|
||||||
|
2. `polling`: polls AnkiConnect at `ankiConnect.pollingRate` (default: 3s).
|
||||||
|
|
||||||
|
In both modes, the enrichment workflow is the same:
|
||||||
|
|
||||||
1. Checks if a duplicate expression already exists (for field grouping).
|
1. Checks if a duplicate expression already exists (for field grouping).
|
||||||
2. Updates the sentence field with the current subtitle.
|
2. Updates the sentence field with the current subtitle.
|
||||||
@@ -20,7 +25,83 @@ SubMiner polls AnkiConnect at a regular interval (default: 3 seconds, configurab
|
|||||||
4. Fills the translation field from the secondary subtitle or AI.
|
4. Fills the translation field from the secondary subtitle or AI.
|
||||||
5. Writes metadata to the miscInfo field.
|
5. Writes metadata to the miscInfo field.
|
||||||
|
|
||||||
Polling uses the query `"deck:<your-deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks.
|
Polling mode uses the query `"deck:<your-deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks.
|
||||||
|
|
||||||
|
### Proxy Mode Setup (Yomitan / Texthooker)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"ankiConnect": {
|
||||||
|
"url": "http://127.0.0.1:8765", // real AnkiConnect
|
||||||
|
"proxy": {
|
||||||
|
"enabled": true,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8766,
|
||||||
|
"upstreamUrl": "http://127.0.0.1:8765"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then point Yomitan/clients to `http://127.0.0.1:8766` instead of `8765`.
|
||||||
|
|
||||||
|
When SubMiner loads the bundled Yomitan extension, it also attempts to update the **default Yomitan profile** (`profiles[0].options.anki.server`) to the active SubMiner endpoint:
|
||||||
|
|
||||||
|
- proxy URL when `ankiConnect.proxy.enabled` is `true`
|
||||||
|
- direct `ankiConnect.url` when proxy mode is disabled
|
||||||
|
|
||||||
|
To avoid clobbering custom setups, this auto-update only changes the default profile when its current server is blank or the stock Yomitan default (`http://127.0.0.1:8765`).
|
||||||
|
|
||||||
|
For browser-based Yomitan or other external clients (for example Texthooker in a normal browser profile), set their Anki server to the same proxy URL separately: `http://127.0.0.1:8766` (or your configured `proxy.host` + `proxy.port`).
|
||||||
|
|
||||||
|
### Browser/Yomitan external setup (separate profile)
|
||||||
|
|
||||||
|
If you want SubMiner to use proxy mode without touching your main/default Yomitan profile, create or select a separate Yomitan profile just for SubMiner and set its Anki server to the proxy URL.
|
||||||
|
|
||||||
|
That profile isolation gives you both benefits:
|
||||||
|
|
||||||
|
- SubMiner can auto-enrich immediately via proxy.
|
||||||
|
- Your default Yomitan profile keeps its existing Anki server setting.
|
||||||
|
|
||||||
|
In Yomitan, go to Settings → Profile and:
|
||||||
|
|
||||||
|
1. Create a profile for SubMiner (or choose one dedicated profile).
|
||||||
|
2. Open Anki settings for that profile.
|
||||||
|
3. Set server to `http://127.0.0.1:8766` (or your configured proxy URL).
|
||||||
|
4. Save and make that profile active when using SubMiner.
|
||||||
|
|
||||||
|
This is only for non-bundled, external/browser Yomitan or other clients. The bundled profile auto-update logic only targets `profiles[0]` when it is blank or still default.
|
||||||
|
|
||||||
|
### Proxy Troubleshooting (quick checks)
|
||||||
|
|
||||||
|
If auto-enrichment appears to do nothing:
|
||||||
|
|
||||||
|
1. Confirm proxy listener is running while SubMiner is active:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ss -ltnp | rg 8766
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Confirm requests can pass through the proxy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS http://127.0.0.1:8766 \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"action":"version","version":2}'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check both log sinks:
|
||||||
|
|
||||||
|
- Launcher/mpv-integrated log: `~/.cache/SubMiner/mp.log`
|
||||||
|
- App runtime log: `~/.config/SubMiner/logs/SubMiner-YYYY-MM-DD.log`
|
||||||
|
|
||||||
|
4. Ensure config JSONC is valid and logging shape is correct:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"logging": {
|
||||||
|
"level": "debug"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`"logging": "debug"` is invalid for current schema and can break reload/start behavior.
|
||||||
|
|
||||||
## Field Mapping
|
## Field Mapping
|
||||||
|
|
||||||
@@ -186,7 +267,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
|||||||
|
|
||||||
**Disabled** (`"disabled"`): No duplicate detection. Each card is independent.
|
**Disabled** (`"disabled"`): No duplicate detection. Each card is independent.
|
||||||
|
|
||||||
**Auto** (`"auto"`): When a duplicate expression is found, SubMiner merges the new card into the existing one automatically. Both sentences, audio clips, and images are preserved. If `deleteDuplicateInAuto` is true, the new card is deleted after merging.
|
**Auto** (`"auto"`): When a duplicate expression is found, SubMiner merges the new card into the existing one automatically. Both sentences, audio clips, and images are preserved, and exact duplicate values are collapsed to one entry. If `deleteDuplicateInAuto` is true, the new card is deleted after merging.
|
||||||
|
|
||||||
**Manual** (`"manual"`): A modal appears in the overlay showing both cards. You choose which card to keep, preview the merge result, then confirm. The modal has a 90-second timeout, after which it cancels automatically.
|
**Manual** (`"manual"`): A modal appears in the overlay showing both cards. You choose which card to keep, preview the merge result, then confirm. The modal has a 90-second timeout, after which it cancels automatically.
|
||||||
|
|
||||||
@@ -194,9 +275,9 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
|||||||
|
|
||||||
| Field | Merge behavior |
|
| Field | Merge behavior |
|
||||||
| -------- | -------------------------------------------------------------- |
|
| -------- | -------------------------------------------------------------- |
|
||||||
| Sentence | Both sentences preserved, labeled `[Original]` / `[Duplicate]` |
|
| Sentence | Both sentences preserved (exact duplicate text is deduplicated) |
|
||||||
| Audio | Both `[sound:...]` entries kept |
|
| Audio | Both `[sound:...]` entries kept (exact duplicates deduplicated) |
|
||||||
| Image | Both images kept |
|
| Image | Both images kept (exact duplicates deduplicated) |
|
||||||
|
|
||||||
### Keyboard Shortcuts in the Modal
|
### Keyboard Shortcuts in the Modal
|
||||||
|
|
||||||
@@ -214,6 +295,12 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"url": "http://127.0.0.1:8765",
|
"url": "http://127.0.0.1:8765",
|
||||||
"pollingRate": 3000,
|
"pollingRate": 3000,
|
||||||
|
"proxy": {
|
||||||
|
"enabled": false,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8766,
|
||||||
|
"upstreamUrl": "http://127.0.0.1:8765"
|
||||||
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"audio": "ExpressionAudio",
|
"audio": "ExpressionAudio",
|
||||||
"image": "Picture",
|
"image": "Picture",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ SubMiner is split into three cooperating runtimes:
|
|||||||
|
|
||||||
- Electron desktop app (`src/`) for overlay/UI/runtime orchestration.
|
- Electron desktop app (`src/`) for overlay/UI/runtime orchestration.
|
||||||
- Launcher CLI (`launcher/`) for mpv/app command workflows.
|
- Launcher CLI (`launcher/`) for mpv/app command workflows.
|
||||||
- mpv Lua plugin (`plugin/subminer.lua`) for player-side controls and IPC handoff.
|
- mpv Lua plugin (`plugin/subminer/init.lua` + module files) for player-side controls and IPC handoff.
|
||||||
|
|
||||||
Within the desktop app, `src/main.ts` is a composition root that wires small runtime/domain modules plus core services.
|
Within the desktop app, `src/main.ts` is a composition root that wires small runtime/domain modules plus core services.
|
||||||
|
|
||||||
@@ -26,7 +26,9 @@ launcher/ # Standalone CLI launcher wrapper and mpv helpers
|
|||||||
config/ # Launcher config parsers + CLI parser builder
|
config/ # Launcher config parsers + CLI parser builder
|
||||||
main.ts # Launcher entrypoint and command dispatch
|
main.ts # Launcher entrypoint and command dispatch
|
||||||
plugin/
|
plugin/
|
||||||
subminer.lua # mpv plugin (auto-start, IPC, AniSkip + hover controls)
|
subminer/ # Modular mpv plugin (init · main · bootstrap · lifecycle · process
|
||||||
|
# state · messages · hover · ui · options · environment · log
|
||||||
|
# binary · aniskip · aniskip_match)
|
||||||
src/
|
src/
|
||||||
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
|
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
|
||||||
main.ts # Entry point — delegates to runtime composers/domain modules
|
main.ts # Entry point — delegates to runtime composers/domain modules
|
||||||
@@ -66,24 +68,26 @@ src/
|
|||||||
renderer/ # Overlay renderer (modularized UI/runtime)
|
renderer/ # Overlay renderer (modularized UI/runtime)
|
||||||
handlers/ # Keyboard/mouse interaction modules
|
handlers/ # Keyboard/mouse interaction modules
|
||||||
modals/ # Jimaku/Kiku/subsync/runtime-options/session-help modals
|
modals/ # Jimaku/Kiku/subsync/runtime-options/session-help modals
|
||||||
positioning/ # Invisible-layer layout + offset controllers
|
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||||
window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS)
|
window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS)
|
||||||
jimaku/ # Jimaku API integration helpers
|
jimaku/ # Jimaku API integration helpers
|
||||||
subsync/ # Subtitle sync (alass/ffsubsync) helpers
|
subsync/ # Subtitle sync (alass/ffsubsync) helpers
|
||||||
subtitle/ # Subtitle processing utilities
|
subtitle/ # Subtitle processing utilities
|
||||||
tokenizers/ # Tokenizer implementations
|
tokenizers/ # Tokenizer implementations
|
||||||
|
anki-integration/ # AnkiConnect proxy server + note-update enrichment workflow
|
||||||
token-mergers/ # Token merge strategies
|
token-mergers/ # Token merge strategies
|
||||||
translators/ # AI translation providers
|
translators/ # AI translation providers
|
||||||
```
|
```
|
||||||
|
|
||||||
### Service Layer (`src/core/services/`)
|
### Service Layer (`src/core/services/`)
|
||||||
|
|
||||||
- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-window-geometry.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`, `overlay-drop.ts`
|
- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`
|
||||||
- **Shortcuts/input:** `shortcut.ts`, `overlay-shortcut.ts`, `overlay-shortcut-handler.ts`, `shortcut-fallback.ts`, `numeric-shortcut.ts`
|
- **Shortcuts/input:** `shortcut.ts`, `overlay-shortcut.ts`, `overlay-shortcut-handler.ts`, `shortcut-fallback.ts`, `numeric-shortcut.ts`
|
||||||
- **MPV runtime:** `mpv.ts`, `mpv-transport.ts`, `mpv-protocol.ts`, `mpv-properties.ts`, `mpv-render-metrics.ts`
|
- **MPV runtime:** `mpv.ts`, `mpv-transport.ts`, `mpv-protocol.ts`, `mpv-properties.ts`, `mpv-render-metrics.ts`
|
||||||
- **Mining + Anki/Jimaku runtime:** `mining.ts`, `field-grouping.ts`, `field-grouping-overlay.ts`, `anki-jimaku.ts`, `anki-jimaku-ipc.ts`
|
- **Mining + Anki/Jimaku runtime:** `mining.ts`, `field-grouping.ts`, `field-grouping-overlay.ts`, `anki-jimaku.ts`, `anki-jimaku-ipc.ts`
|
||||||
- **Subtitle/token pipeline:** `subtitle-processing-controller.ts`, `subtitle-position.ts`, `subtitle-ws.ts`, `tokenizer.ts` + `tokenizer/*` stage modules
|
- **Subtitle/token pipeline:** `subtitle-processing-controller.ts`, `subtitle-position.ts`, `subtitle-ws.ts`, `tokenizer.ts` + `tokenizer/*` stage modules (including `parser-enrichment-worker-runtime.ts` for async MeCab enrichment and `yomitan-parser-runtime.ts`)
|
||||||
- **Integrations:** `jimaku.ts`, `subsync.ts`, `subsync-runner.ts`, `texthooker.ts`, `jellyfin.ts`, `jellyfin-remote.ts`, `discord-presence.ts`, `yomitan-extension-loader.ts`, `yomitan-settings.ts`
|
- **Integrations:** `jimaku.ts`, `subsync.ts`, `subsync-runner.ts`, `texthooker.ts`, `jellyfin.ts`, `jellyfin-remote.ts`, `discord-presence.ts`, `yomitan-extension-loader.ts`, `yomitan-settings.ts`
|
||||||
|
- **Anki integration:** `anki-integration.ts`, `anki-integration/anki-connect-proxy.ts` (local proxy for push-based auto-enrichment), `anki-integration/note-update-workflow.ts`
|
||||||
- **Config/runtime controls:** `config-hot-reload.ts`, `runtime-options-ipc.ts`, `cli-command.ts`, `startup.ts`
|
- **Config/runtime controls:** `config-hot-reload.ts`, `runtime-options-ipc.ts`, `cli-command.ts`, `startup.ts`
|
||||||
- **Domain submodules:** `anilist/*` (token/update queue/updater), `immersion-tracker/*` (storage/session/metadata/query/reducer)
|
- **Domain submodules:** `anilist/*` (token/update queue/updater), `immersion-tracker/*` (storage/session/metadata/query/reducer)
|
||||||
|
|
||||||
@@ -95,15 +99,15 @@ The renderer keeps `renderer.ts` focused on orchestration. UI behavior is delega
|
|||||||
src/renderer/
|
src/renderer/
|
||||||
renderer.ts # Entrypoint/orchestration only
|
renderer.ts # Entrypoint/orchestration only
|
||||||
context.ts # Shared runtime context contract
|
context.ts # Shared runtime context contract
|
||||||
state.ts # Centralized renderer mutable state
|
state.ts # Centralized renderer mutable state (visible overlay only)
|
||||||
error-recovery.ts # Global renderer error boundary + recovery actions
|
error-recovery.ts # Global renderer error boundary + recovery actions
|
||||||
overlay-content-measurement.ts # Reports rendered bounds to main process
|
overlay-content-measurement.ts # Reports rendered bounds to main process
|
||||||
subtitle-render.ts # Primary/secondary subtitle rendering + style application
|
subtitle-render.ts # Primary/secondary subtitle rendering + style application
|
||||||
positioning.ts # Facade export for positioning controller
|
positioning.ts # Facade export for positioning controller
|
||||||
|
yomitan-popup.ts # Yomitan popup iframe detection utilities
|
||||||
positioning/
|
positioning/
|
||||||
controller.ts # Position controller orchestration
|
controller.ts # Subtitle drag-position controller
|
||||||
invisible-layout*.ts # Invisible layer layout computations
|
position-state.ts # Position state helpers (yPercent)
|
||||||
position-state.ts # Position state helpers
|
|
||||||
handlers/
|
handlers/
|
||||||
keyboard.ts # Keybindings, chord handling, modal key routing
|
keyboard.ts # Keybindings, chord handling, modal key routing
|
||||||
mouse.ts # Hover/drag behavior, selection + observer wiring
|
mouse.ts # Hover/drag behavior, selection + observer wiring
|
||||||
@@ -121,11 +125,11 @@ src/renderer/
|
|||||||
### Launcher + Plugin Runtimes
|
### Launcher + Plugin Runtimes
|
||||||
|
|
||||||
- `launcher/main.ts` dispatches commands through `launcher/commands/*` and shared config readers in `launcher/config/*`. It handles mpv startup, app passthrough, Jellyfin helper commands, and playback handoff.
|
- `launcher/main.ts` dispatches commands through `launcher/commands/*` and shared config readers in `launcher/config/*`. It handles mpv startup, app passthrough, Jellyfin helper commands, and playback handoff.
|
||||||
- `plugin/subminer.lua` runs inside mpv and handles IPC startup checks, overlay toggles, hover-token messages, and AniSkip intro-skip UX.
|
- `plugin/subminer/init.lua` runs inside mpv and loads modular Lua files: `main.lua` (orchestration), `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution), `aniskip.lua` + `aniskip_match.lua` (intro-skip UX).
|
||||||
|
|
||||||
## Flow Diagram
|
## Flow Diagram
|
||||||
|
|
||||||
The main process has three layers: `main.ts` delegates to composition modules that wire together domain services. Three overlay windows (visible, invisible, secondary) run in separate Electron renderer processes, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough.
|
The main process orchestrates a single primary overlay window plus modal surfaces: `main.ts` delegates to composition modules that wire together domain services. Subtitle layers (primary + secondary bar) are rendered in the same overlay renderer process, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -139,7 +143,7 @@ flowchart LR
|
|||||||
|
|
||||||
subgraph ExtRt["External Runtimes"]
|
subgraph ExtRt["External Runtimes"]
|
||||||
Launcher["launcher/<br/>CLI dispatch"]:::extrt
|
Launcher["launcher/<br/>CLI dispatch"]:::extrt
|
||||||
Plugin["subminer.lua<br/>mpv plugin"]:::extrt
|
Plugin["subminer/init.lua<br/>mpv plugin"]:::extrt
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Ext["External Systems"]
|
subgraph Ext["External Systems"]
|
||||||
@@ -162,8 +166,9 @@ flowchart LR
|
|||||||
|
|
||||||
subgraph Svc["Services — src/core/services/"]
|
subgraph Svc["Services — src/core/services/"]
|
||||||
Mpv["MPV Stack<br/>transport · protocol<br/>properties · metrics"]:::svc
|
Mpv["MPV Stack<br/>transport · protocol<br/>properties · metrics"]:::svc
|
||||||
Overlay["Overlay Manager<br/>window · geometry<br/>visibility · bridge"]:::svc
|
OverlaySvc["Overlay Manager<br/>window · visibility · bridge<br/>mpv-sub-visibility"]:::svc
|
||||||
Mining["Mining & Subtitles<br/>mining · field-grouping<br/>subtitle-ws · tokenizer"]:::svc
|
Mining["Mining & Subtitles<br/>mining · field-grouping<br/>subtitle-ws · tokenizer"]:::svc
|
||||||
|
AnkiProxy["Anki Integration<br/>anki-connect-proxy<br/>note-update-workflow"]:::svc
|
||||||
Integrations["Integrations<br/>jimaku · subsync<br/>texthooker · yomitan"]:::svc
|
Integrations["Integrations<br/>jimaku · subsync<br/>texthooker · yomitan"]:::svc
|
||||||
Tracking["Tracking<br/>anilist · jellyfin<br/>immersion · discord"]:::svc
|
Tracking["Tracking<br/>anilist · jellyfin<br/>immersion · discord"]:::svc
|
||||||
Config["Config & Runtime<br/>hot-reload<br/>runtime-options"]:::svc
|
Config["Config & Runtime<br/>hot-reload<br/>runtime-options"]:::svc
|
||||||
@@ -172,9 +177,7 @@ flowchart LR
|
|||||||
Bridge(["preload.ts<br/>Electron IPC"]):::bridge
|
Bridge(["preload.ts<br/>Electron IPC"]):::bridge
|
||||||
|
|
||||||
subgraph Rend["Renderer — src/renderer/"]
|
subgraph Rend["Renderer — src/renderer/"]
|
||||||
Visible["Visible window<br/>Yomitan lookups"]:::rend
|
OverlayWin["Main overlay window<br/>primary + secondary subtitles"]:::rend
|
||||||
Invisible["Invisible window<br/>mpv positioning"]:::rend
|
|
||||||
Secondary["Secondary window<br/>subtitle bar"]:::rend
|
|
||||||
UI["subtitle-render<br/>positioning<br/>handlers · modals"]:::rend
|
UI["subtitle-render<br/>positioning<br/>handlers · modals"]:::rend
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -185,18 +188,16 @@ flowchart LR
|
|||||||
Comp --> Svc
|
Comp --> Svc
|
||||||
|
|
||||||
mpvExt <-->|"JSON socket"| Mpv
|
mpvExt <-->|"JSON socket"| Mpv
|
||||||
AnkiExt <-->|"HTTP"| Mining
|
AnkiExt <-->|"HTTP"| AnkiProxy
|
||||||
JimakuExt <-->|"HTTP"| Integrations
|
JimakuExt <-->|"HTTP"| Integrations
|
||||||
TrackerExt <-->|"platform"| Overlay
|
TrackerExt <-->|"platform"| OverlaySvc
|
||||||
AnilistExt <-->|"HTTP"| Tracking
|
AnilistExt <-->|"HTTP"| Tracking
|
||||||
JellyfinExt <-->|"HTTP"| Tracking
|
JellyfinExt <-->|"HTTP"| Tracking
|
||||||
DiscordExt <-->|"RPC"| Integrations
|
DiscordExt <-->|"RPC"| Integrations
|
||||||
|
|
||||||
Overlay & Mining --> Bridge
|
OverlaySvc & Mining --> Bridge
|
||||||
Bridge --> Visible
|
Bridge --> OverlayWin
|
||||||
Bridge --> Invisible
|
OverlayWin --> UI
|
||||||
Bridge --> Secondary
|
|
||||||
Visible & Invisible & Secondary --> UI
|
|
||||||
|
|
||||||
style Comp fill:#363a4f,stroke:#494d64,color:#cad3f5
|
style Comp fill:#363a4f,stroke:#494d64,color:#cad3f5
|
||||||
style Svc fill:#363a4f,stroke:#494d64,color:#cad3f5
|
style Svc fill:#363a4f,stroke:#494d64,color:#cad3f5
|
||||||
@@ -264,10 +265,10 @@ For domains migrated to reducer-style transitions (for example AniList token/que
|
|||||||
- **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
|
- **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
|
||||||
- **Startup:** If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks.
|
- **Startup:** If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks.
|
||||||
- **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to 26 properties), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`.
|
- **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to 26 properties), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`.
|
||||||
- **Overlay runtime:** `initializeOverlayRuntime()` creates three overlay windows — **visible** (interactive Yomitan lookups), **invisible** (mpv-matched subtitle positioning), and **secondary** (secondary subtitle bar, top 20% via `splitOverlayGeometryForSecondaryBar`) — then registers global shortcuts and sets initial bounds from the window tracker.
|
- **Overlay runtime:** `initializeOverlayRuntime()` creates the primary overlay window (interactive Yomitan lookups and subtitle rendering), registers global shortcuts, and sets up bounds tracking via the active window tracker. mpv subtitle suppression is handled by a dedicated `overlay-mpv-sub-visibility` service.
|
||||||
- **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check, Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, and AniList token refresh.
|
- **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check (with async worker thread), Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, AniList token refresh, and optional AnkiConnect proxy server. Warmup coverage is configurable through `startupWarmups` (including low-power mode that defers all but Yomitan).
|
||||||
- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through `SubtitlePipeline` (normalize → tokenize → merge), and results broadcast to all overlay windows.
|
- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through `SubtitlePipeline` (normalize → tokenize → merge), and results are sent to the main overlay renderer and modal surfaces.
|
||||||
- **Shutdown:** `onWillQuitCleanup` destroys tray + config watcher, unregisters shortcuts, stops WebSocket + texthooker servers, closes the mpv socket + flushes OSD log, stops the window tracker, closes the Yomitan parser window, flushes the immersion tracker (SQLite), stops Jellyfin/Discord services, and cleans Anki/AniList state.
|
- **Shutdown:** `onWillQuitCleanup` destroys tray + config watcher, unregisters shortcuts, stops WebSocket + texthooker servers, closes the mpv socket + flushes OSD log, stops the window tracker, closes the Yomitan parser window, flushes the immersion tracker (SQLite), stops Jellyfin/Discord services, stops the AnkiConnect proxy server, and cleans Anki/AniList state.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -298,27 +299,24 @@ flowchart LR
|
|||||||
|
|
||||||
OverlayInit["initializeOverlay<br/>Runtime()"]:::phase
|
OverlayInit["initializeOverlay<br/>Runtime()"]:::phase
|
||||||
|
|
||||||
OverlayInit --> VisWin["Visible window<br/>Yomitan lookups"]:::init
|
OverlayInit --> MainWin["Main overlay window<br/>primary + secondary subtitles"]:::init
|
||||||
OverlayInit --> InvWin["Invisible window<br/>mpv positioning"]:::init
|
|
||||||
OverlayInit --> SecWin["Secondary window<br/>subtitle bar"]:::init
|
|
||||||
OverlayInit --> Shortcuts["Register global<br/>shortcuts"]:::init
|
OverlayInit --> Shortcuts["Register global<br/>shortcuts"]:::init
|
||||||
|
|
||||||
VisWin --> Warmups
|
MainWin --> Warmups
|
||||||
InvWin --> Warmups
|
|
||||||
SecWin --> Warmups
|
|
||||||
Shortcuts --> Warmups
|
Shortcuts --> Warmups
|
||||||
|
|
||||||
Warmups["Background<br/>warmups"]:::phase
|
Warmups["Background<br/>warmups"]:::phase
|
||||||
|
|
||||||
subgraph WarmupGroup[" "]
|
subgraph WarmupGroup[" "]
|
||||||
direction TB
|
direction TB
|
||||||
W1["MeCab"]:::warmup
|
W1["MeCab<br/>+ worker thread"]:::warmup
|
||||||
W2["Yomitan"]:::warmup
|
W2["Yomitan"]:::warmup
|
||||||
W3["JLPT + freq<br/>dictionaries"]:::warmup
|
W3["JLPT + freq<br/>dictionaries"]:::warmup
|
||||||
W4["Jellyfin"]:::warmup
|
W4["Jellyfin"]:::warmup
|
||||||
W5["Discord"]:::warmup
|
W5["Discord"]:::warmup
|
||||||
W6["AniList"]:::warmup
|
W6["AniList"]:::warmup
|
||||||
W1 ~~~ W2 ~~~ W3 ~~~ W4 ~~~ W5 ~~~ W6
|
W7["AnkiConnect<br/>proxy"]:::warmup
|
||||||
|
W1 ~~~ W2 ~~~ W3 ~~~ W4 ~~~ W5 ~~~ W6 ~~~ W7
|
||||||
end
|
end
|
||||||
|
|
||||||
Warmups --> WarmupGroup
|
Warmups --> WarmupGroup
|
||||||
@@ -330,7 +328,7 @@ flowchart LR
|
|||||||
ExtEvt["Shortcuts · config hot-reload"]:::runtime
|
ExtEvt["Shortcuts · config hot-reload"]:::runtime
|
||||||
MpvEvt & IpcEvt & ExtEvt --> Route["Route via composers"]:::runtime
|
MpvEvt & IpcEvt & ExtEvt --> Route["Route via composers"]:::runtime
|
||||||
Route --> Process["SubtitlePipeline<br/>normalize → tokenize → merge"]:::runtime
|
Route --> Process["SubtitlePipeline<br/>normalize → tokenize → merge"]:::runtime
|
||||||
Process --> Broadcast["Update AppState<br/>broadcast to windows"]:::runtime
|
Process --> Broadcast["Update AppState<br/>broadcast to renderer + modals"]:::runtime
|
||||||
end
|
end
|
||||||
|
|
||||||
WarmupGroup --> Loop
|
WarmupGroup --> Loop
|
||||||
@@ -342,7 +340,7 @@ flowchart LR
|
|||||||
Quit --> T1["Tray · config watcher<br/>global shortcuts"]:::shutdown
|
Quit --> T1["Tray · config watcher<br/>global shortcuts"]:::shutdown
|
||||||
Quit --> T2["WebSocket · texthooker<br/>mpv socket · OSD log"]:::shutdown
|
Quit --> T2["WebSocket · texthooker<br/>mpv socket · OSD log"]:::shutdown
|
||||||
Quit --> T3["Window tracker<br/>Yomitan parser"]:::shutdown
|
Quit --> T3["Window tracker<br/>Yomitan parser"]:::shutdown
|
||||||
Quit --> T4["Immersion tracker<br/>Jellyfin · Discord<br/>Anki · AniList"]:::shutdown
|
Quit --> T4["Immersion tracker<br/>Jellyfin · Discord<br/>Anki proxy · AniList"]:::shutdown
|
||||||
|
|
||||||
style Loop fill:#363a4f,stroke:#494d64,color:#cad3f5
|
style Loop fill:#363a4f,stroke:#494d64,color:#cad3f5
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ Malformed config syntax (invalid JSON/JSONC) is startup-blocking: SubMiner shows
|
|||||||
|
|
||||||
For valid JSON/JSONC with invalid option values, SubMiner uses warn-and-fallback behavior: it logs the bad key/value and continues with the default for that option.
|
For valid JSON/JSONC with invalid option values, SubMiner uses warn-and-fallback behavior: it logs the bad key/value and continues with the default for that option.
|
||||||
|
|
||||||
|
On macOS, these validation warnings also open a native dialog with full details (desktop notification banners can truncate long messages).
|
||||||
|
|
||||||
### Hot-Reload Behavior
|
### Hot-Reload Behavior
|
||||||
|
|
||||||
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
|
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
|
||||||
@@ -74,7 +76,7 @@ The configuration file includes several main sections:
|
|||||||
- [**Auto-Start Overlay**](#auto-start-overlay) - Automatically show overlay on MPV connection
|
- [**Auto-Start Overlay**](#auto-start-overlay) - Automatically show overlay on MPV connection
|
||||||
- [**Visible Overlay Subtitle Binding**](#visible-overlay-subtitle-binding) - Link visible overlay toggles to MPV subtitle visibility
|
- [**Visible Overlay Subtitle Binding**](#visible-overlay-subtitle-binding) - Link visible overlay toggles to MPV subtitle visibility
|
||||||
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
||||||
- [**Invisible Overlay**](#invisible-overlay) - Startup visibility behavior for the invisible mining layer
|
- [**Subtitle Position Edit**](#subtitle-position-edit) - Fine-tune subtitle alignment in overlay
|
||||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
- [**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
|
||||||
@@ -87,6 +89,7 @@ The configuration file includes several main sections:
|
|||||||
- [**Subtitle Style**](#subtitle-style) - Appearance customization
|
- [**Subtitle Style**](#subtitle-style) - Appearance customization
|
||||||
- [**Texthooker**](#texthooker) - Control browser opening behavior
|
- [**Texthooker**](#texthooker) - Control browser opening behavior
|
||||||
- [**WebSocket Server**](#websocket-server) - Built-in subtitle broadcasting server
|
- [**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
|
||||||
|
|
||||||
@@ -100,6 +103,12 @@ Enable automatic Anki card creation and updates with media generation:
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"url": "http://127.0.0.1:8765",
|
"url": "http://127.0.0.1:8765",
|
||||||
"pollingRate": 3000,
|
"pollingRate": 3000,
|
||||||
|
"proxy": {
|
||||||
|
"enabled": false,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8766,
|
||||||
|
"upstreamUrl": "http://127.0.0.1:8765"
|
||||||
|
},
|
||||||
"tags": ["SubMiner"],
|
"tags": ["SubMiner"],
|
||||||
"deck": "Learning::Japanese",
|
"deck": "Learning::Japanese",
|
||||||
"fields": {
|
"fields": {
|
||||||
@@ -163,7 +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 (default: `3000`) |
|
| `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.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.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`. |
|
||||||
@@ -338,21 +351,7 @@ Control whether the overlay automatically becomes visible when it connects to mp
|
|||||||
| -------------------- | --------------- | ------------------------------------------------------ |
|
| -------------------- | --------------- | ------------------------------------------------------ |
|
||||||
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) |
|
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) |
|
||||||
|
|
||||||
The mpv plugin controls startup per layer via `auto_start_visible_overlay` and `auto_start_invisible_overlay` in `subminer.conf` (`platform-default` for invisible means hidden on Linux, visible on macOS/Windows).
|
The mpv plugin controls startup overlay visibility via `auto_start_visible_overlay` in `subminer.conf`.
|
||||||
|
|
||||||
### Visible Overlay Subtitle Binding
|
|
||||||
|
|
||||||
Control whether toggling the visible overlay also toggles MPV subtitle visibility:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"bind_visible_overlay_to_mpv_sub_visibility": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| -------------------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `bind_visible_overlay_to_mpv_sub_visibility` | `true`, `false` | When `true` (default), visible overlay hides MPV primary/secondary subtitles and restores them when hidden. When `false`, visible overlay toggles do not change MPV subtitle visibility. |
|
|
||||||
|
|
||||||
### Auto Subtitle Sync
|
### Auto Subtitle Sync
|
||||||
|
|
||||||
@@ -379,20 +378,12 @@ 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.
|
||||||
|
|
||||||
### Invisible Overlay
|
### Subtitle Position Edit
|
||||||
|
|
||||||
SubMiner includes a second subtitle mining layer that can be visually invisible while still interactive for Yomitan lookups.
|
Subtitle positioning can be adjusted directly in the overlay:
|
||||||
|
|
||||||
- `invisibleOverlay.startupVisibility` values:
|
|
||||||
|
|
||||||
1. `"platform-default"`: hidden on Wayland, visible on Windows/macOS/other sessions.
|
|
||||||
2. `"visible"`: always shown on startup.
|
|
||||||
3. `"hidden"`: always hidden on startup.
|
|
||||||
|
|
||||||
Invisible subtitle positioning can be adjusted directly in the invisible layer:
|
|
||||||
|
|
||||||
- `Ctrl/Cmd+Shift+P` toggles position edit mode.
|
- `Ctrl/Cmd+Shift+P` toggles position edit mode.
|
||||||
- Use arrow keys to move the invisible subtitle text.
|
- Use arrow keys to move subtitle text.
|
||||||
- Press `Enter` or `Ctrl/Cmd+S` to save, or `Esc` to cancel.
|
- Press `Enter` or `Ctrl/Cmd+S` to save, or `Esc` to cancel.
|
||||||
- This edit-mode shortcut is fixed (not currently configurable in `shortcuts`/`keybindings`).
|
- This edit-mode shortcut is fixed (not currently configurable in `shortcuts`/`keybindings`).
|
||||||
|
|
||||||
@@ -450,6 +441,8 @@ Setup flow details:
|
|||||||
2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
|
2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
|
||||||
3. Approve access in AniList.
|
3. Approve access in AniList.
|
||||||
4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically.
|
4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically.
|
||||||
|
- Encryption backend: Linux defaults to `gnome-libsecret`.
|
||||||
|
Override with `--password-store=<backend>` (for example `--password-store=basic_text`).
|
||||||
|
|
||||||
Token + detection notes:
|
Token + detection notes:
|
||||||
|
|
||||||
@@ -506,6 +499,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
|||||||
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
||||||
|
|
||||||
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup.
|
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup.
|
||||||
|
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
||||||
|
|
||||||
Launcher subcommands:
|
Launcher subcommands:
|
||||||
|
|
||||||
@@ -514,6 +508,7 @@ Launcher subcommands:
|
|||||||
- `subminer jellyfin --logout` clears stored credentials.
|
- `subminer jellyfin --logout` clears stored credentials.
|
||||||
- `subminer jellyfin -p` opens play picker.
|
- `subminer jellyfin -p` opens play picker.
|
||||||
- `subminer jellyfin -d` starts cast discovery mode.
|
- `subminer jellyfin -d` starts cast discovery mode.
|
||||||
|
- 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.
|
||||||
|
|
||||||
@@ -666,7 +661,6 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
{
|
{
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
||||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
|
|
||||||
"copySubtitle": "CommandOrControl+C",
|
"copySubtitle": "CommandOrControl+C",
|
||||||
"copySubtitleMultiple": "CommandOrControl+Shift+C",
|
"copySubtitleMultiple": "CommandOrControl+Shift+C",
|
||||||
"updateLastCardFromClipboard": "CommandOrControl+V",
|
"updateLastCardFromClipboard": "CommandOrControl+V",
|
||||||
@@ -685,7 +679,6 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
||||||
| `toggleInvisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling invisible interactive overlay (default: `"Alt+Shift+I"`) |
|
|
||||||
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
||||||
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
|
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
|
||||||
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
||||||
@@ -730,15 +723,23 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
|
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP",
|
||||||
"fontSize": 35,
|
"fontSize": 35,
|
||||||
"fontColor": "#cad3f5",
|
"fontColor": "#cad3f5",
|
||||||
"fontWeight": "normal",
|
"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",
|
"fontStyle": "normal",
|
||||||
"backgroundColor": "rgb(30, 32, 48, 0.88)",
|
"backgroundColor": "rgb(30, 32, 48, 0.88)",
|
||||||
|
"backdropFilter": "blur(6px)",
|
||||||
"secondary": {
|
"secondary": {
|
||||||
|
"fontFamily": "Manrope, Inter",
|
||||||
"fontSize": 24,
|
"fontSize": 24,
|
||||||
"fontColor": "#ffffff",
|
"fontColor": "#cad3f5",
|
||||||
"backgroundColor": "transparent"
|
"backgroundColor": "transparent"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -747,16 +748,16 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `fontFamily` | string | CSS font-family value (default: `"Noto Sans CJK JP Regular, ..."`) |
|
| `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`) |
|
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
||||||
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
||||||
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"normal"`) |
|
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
||||||
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
||||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
|
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
|
||||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
| `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 the built-in bundled dictionary search paths. |
|
| `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.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
|
||||||
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
||||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
||||||
@@ -773,12 +774,12 @@ Frequency dictionary highlighting uses the same dictionary file format as JLPT b
|
|||||||
Lookup behavior:
|
Lookup behavior:
|
||||||
|
|
||||||
- Set `frequencyDictionary.sourcePath` to a directory containing `term_meta_bank_*.json` for a fully custom source.
|
- Set `frequencyDictionary.sourcePath` to a directory containing `term_meta_bank_*.json` for a fully custom source.
|
||||||
- If `sourcePath` is missing or empty, SubMiner uses bundled defaults from `vendor/jiten_freq_global` (packaged under `<resources>/jiten_freq_global` in distribution builds).
|
- If `sourcePath` is missing or empty, SubMiner searches default install/runtime locations for `frequency-dictionary` directories (for example app resources, user data paths, and current working directory).
|
||||||
- In both cases, only terms with a valid `frequencyRank` are used; everything else falls back to no highlighting.
|
- In both cases, only terms with a valid `frequencyRank` are used; everything else falls back to no highlighting.
|
||||||
|
|
||||||
In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window.
|
In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window.
|
||||||
|
|
||||||
Secondary subtitle defaults: `fontSize: 24`, `fontColor: "#ffffff"`, `backgroundColor: "transparent"`. Any property not set in `secondary` falls back to the CSS defaults.
|
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.
|
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
|
||||||
|
|
||||||
@@ -828,6 +829,32 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `enabled` | `true`, `false`, `"auto"` | `"auto"` (default) disables if mpv_websocket is detected |
|
| `enabled` | `true`, `false`, `"auto"` | `"auto"` (default) disables if mpv_websocket is detected |
|
||||||
| `port` | number | WebSocket server port (default: 6677) |
|
| `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:
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ bun run dev # builds + launches with --start --dev
|
|||||||
electron . --start --dev --log-level debug # equivalent Electron launch with verbose logging
|
electron . --start --dev --log-level debug # equivalent Electron launch with verbose logging
|
||||||
electron . --background # tray/background mode, minimal default logging
|
electron . --background # tray/background mode, minimal default logging
|
||||||
make dev-start # build + launch via Makefile
|
make dev-start # build + launch via Makefile
|
||||||
|
make dev-watch # watch TS + renderer and launch Electron (faster edit loop)
|
||||||
|
make dev-watch-macos # same as dev-watch, forcing --backend macos
|
||||||
|
```
|
||||||
|
|
||||||
|
For mpv-plugin-driven testing without exporting `SUBMINER_BINARY_PATH` each run, set a one-time
|
||||||
|
dev binary path in `~/.config/mpv/script-opts/subminer.conf`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
binary_path=/absolute/path/to/SubMiner/scripts/subminer-dev.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ titleTemplate: Immersion Mining Workflow for MPV
|
|||||||
hero:
|
hero:
|
||||||
name: SubMiner
|
name: SubMiner
|
||||||
text: Immersion Mining for MPV
|
text: Immersion Mining for MPV
|
||||||
tagline: Watch media, mine vocabulary, and build cards without leaving the scene.
|
tagline: Watch media, mine vocabulary, and craft anki cards without leaving the scene.
|
||||||
image:
|
image:
|
||||||
src: /assets/SubMiner.png
|
src: /assets/SubMiner.png
|
||||||
alt: SubMiner logo
|
alt: SubMiner logo
|
||||||
@@ -35,16 +35,11 @@ features:
|
|||||||
alt: Anki card icon
|
alt: Anki card icon
|
||||||
title: Anki Card Enrichment
|
title: Anki Card Enrichment
|
||||||
details: Auto-fills card fields with subtitle sentence, clipping, image, and translation so you can focus on learning.
|
details: Auto-fills card fields with subtitle sentence, clipping, image, and translation so you can focus on learning.
|
||||||
- icon:
|
|
||||||
src: /assets/dual-layer.svg
|
|
||||||
alt: Dual layer icon
|
|
||||||
title: Three-Plane Overlay Stack
|
|
||||||
details: Secondary context plane + visible interactive layer + invisible interaction plane, each with independent behavior and startup state.
|
|
||||||
- icon:
|
- icon:
|
||||||
src: /assets/highlight.svg
|
src: /assets/highlight.svg
|
||||||
alt: Highlight icon
|
alt: Highlight icon
|
||||||
title: N+1 Highlighting
|
title: Reading Annotations
|
||||||
details: Surfaces known words from your deck so unknown targets stand out during immersion sessions.
|
details: Combines N+1 targeting, Jiten frequency highlighting, and JLPT tagging so useful cues stay visible while you read.
|
||||||
- icon:
|
- icon:
|
||||||
src: /assets/tokenization.svg
|
src: /assets/tokenization.svg
|
||||||
alt: Tokenization icon
|
alt: Tokenization icon
|
||||||
@@ -55,16 +50,6 @@ features:
|
|||||||
alt: Subtitle download icon
|
alt: Subtitle download icon
|
||||||
title: Subtitle Download & Sync
|
title: Subtitle Download & Sync
|
||||||
details: Pull and synchronize subtitles with Jimaku plus alass/ffsubsync in one cohesive workflow.
|
details: Pull and synchronize subtitles with Jimaku plus alass/ffsubsync in one cohesive workflow.
|
||||||
- icon:
|
|
||||||
src: /assets/keyboard.svg
|
|
||||||
alt: Keyboard icon
|
|
||||||
title: Keyboard-Driven
|
|
||||||
details: Run lookups, mining actions, clipping, and workflow toggles with one configurable shortcut surface.
|
|
||||||
- icon:
|
|
||||||
src: /assets/texthooker.svg
|
|
||||||
alt: Texthooker icon
|
|
||||||
title: Texthooker & WebSocket
|
|
||||||
details: Stream subtitles in real time to browser tools via local WebSocket and keep your stack integrated.
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|||||||
@@ -150,7 +150,9 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-asse
|
|||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
mkdir -p ~/.config/SubMiner
|
mkdir -p ~/.config/SubMiner
|
||||||
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
||||||
cp /tmp/plugin/subminer.lua ~/.config/mpv/scripts/
|
mkdir -p ~/.config/mpv/scripts/subminer
|
||||||
|
mkdir -p ~/.config/mpv/script-opts
|
||||||
|
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
|
||||||
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
||||||
|
|
||||||
# Option 2: from source checkout
|
# Option 2: from source checkout
|
||||||
@@ -181,9 +183,6 @@ All keybindings use a `y` chord prefix — press `y`, then the second key:
|
|||||||
| `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-i` | Toggle invisible overlay |
|
|
||||||
| `y-I` | Show invisible overlay |
|
|
||||||
| `y-u` | Hide invisible 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 |
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ SubMiner includes an optional Jellyfin CLI integration for:
|
|||||||
|
|
||||||
- Jellyfin server URL and user credentials
|
- Jellyfin server URL and user credentials
|
||||||
- For `--jellyfin-play`: connected mpv IPC socket (`--start` or existing mpv plugin workflow)
|
- For `--jellyfin-play`: connected mpv IPC socket (`--start` or existing mpv plugin workflow)
|
||||||
|
- On Linux, token encryption defaults to `gnome-libsecret`; pass `--password-store=<backend>` to override.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -150,6 +151,7 @@ User-visible errors are shown through CLI logs and mpv OSD for:
|
|||||||
## Security Notes and Limitations
|
## Security Notes and Limitations
|
||||||
|
|
||||||
- Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted token storage after login/setup.
|
- Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted token storage after login/setup.
|
||||||
|
- Launcher wrappers support `--password-store=<backend>` and forward it through to the app process.
|
||||||
- Optional environment overrides are supported: `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID`.
|
- Optional environment overrides are supported: `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID`.
|
||||||
- Treat both token storage and config files as secrets and avoid committing them.
|
- Treat both token storage and config files as secrets and avoid committing them.
|
||||||
- Password is used only for login and is not stored.
|
- Password is used only for login and is not stored.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ The expected files are:
|
|||||||
|
|
||||||
Each bank maps terms to frequency metadata; only entries with a `frequency.displayValue` are considered for JLPT tagging.
|
Each bank maps terms to frequency metadata; only entries with a `frequency.displayValue` are considered for JLPT tagging.
|
||||||
|
|
||||||
SubMiner also reuses the same `term_meta_bank_*.json` format for frequency-based subtitle highlighting. The default frequency source is now bundled as `vendor/jiten_freq_global`, so users can enable `subtitleStyle.frequencyDictionary` without extra setup.
|
SubMiner also reuses the same `term_meta_bank_*.json` format for frequency-based subtitle highlighting, using installed/default `frequency-dictionary` locations or an explicit `subtitleStyle.frequencyDictionary.sourcePath`.
|
||||||
|
|
||||||
## Source and update process
|
## Source and update process
|
||||||
|
|
||||||
|
|||||||
@@ -20,15 +20,15 @@ SubMiner prioritizes subtitle responsiveness over heavy initialization:
|
|||||||
1. The first subtitle render is **plain text first** (no tokenization wait).
|
1. The first subtitle render is **plain text first** (no tokenization wait).
|
||||||
2. Tokenized enrichment (word spans, known-word flags, JLPT/frequency metadata) is applied right after parsing completes.
|
2. Tokenized enrichment (word spans, known-word flags, JLPT/frequency metadata) is applied right after parsing completes.
|
||||||
3. Under rapid subtitle churn, SubMiner uses a **latest-only tokenization queue** so stale lines are dropped instead of building lag.
|
3. Under rapid subtitle churn, SubMiner uses a **latest-only tokenization queue** so stale lines are dropped instead of building lag.
|
||||||
4. MeCab, Yomitan extension load, and dictionary prewarm run as background warmups after overlay initialization.
|
4. MeCab, Yomitan extension load, and dictionary prewarm run as background warmups after overlay initialization (configurable via `startupWarmups`, including low-power mode).
|
||||||
|
|
||||||
This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes.
|
This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes.
|
||||||
|
|
||||||
## The Three Overlay Planes
|
## Overlay Model
|
||||||
|
|
||||||
SubMiner uses three overlay planes, each serving a different purpose.
|
SubMiner uses one overlay window with modal surfaces.
|
||||||
|
|
||||||
### Visible Overlay
|
### Primary Subtitle Layer
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
@@ -38,31 +38,17 @@ The visible overlay renders subtitles as tokenized, clickable word spans. Each w
|
|||||||
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
||||||
- **N+1 highlighting** — known words from your Anki deck are visually highlighted
|
- **N+1 highlighting** — known words from your Anki deck are visually highlighted
|
||||||
|
|
||||||
Toggle with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
|
Toggle visibility with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
|
||||||
|
|
||||||
### Secondary Subtitle Plane
|
### Secondary Subtitle Bar
|
||||||
|
|
||||||
The secondary plane is a compact top-strip layer for translation and context visibility while keeping primary reading flow below. It mirrors your configured secondary subtitle preference and can be independently shown or hidden.
|
The secondary subtitle bar is a compact top-strip region in the same overlay window for translation/context visibility while keeping primary reading flow below. It mirrors your configured secondary subtitle preference and can be independently shown or hidden.
|
||||||
|
|
||||||
It is controlled by `secondarySub` configuration and shares lifecycle with the overlay stack.
|
It is controlled by `secondarySub` configuration and shares lifecycle with the main overlay window.
|
||||||
|
|
||||||
### Invisible Overlay
|
### Modal Surfaces
|
||||||
|
|
||||||
The invisible overlay is a transparent layer aligned with mpv's own subtitle rendering. It uses mpv's subtitle metrics (font size, margins, position, scaling) to map click targets accurately.
|
Jimaku search, field-grouping, runtime options, and manual subsync open as modal surfaces on top of the same overlay window.
|
||||||
|
|
||||||
This layer still supports:
|
|
||||||
|
|
||||||
- Word-level click-through lookups over the text region
|
|
||||||
- Optional manual position fine-tuning in pixel mode
|
|
||||||
- Independent toggle behavior with global shortcuts
|
|
||||||
|
|
||||||
Position edit mode is available via `Ctrl/Cmd+Shift+P`, then arrow keys / `hjkl` to nudge position; `Shift` moves faster. Save with `Enter` or `Ctrl+S`, cancel with `Esc`.
|
|
||||||
|
|
||||||
Toggle controls:
|
|
||||||
|
|
||||||
- `Alt+Shift+O` / `y-t`: visible overlay
|
|
||||||
- `Alt+Shift+I` / `y-i`: invisible overlay
|
|
||||||
- Secondary plane visibility is controlled via `secondarySub` config and matching global shortcuts.
|
|
||||||
|
|
||||||
## Looking Up Words
|
## Looking Up Words
|
||||||
|
|
||||||
@@ -73,10 +59,10 @@ Toggle controls:
|
|||||||
3. Yomitan detects the text selection and opens its popup with dictionary results.
|
3. Yomitan detects the text selection and opens its popup with dictionary results.
|
||||||
4. From the Yomitan popup, you can add the word directly to Anki.
|
4. From the Yomitan popup, you can add the word directly to Anki.
|
||||||
|
|
||||||
### On the Invisible Overlay
|
### On Overlay Subtitles
|
||||||
|
|
||||||
1. The invisible layer sits over mpv's own subtitle text.
|
1. Subtitles are rendered directly in the overlay.
|
||||||
2. Click on any word in the subtitle — SubMiner maps your click position to the underlying text.
|
2. Click on any word in the subtitle.
|
||||||
3. On macOS, word selection happens automatically on hover.
|
3. On macOS, word selection happens automatically on hover.
|
||||||
4. Yomitan popup appears for lookup and card creation.
|
4. Yomitan popup appears for lookup and card creation.
|
||||||
|
|
||||||
@@ -86,11 +72,13 @@ There are three ways to create cards, depending on your workflow.
|
|||||||
|
|
||||||
### 1. Auto-Update from Yomitan
|
### 1. Auto-Update from Yomitan
|
||||||
|
|
||||||
This is the most common flow. Yomitan creates a card in Anki, and SubMiner detects it via polling and enriches it automatically.
|
This is the most common flow. Yomitan creates a card in Anki, and SubMiner enriches it automatically.
|
||||||
|
|
||||||
1. Click a word → Yomitan popup appears.
|
1. Click a word → Yomitan popup appears.
|
||||||
2. Click the Anki icon in Yomitan to add the word.
|
2. Click the Anki icon in Yomitan to add the word.
|
||||||
3. SubMiner detects the new card (polls AnkiConnect every 3 seconds by default).
|
3. SubMiner receives or detects the new card:
|
||||||
|
- **Proxy mode** (`ankiConnect.proxy.enabled: true`): immediate enrich after successful `addNote` / `addNotes`.
|
||||||
|
- **Polling mode** (default): detects via AnkiConnect polling (`ankiConnect.pollingRate`, default 3 seconds).
|
||||||
4. SubMiner updates the card with:
|
4. SubMiner updates the card with:
|
||||||
- **Sentence**: The current subtitle line.
|
- **Sentence**: The current subtitle line.
|
||||||
- **Audio**: Extracted from the video using the subtitle's start/end timing (plus configurable padding).
|
- **Audio**: Extracted from the video using the subtitle's start/end timing (plus configurable padding).
|
||||||
@@ -109,7 +97,7 @@ If you prefer a hands-on approach (animecards-style), you can copy the current s
|
|||||||
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1`–`9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
|
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1`–`9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
|
||||||
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation — the same fields auto-update would fill.
|
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation — the same fields auto-update would fill.
|
||||||
|
|
||||||
This is useful when auto-update polling 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 |
|
||||||
| --------------------------- | ----------------------------------------- | ------------------------------------- |
|
| --------------------------- | ----------------------------------------- | ------------------------------------- |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# MPV Plugin
|
# MPV Plugin
|
||||||
|
|
||||||
The SubMiner mpv plugin (`subminer.lua`) provides in-player keybindings to control the overlay without leaving mpv. It communicates with SubMiner by invoking the AppImage (or binary) with CLI flags.
|
The SubMiner mpv plugin (`subminer/main.lua`) provides in-player keybindings to control the overlay without leaving mpv. It communicates with SubMiner by invoking the AppImage (or binary) with CLI flags.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -10,7 +10,9 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-asse
|
|||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
mkdir -p ~/.config/SubMiner
|
mkdir -p ~/.config/SubMiner
|
||||||
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
||||||
cp /tmp/plugin/subminer.lua ~/.config/mpv/scripts/
|
mkdir -p ~/.config/mpv/scripts/subminer
|
||||||
|
mkdir -p ~/.config/mpv/script-opts
|
||||||
|
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
|
||||||
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
||||||
|
|
||||||
# Or from source checkout: make install-plugin
|
# Or from source checkout: make install-plugin
|
||||||
@@ -33,9 +35,6 @@ All keybindings use a `y` chord prefix — press `y`, then the second key:
|
|||||||
| `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-i` | Toggle invisible overlay |
|
|
||||||
| `y-I` | Show invisible overlay |
|
|
||||||
| `y-u` | Hide invisible 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 |
|
||||||
@@ -50,10 +49,9 @@ SubMiner:
|
|||||||
1. Start overlay
|
1. Start overlay
|
||||||
2. Stop overlay
|
2. Stop overlay
|
||||||
3. Toggle overlay
|
3. Toggle overlay
|
||||||
4. Toggle invisible overlay
|
4. Open options
|
||||||
5. Open options
|
5. Restart overlay
|
||||||
6. Restart overlay
|
6. Check status
|
||||||
7. Check status
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Select an item by pressing its number.
|
Select an item by pressing its number.
|
||||||
@@ -79,15 +77,13 @@ texthooker_port=5174
|
|||||||
backend=auto
|
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.
|
||||||
auto_start=no
|
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.
|
||||||
auto_start_visible_overlay=no
|
auto_start_visible_overlay=no
|
||||||
|
|
||||||
# Invisible overlay startup: platform-default, visible, hidden.
|
|
||||||
# platform-default = hidden on Linux, visible on macOS/Windows.
|
|
||||||
auto_start_invisible_overlay=platform-default
|
|
||||||
|
|
||||||
# Show OSD messages for overlay status changes.
|
# Show OSD messages for overlay status changes.
|
||||||
osd_messages=yes
|
osd_messages=yes
|
||||||
|
|
||||||
@@ -127,9 +123,8 @@ aniskip_button_duration=3
|
|||||||
| `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` | `no` | `yes` / `no` | Auto-start overlay on file load |
|
| `auto_start` | `no` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
|
||||||
| `auto_start_visible_overlay` | `no` | `yes` / `no` | Show visible layer on auto-start |
|
| `auto_start_visible_overlay` | `no` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
|
||||||
| `auto_start_invisible_overlay` | `platform-default` | `platform-default`, `visible`, `hidden` | Invisible layer on auto-start |
|
|
||||||
| `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 |
|
||||||
@@ -182,9 +177,6 @@ The plugin can be controlled from other mpv scripts or the mpv command line usin
|
|||||||
script-message subminer-start
|
script-message subminer-start
|
||||||
script-message subminer-stop
|
script-message subminer-stop
|
||||||
script-message subminer-toggle
|
script-message subminer-toggle
|
||||||
script-message subminer-toggle-invisible
|
|
||||||
script-message subminer-show-invisible
|
|
||||||
script-message subminer-hide-invisible
|
|
||||||
script-message subminer-menu
|
script-message subminer-menu
|
||||||
script-message subminer-options
|
script-message subminer-options
|
||||||
script-message subminer-restart
|
script-message subminer-restart
|
||||||
@@ -204,7 +196,12 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
|||||||
|
|
||||||
## AniSkip Intro Skip
|
## AniSkip Intro Skip
|
||||||
|
|
||||||
- On file load, plugin resolves title + episode, resolves MAL id, then calls AniSkip API.
|
- AniSkip lookups are gated. The plugin only runs lookup when:
|
||||||
|
- SubMiner launcher metadata is present, or
|
||||||
|
- SubMiner app process is already running, or
|
||||||
|
- You explicitly call `script-message subminer-aniskip-refresh`.
|
||||||
|
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
|
||||||
|
- MAL/title resolution is cached for the current mpv session.
|
||||||
- When launched via `subminer`, launcher runs `guessit` first (file targets) and passes title/season/episode to the plugin; fallback is filename-derived title.
|
- When launched via `subminer`, launcher runs `guessit` first (file targets) and passes title/season/episode to the plugin; fallback is filename-derived title.
|
||||||
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
||||||
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
||||||
@@ -213,7 +210,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
|||||||
|
|
||||||
## Lifecycle
|
## Lifecycle
|
||||||
|
|
||||||
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay and applies visibility preferences after a short delay.
|
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
|
||||||
- **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.
|
||||||
|
|
||||||
|
|||||||
55
docs/plans/2026-02-26-secondary-subtitles-main-overlay.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Secondary Subtitles Main Overlay Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Ensure secondary subtitles render in the unified main overlay window and remove stale secondary-window/layer paths.
|
||||||
|
|
||||||
|
**Architecture:** Keep secondary subtitle DOM in the shared renderer tree, rely on mode classes (`secondary-sub-hidden|visible|hover`) for visibility, and remove obsolete legacy overlay-layer assumptions. Preserve modal behavior and existing subtitle rendering flow.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Electron renderer CSS/DOM, Bun test runner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Regression Tests For Main Overlay Secondary Rendering
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/renderer/subtitle-render.test.ts`
|
||||||
|
- Modify: `src/renderer/error-recovery.test.ts`
|
||||||
|
|
||||||
|
**Step 1: Write failing tests**
|
||||||
|
- Assert stylesheet no longer hides secondary subtitles in `layer-visible`.
|
||||||
|
- Assert renderer platform resolution ignores legacy `secondary` overlay layer.
|
||||||
|
|
||||||
|
**Step 2: Run tests to verify failures**
|
||||||
|
|
||||||
|
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts`
|
||||||
|
Expected: FAIL on secondary subtitle hide rule + legacy secondary layer handling.
|
||||||
|
|
||||||
|
### Task 2: Remove Secondary-Window CSS/Routing Assumptions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/renderer/style.css`
|
||||||
|
- Modify: `src/renderer/utils/platform.ts`
|
||||||
|
- Modify: `src/renderer/error-recovery.ts`
|
||||||
|
- Modify: `src/types.ts`
|
||||||
|
|
||||||
|
**Step 1: Implement minimal changes**
|
||||||
|
- Remove legacy forced hide on `#secondarySubContainer`.
|
||||||
|
- Remove obsolete layer-specific secondary-subtitle CSS blocks.
|
||||||
|
- Drop legacy `secondary` overlay-layer parsing path from renderer platform resolver.
|
||||||
|
- Narrow related overlay layer type unions.
|
||||||
|
|
||||||
|
**Step 2: Run targeted tests**
|
||||||
|
|
||||||
|
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Validate Wider Related Surface
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No additional code changes required.
|
||||||
|
|
||||||
|
**Step 1: Run broader related tests**
|
||||||
|
|
||||||
|
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/overlay-window-factory.test.ts src/core/services/overlay-manager.test.ts`
|
||||||
|
Expected: Renderer tests pass; report any unrelated pre-existing failures.
|
||||||
@@ -1,15 +1,40 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="ac" x1="6" y1="6" x2="36" y2="42" gradientUnits="userSpaceOnUse">
|
<linearGradient id="ac-card" x1="6" y1="8" x2="38" y2="44" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#34d399"/>
|
<stop stop-color="#34d399"/>
|
||||||
<stop offset="1" stop-color="#059669"/>
|
<stop offset="1" stop-color="#059669"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="ac-glow" x1="8" y1="10" x2="36" y2="42" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#6ee7b7" stop-opacity="0.5"/>
|
||||||
|
<stop offset="1" stop-color="#059669" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="ac-soft" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="1.2"/>
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="12" y="5" width="24" height="34" rx="3" fill="#059669" opacity="0.18"/>
|
<!-- Glow aura behind card -->
|
||||||
<rect x="8" y="9" width="24" height="34" rx="3" fill="url(#ac)"/>
|
<rect x="6" y="8" width="28" height="36" rx="5" fill="url(#ac-glow)" filter="url(#ac-soft)"/>
|
||||||
<rect x="13" y="18" width="14" height="2.5" rx="1.25" fill="white" opacity="0.85"/>
|
<!-- Shadow card (back) -->
|
||||||
<rect x="13" y="24" width="10" height="2.5" rx="1.25" fill="white" opacity="0.4"/>
|
<rect x="14" y="5" width="26" height="34" rx="4" fill="#059669" opacity="0.15"/>
|
||||||
<rect x="13" y="30" width="12" height="2.5" rx="1.25" fill="white" opacity="0.4"/>
|
<!-- Main card -->
|
||||||
<path d="M39.5 8l1.8 4.2 4.2 1.8-4.2 1.8L39.5 20l-1.8-4.2L33.5 14l4.2-1.8z" fill="#34d399"/>
|
<rect x="6" y="9" width="26" height="34" rx="4" fill="url(#ac-card)"/>
|
||||||
<path d="M36 27l1 2.3 2.3 1-2.3 1L36 33.5l-1-2.2-2.3-1 2.3-1z" fill="#34d399" opacity="0.45"/>
|
<!-- Sentence line -->
|
||||||
|
<rect x="10" y="16" width="16" height="2.5" rx="1.25" fill="white" opacity="0.9"/>
|
||||||
|
<!-- Audio waveform mini -->
|
||||||
|
<rect x="10" y="22" width="1.8" height="5" rx="0.9" fill="white" opacity="0.55"/>
|
||||||
|
<rect x="13" y="20.5" width="1.8" height="8" rx="0.9" fill="white" opacity="0.55"/>
|
||||||
|
<rect x="16" y="21.5" width="1.8" height="6" rx="0.9" fill="white" opacity="0.55"/>
|
||||||
|
<rect x="19" y="23" width="1.8" height="3" rx="0.9" fill="white" opacity="0.55"/>
|
||||||
|
<rect x="22" y="21" width="1.8" height="7" rx="0.9" fill="white" opacity="0.55"/>
|
||||||
|
<rect x="25" y="22.5" width="1.8" height="4" rx="0.9" fill="white" opacity="0.55"/>
|
||||||
|
<!-- Image thumbnail placeholder -->
|
||||||
|
<rect x="10" y="30" width="10" height="8" rx="2" fill="white" opacity="0.25"/>
|
||||||
|
<path d="M12.5 35.5l2-2.5 2 1.8 1.5-1 2.5 3h-8z" fill="white" opacity="0.5"/>
|
||||||
|
<!-- Translation line -->
|
||||||
|
<rect x="22" y="32" width="7" height="2" rx="1" fill="white" opacity="0.35"/>
|
||||||
|
<rect x="22" y="35.5" width="5" height="2" rx="1" fill="white" opacity="0.25"/>
|
||||||
|
<!-- Enrichment sparkle burst -->
|
||||||
|
<path d="M40 10l1.6 3.8 3.8 1.6-3.8 1.6L40 20.8l-1.6-3.8L34.6 15.4l3.8-1.6z" fill="#6ee7b7"/>
|
||||||
|
<path d="M37 29l0.9 2.1 2.1 0.9-2.1 0.9L37 35l-0.9-2.1-2.1-0.9 2.1-0.9z" fill="#6ee7b7" opacity="0.5"/>
|
||||||
|
<circle cx="43" cy="25" r="1.2" fill="#34d399" opacity="0.4"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 2.3 KiB |
@@ -1,13 +1,39 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="hl" x1="20" y1="14" x2="38" y2="34" gradientUnits="userSpaceOnUse">
|
<linearGradient id="hl-freq" x1="0" y1="0" x2="14" y2="8" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#fbbf24"/>
|
<stop stop-color="#fbbf24"/>
|
||||||
<stop offset="1" stop-color="#f59e0b"/>
|
<stop offset="1" stop-color="#f59e0b"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="hl-n1" x1="0" y1="0" x2="10" y2="10" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#60a5fa"/>
|
||||||
|
<stop offset="1" stop-color="#3b82f6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="hl-jlpt" x1="0" y1="0" x2="12" y2="8" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#a78bfa"/>
|
||||||
|
<stop offset="1" stop-color="#7c3aed"/>
|
||||||
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="2" y="17" width="10" height="14" rx="3" fill="#fbbf24" opacity="0.3"/>
|
<!-- Viewport / video frame background -->
|
||||||
<rect x="14" y="17" width="7" height="14" rx="3" fill="#fbbf24" opacity="0.3"/>
|
<rect x="1" y="5" width="46" height="38" rx="4" fill="#1e293b" opacity="0.55"/>
|
||||||
<rect x="23" y="13" width="13" height="22" rx="3.5" fill="url(#hl)"/>
|
<rect x="1" y="5" width="46" height="38" rx="4" stroke="#334155" stroke-width="0.8" fill="none" opacity="0.5"/>
|
||||||
<rect x="38" y="17" width="8" height="14" rx="3" fill="#fbbf24" opacity="0.3"/>
|
<!-- Subtitle line 1 — tokens with frequency highlight -->
|
||||||
<path d="M28.2 4l1 2.4 2.4 1-2.4 1-1 2.4-1-2.4-2.4-1 2.4-1z" fill="#fbbf24" opacity="0.7"/>
|
<rect x="6" y="18" width="9" height="5" rx="1.5" fill="#cbd5e1" opacity="0.2"/>
|
||||||
|
<!-- Frequency-highlighted token -->
|
||||||
|
<rect x="17" y="17" width="14" height="7" rx="2" fill="url(#hl-freq)" opacity="0.2"/>
|
||||||
|
<rect x="17.5" y="17.5" width="13" height="6" rx="1.8" fill="url(#hl-freq)"/>
|
||||||
|
<rect x="20" y="19.5" width="8" height="2" rx="1" fill="white" opacity="0.85"/>
|
||||||
|
<rect x="33" y="18" width="8" height="5" rx="1.5" fill="#cbd5e1" opacity="0.2"/>
|
||||||
|
<!-- Subtitle line 2 — tokens with N+1 dot and JLPT badge -->
|
||||||
|
<rect x="8" y="28" width="8" height="5" rx="1.5" fill="#cbd5e1" opacity="0.2"/>
|
||||||
|
<!-- N+1 targeted token with dot -->
|
||||||
|
<rect x="18" y="28" width="10" height="5" rx="1.5" fill="#60a5fa" opacity="0.15"/>
|
||||||
|
<rect x="18.5" y="28.5" width="9" height="4" rx="1.2" fill="#cbd5e1" opacity="0.3"/>
|
||||||
|
<circle cx="16.5" cy="30.5" r="2.2" fill="url(#hl-n1)"/>
|
||||||
|
<text x="16.5" y="31.9" text-anchor="middle" font-size="2.6" font-weight="800" fill="white" font-family="sans-serif">+1</text>
|
||||||
|
<!-- JLPT badge token -->
|
||||||
|
<rect x="30" y="28" width="7" height="5" rx="1.5" fill="#cbd5e1" opacity="0.3"/>
|
||||||
|
<rect x="37.5" y="27" width="9" height="7" rx="2" fill="url(#hl-jlpt)"/>
|
||||||
|
<text x="42" y="31.8" text-anchor="middle" font-size="3.5" font-weight="700" fill="white" font-family="sans-serif">N2</text>
|
||||||
|
<!-- Subtle sparkle -->
|
||||||
|
<path d="M43 10l0.7 1.6 1.6 0.7-1.6 0.7L43 14.6l-0.7-1.6-1.6-0.7 1.6-0.7z" fill="#fbbf24" opacity="0.5"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 729 B After Width: | Height: | Size: 2.4 KiB |
@@ -1,21 +1,31 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="kb" x1="2" y1="10" x2="46" y2="42" gradientUnits="userSpaceOnUse">
|
<linearGradient id="kb-main" x1="2" y1="10" x2="46" y2="42" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#c084fc"/>
|
<stop stop-color="#c084fc"/>
|
||||||
<stop offset="1" stop-color="#7c3aed"/>
|
<stop offset="1" stop-color="#7c3aed"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<filter id="kb-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="2"/>
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="2" y="12" width="44" height="30" rx="5" fill="url(#kb)" opacity="0.12"/>
|
<!-- Keyboard body -->
|
||||||
<rect x="2" y="12" width="44" height="30" rx="5" stroke="url(#kb)" stroke-width="1.5" fill="none"/>
|
<rect x="2" y="14" width="44" height="28" rx="4.5" fill="url(#kb-main)" opacity="0.1"/>
|
||||||
<rect x="6" y="16" width="8" height="6" rx="2" fill="url(#kb)"/>
|
<rect x="2" y="14" width="44" height="28" rx="4.5" stroke="url(#kb-main)" stroke-width="1.4" fill="none"/>
|
||||||
<rect x="16" y="16" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<!-- Row 1 -->
|
||||||
<rect x="26" y="16" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<rect x="6" y="18" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
<rect x="36" y="16" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<rect x="15" y="18" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
<rect x="6" y="24" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<rect x="24" y="18" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
<rect x="16" y="24" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<rect x="33" y="18" width="11" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
<rect x="26" y="24" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<!-- Row 2 — active key with glow -->
|
||||||
<rect x="36" y="24" width="8" height="6" rx="2" fill="url(#kb)"/>
|
<rect x="6" y="25.5" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
<rect x="6" y="32" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<!-- Active/pressed key glow -->
|
||||||
<rect x="16" y="32" width="16" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<rect x="15" y="25.5" width="7" height="5.5" rx="1.8" fill="#c084fc" opacity="0.25" filter="url(#kb-glow)"/>
|
||||||
<rect x="34" y="32" width="10" height="6" rx="2" fill="url(#kb)" opacity="0.35"/>
|
<rect x="15" y="25.5" width="7" height="5.5" rx="1.8" fill="url(#kb-main)"/>
|
||||||
|
<text x="18.5" y="30" text-anchor="middle" font-size="3.5" font-weight="700" fill="white" font-family="sans-serif">M</text>
|
||||||
|
<rect x="24" y="25.5" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
|
<rect x="33" y="25.5" width="11" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
|
<!-- Row 3 — spacebar -->
|
||||||
|
<rect x="6" y="33" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
|
<rect x="15" y="33" width="16" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.25"/>
|
||||||
|
<rect x="33" y="33" width="11" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 141 KiB |
BIN
docs/public/assets/kiku-integration.gif
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
docs/public/assets/kiku-integration.mkv
Normal file
BIN
docs/public/assets/kiku-integration.mp4
Normal file
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 13 MiB After Width: | Height: | Size: 12 MiB |
@@ -1,16 +1,35 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="sd" x1="4" y1="4" x2="44" y2="44" gradientUnits="userSpaceOnUse">
|
<linearGradient id="sd-main" x1="4" y1="4" x2="44" y2="44" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#22d3ee"/>
|
<stop stop-color="#22d3ee"/>
|
||||||
<stop offset="1" stop-color="#0891b2"/>
|
<stop offset="1" stop-color="#0891b2"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="sd-sync" x1="30" y1="28" x2="46" y2="44" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#34d399"/>
|
||||||
|
<stop offset="1" stop-color="#059669"/>
|
||||||
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="8" y="4" width="24" height="32" rx="3" fill="url(#sd)" opacity="0.15"/>
|
<!-- Subtitle file -->
|
||||||
<rect x="8" y="4" width="24" height="32" rx="3" stroke="url(#sd)" stroke-width="1.5" fill="none"/>
|
<rect x="4" y="3" width="26" height="34" rx="3.5" fill="url(#sd-main)" opacity="0.12"/>
|
||||||
<rect x="13" y="12" width="14" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.5"/>
|
<rect x="4" y="3" width="26" height="34" rx="3.5" stroke="url(#sd-main)" stroke-width="1.4" fill="none"/>
|
||||||
<rect x="13" y="18" width="10" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.35"/>
|
<!-- SRT-style timing line -->
|
||||||
<rect x="13" y="24" width="12" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.35"/>
|
<rect x="8.5" y="10" width="10" height="2" rx="1" fill="#22d3ee" opacity="0.35"/>
|
||||||
<line x1="38" y1="16" x2="38" y2="32" stroke="url(#sd)" stroke-width="2.5" stroke-linecap="round"/>
|
<rect x="20" y="10" width="3" height="2" rx="1" fill="#22d3ee" opacity="0.25"/>
|
||||||
<path d="M33 28l5 5 5-5" stroke="url(#sd)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
<!-- Subtitle text lines -->
|
||||||
<line x1="33" y1="40" x2="43" y2="40" stroke="url(#sd)" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
|
<rect x="8.5" y="15" width="17" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.6"/>
|
||||||
|
<rect x="8.5" y="20" width="12" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.4"/>
|
||||||
|
<!-- Divider -->
|
||||||
|
<line x1="8.5" y1="25.5" x2="26" y2="25.5" stroke="#22d3ee" stroke-width="0.6" opacity="0.2"/>
|
||||||
|
<!-- Second block timing -->
|
||||||
|
<rect x="8.5" y="28" width="10" height="2" rx="1" fill="#22d3ee" opacity="0.35"/>
|
||||||
|
<!-- Second block text -->
|
||||||
|
<rect x="8.5" y="32.5" width="14" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.4"/>
|
||||||
|
<!-- Download arrow -->
|
||||||
|
<line x1="38" y1="6" x2="38" y2="20" stroke="url(#sd-main)" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<path d="M33 16.5l5 5.5 5-5.5" stroke="url(#sd-main)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
<!-- Sync arrows (circular) -->
|
||||||
|
<path d="M35 35a6 6 0 0 1 8.5-1.5" stroke="url(#sd-sync)" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
||||||
|
<path d="M44.5 35.5l-1-2.8-2.8 1" stroke="url(#sd-sync)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
<path d="M43.5 41a6 6 0 0 1-8.5 1.5" stroke="url(#sd-sync)" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
||||||
|
<path d="M34 40.5l1 2.8 2.8-1" stroke="url(#sd-sync)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,19 +1,46 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="th" x1="4" y1="6" x2="44" y2="42" gradientUnits="userSpaceOnUse">
|
<linearGradient id="th-main" x1="2" y1="6" x2="22" y2="42" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#f97316"/>
|
<stop stop-color="#f97316"/>
|
||||||
<stop offset="1" stop-color="#c2410c"/>
|
<stop offset="1" stop-color="#c2410c"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="th-browser" x1="28" y1="6" x2="46" y2="42" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#fb923c"/>
|
||||||
|
<stop offset="1" stop-color="#ea580c"/>
|
||||||
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="4" y="6" width="30" height="36" rx="4" fill="url(#th)" opacity="0.12"/>
|
<!-- Source panel (subtitle/text source) -->
|
||||||
<rect x="4" y="6" width="30" height="36" rx="4" stroke="url(#th)" stroke-width="1.5" fill="none"/>
|
<rect x="2" y="8" width="18" height="32" rx="3" fill="url(#th-main)" opacity="0.12"/>
|
||||||
<rect x="9" y="14" width="14" height="2.5" rx="1.25" fill="#f97316" opacity="0.6"/>
|
<rect x="2" y="8" width="18" height="32" rx="3" stroke="url(#th-main)" stroke-width="1.3" fill="none"/>
|
||||||
<rect x="9" y="20" width="18" height="2.5" rx="1.25" fill="#f97316" opacity="0.4"/>
|
<!-- Subtitle text lines streaming out -->
|
||||||
<rect x="9" y="26" width="12" height="2.5" rx="1.25" fill="#f97316" opacity="0.4"/>
|
<rect x="5" y="14" width="12" height="2" rx="1" fill="#f97316" opacity="0.6"/>
|
||||||
<rect x="9" y="32" width="16" height="2.5" rx="1.25" fill="#f97316" opacity="0.4"/>
|
<rect x="5" y="19" width="10" height="2" rx="1" fill="#f97316" opacity="0.5"/>
|
||||||
<circle cx="40" cy="18" r="3.5" fill="url(#th)" opacity="0.8"/>
|
<rect x="5" y="24" width="11" height="2" rx="1" fill="#f97316" opacity="0.4"/>
|
||||||
<circle cx="40" cy="30" r="3.5" fill="url(#th)" opacity="0.8"/>
|
<rect x="5" y="29" width="9" height="2" rx="1" fill="#f97316" opacity="0.35"/>
|
||||||
<line x1="36" y1="18" x2="34" y2="18" stroke="url(#th)" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/>
|
<rect x="5" y="34" width="12" height="2" rx="1" fill="#f97316" opacity="0.3"/>
|
||||||
<line x1="36" y1="30" x2="34" y2="30" stroke="url(#th)" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/>
|
<!-- WebSocket stream particles -->
|
||||||
<line x1="40" y1="21.5" x2="40" y2="26.5" stroke="url(#th)" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
|
<circle cx="23" cy="18" r="1.2" fill="#fb923c" opacity="0.7"/>
|
||||||
|
<circle cx="25" cy="24" r="1" fill="#fb923c" opacity="0.5"/>
|
||||||
|
<circle cx="23.5" cy="30" r="1.1" fill="#fb923c" opacity="0.4"/>
|
||||||
|
<!-- Connection line (wavy/flowing) -->
|
||||||
|
<path d="M20 15c2-1 4 2 6 1s3-3 5-2" stroke="#fb923c" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.3"/>
|
||||||
|
<path d="M20 24c2.5 0 3 2 5 1.5s3-2.5 5.5-1.5" stroke="#fb923c" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.25"/>
|
||||||
|
<path d="M20 33c2-1 3.5 1.5 5.5 0.5s3-2 5-1" stroke="#fb923c" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.2"/>
|
||||||
|
<!-- Browser window (destination) -->
|
||||||
|
<rect x="28" y="8" width="18" height="32" rx="3" fill="url(#th-browser)" opacity="0.12"/>
|
||||||
|
<rect x="28" y="8" width="18" height="32" rx="3" stroke="url(#th-browser)" stroke-width="1.3" fill="none"/>
|
||||||
|
<!-- Browser chrome dots -->
|
||||||
|
<circle cx="32" cy="12" r="1.2" fill="#f97316" opacity="0.45"/>
|
||||||
|
<circle cx="35.5" cy="12" r="1.2" fill="#f97316" opacity="0.35"/>
|
||||||
|
<circle cx="39" cy="12" r="1.2" fill="#f97316" opacity="0.25"/>
|
||||||
|
<!-- Browser address bar -->
|
||||||
|
<rect x="31" y="15.5" width="12" height="2.5" rx="1.25" fill="#f97316" opacity="0.15"/>
|
||||||
|
<!-- Received text lines in browser -->
|
||||||
|
<rect x="31" y="21" width="11" height="2" rx="1" fill="#fb923c" opacity="0.55"/>
|
||||||
|
<rect x="31" y="25.5" width="9" height="2" rx="1" fill="#fb923c" opacity="0.45"/>
|
||||||
|
<rect x="31" y="30" width="10" height="2" rx="1" fill="#fb923c" opacity="0.35"/>
|
||||||
|
<rect x="31" y="34.5" width="8" height="2" rx="1" fill="#fb923c" opacity="0.25"/>
|
||||||
|
<!-- WS label -->
|
||||||
|
<rect x="21" y="5" width="8" height="5.5" rx="2.5" fill="#c2410c"/>
|
||||||
|
<text x="25" y="9.2" text-anchor="middle" font-size="3.2" font-weight="800" fill="white" font-family="sans-serif">WS</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -1,16 +1,34 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="tk" x1="0" y1="14" x2="48" y2="34" gradientUnits="userSpaceOnUse">
|
<linearGradient id="tk-bar" x1="0" y1="40" x2="0" y2="10" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#22d3ee"/>
|
<stop stop-color="#0891b2"/>
|
||||||
<stop offset="1" stop-color="#0891b2"/>
|
<stop offset="1" stop-color="#22d3ee"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="tk-glow" x1="4" y1="40" x2="44" y2="10" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#22d3ee" stop-opacity="0.25"/>
|
||||||
|
<stop offset="1" stop-color="#06b6d4" stop-opacity="0"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="2" y="12" width="12" height="24" rx="3.5" fill="url(#tk)"/>
|
<!-- Subtle grid lines -->
|
||||||
<rect x="18" y="12" width="12" height="24" rx="3.5" fill="url(#tk)"/>
|
<line x1="4" y1="14" x2="44" y2="14" stroke="#22d3ee" stroke-width="0.5" opacity="0.12"/>
|
||||||
<rect x="34" y="12" width="12" height="24" rx="3.5" fill="url(#tk)"/>
|
<line x1="4" y1="22" x2="44" y2="22" stroke="#22d3ee" stroke-width="0.5" opacity="0.12"/>
|
||||||
<line x1="15.5" y1="10" x2="15.5" y2="38" stroke="#22d3ee" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="3 3" opacity="0.45"/>
|
<line x1="4" y1="30" x2="44" y2="30" stroke="#22d3ee" stroke-width="0.5" opacity="0.12"/>
|
||||||
<line x1="32.5" y1="10" x2="32.5" y2="38" stroke="#22d3ee" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="3 3" opacity="0.45"/>
|
<!-- Base line -->
|
||||||
<rect x="5" y="22" width="6" height="2.5" rx="1.25" fill="white" opacity="0.7"/>
|
<line x1="4" y1="40" x2="44" y2="40" stroke="#0891b2" stroke-width="1" opacity="0.3"/>
|
||||||
<rect x="21" y="22" width="6" height="2.5" rx="1.25" fill="white" opacity="0.7"/>
|
<!-- Activity bars (daily rollups) -->
|
||||||
<rect x="37" y="22" width="6" height="2.5" rx="1.25" fill="white" opacity="0.7"/>
|
<rect x="5" y="30" width="4" height="10" rx="1.5" fill="url(#tk-bar)" opacity="0.4"/>
|
||||||
|
<rect x="11" y="24" width="4" height="16" rx="1.5" fill="url(#tk-bar)" opacity="0.55"/>
|
||||||
|
<rect x="17" y="28" width="4" height="12" rx="1.5" fill="url(#tk-bar)" opacity="0.5"/>
|
||||||
|
<rect x="23" y="18" width="4" height="22" rx="1.5" fill="url(#tk-bar)" opacity="0.7"/>
|
||||||
|
<rect x="29" y="22" width="4" height="18" rx="1.5" fill="url(#tk-bar)" opacity="0.6"/>
|
||||||
|
<rect x="35" y="14" width="4" height="26" rx="1.5" fill="url(#tk-bar)"/>
|
||||||
|
<rect x="41" y="20" width="4" height="20" rx="1.5" fill="url(#tk-bar)" opacity="0.65"/>
|
||||||
|
<!-- Trend line -->
|
||||||
|
<polyline points="7,28 13,22 19,25.5 25,16 31,20 37,12 43,18" stroke="#67e8f9" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.7"/>
|
||||||
|
<!-- Trend dot on peak -->
|
||||||
|
<circle cx="37" cy="12" r="2.2" fill="#22d3ee" opacity="0.6"/>
|
||||||
|
<circle cx="37" cy="12" r="1" fill="white" opacity="0.9"/>
|
||||||
|
<!-- Mini counter badge -->
|
||||||
|
<rect x="33" y="4" width="12" height="7" rx="3.5" fill="#0891b2"/>
|
||||||
|
<text x="39" y="9" text-anchor="middle" font-size="4" font-weight="700" fill="white" font-family="sans-serif">42d</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -12,13 +12,6 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Visible Overlay Subtitle Binding
|
|
||||||
// Control whether visible overlay toggles also toggle MPV subtitle visibility.
|
|
||||||
// When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.
|
|
||||||
// ==========================================
|
|
||||||
"bind_visible_overlay_to_mpv_sub_visibility": true, // Link visible overlay toggles to MPV subtitle visibility (primary and secondary). Values: true | false
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Texthooker Server
|
// Texthooker Server
|
||||||
// Control whether browser opens automatically for texthooker.
|
// Control whether browser opens automatically for texthooker.
|
||||||
@@ -53,7 +46,6 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
||||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
|
|
||||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
||||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
||||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
||||||
@@ -68,16 +60,6 @@
|
|||||||
"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.
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Invisible Overlay
|
|
||||||
// Startup behavior for the invisible interactive subtitle mining layer.
|
|
||||||
// Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.
|
|
||||||
// This edit-mode shortcut is fixed and is not currently configurable.
|
|
||||||
// ==========================================
|
|
||||||
"invisibleOverlay": {
|
|
||||||
"startupVisibility": "platform-default" // Startup visibility setting.
|
|
||||||
}, // Startup behavior for the invisible interactive subtitle mining layer.
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Keybindings (MPV Commands)
|
// Keybindings (MPV Commands)
|
||||||
// Extra keybindings that are merged with built-in defaults.
|
// Extra keybindings that are merged with built-in defaults.
|
||||||
@@ -125,13 +107,21 @@
|
|||||||
"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
|
||||||
"hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv.
|
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||||
"fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif", // Font family setting.
|
"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.
|
||||||
"fontSize": 35, // Font size setting.
|
"fontSize": 35, // Font size setting.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
"fontColor": "#cad3f5", // Font color setting.
|
||||||
"fontWeight": "normal", // Font weight setting.
|
"fontWeight": "600", // Font weight setting.
|
||||||
|
"lineHeight": 1.35, // Line height setting.
|
||||||
|
"letterSpacing": "-0.01em", // Letter spacing setting.
|
||||||
|
"wordSpacing": 0, // Word spacing setting.
|
||||||
|
"fontKerning": "normal", // Font kerning setting.
|
||||||
|
"textRendering": "geometricPrecision", // Text rendering setting.
|
||||||
|
"textShadow": "0 3px 10px rgba(0,0,0,0.69)", // Text shadow setting.
|
||||||
"fontStyle": "normal", // Font style setting.
|
"fontStyle": "normal", // Font style setting.
|
||||||
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
|
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
|
||||||
|
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
||||||
"knownWordColor": "#a6da95", // Known word color setting.
|
"knownWordColor": "#a6da95", // Known word color setting.
|
||||||
"jlptColors": {
|
"jlptColors": {
|
||||||
@@ -143,7 +133,7 @@
|
|||||||
}, // 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, built-in discovery search paths are used.
|
"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
|
||||||
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
||||||
@@ -156,12 +146,19 @@
|
|||||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
}, // Frequency dictionary setting.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
|
"fontFamily": "Manrope, Inter", // Font family setting.
|
||||||
"fontSize": 24, // Font size setting.
|
"fontSize": 24, // Font size setting.
|
||||||
"fontColor": "#ffffff", // Font color setting.
|
"fontColor": "#cad3f5", // Font color setting.
|
||||||
|
"lineHeight": 1.35, // Line height setting.
|
||||||
|
"letterSpacing": "-0.01em", // Letter spacing setting.
|
||||||
|
"wordSpacing": 0, // Word spacing setting.
|
||||||
|
"fontKerning": "normal", // Font kerning setting.
|
||||||
|
"textRendering": "geometricPrecision", // Text rendering setting.
|
||||||
|
"textShadow": "0 3px 10px rgba(0,0,0,0.69)", // Text shadow setting.
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
"backgroundColor": "transparent", // Background color 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.
|
||||||
"fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif" // Font family setting.
|
|
||||||
} // Secondary setting.
|
} // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
@@ -175,6 +172,12 @@
|
|||||||
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
||||||
"url": "http://127.0.0.1:8765", // Url setting.
|
"url": "http://127.0.0.1:8765", // Url setting.
|
||||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||||
|
"proxy": {
|
||||||
|
"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.
|
||||||
|
"port": 8766, // Bind port for local AnkiConnect proxy.
|
||||||
|
"upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
|
||||||
|
}, // Proxy setting.
|
||||||
"tags": [
|
"tags": [
|
||||||
"SubMiner"
|
"SubMiner"
|
||||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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+I` | Toggle invisible overlay | `shortcuts.toggleInvisibleOverlayGlobal` |
|
|
||||||
| `Alt+Shift+Y` | Open Yomitan settings | Fixed (not configurable) |
|
| `Alt+Shift+Y` | Open Yomitan settings | Fixed (not configurable) |
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
@@ -64,9 +63,9 @@ These keybindings can be overridden or disabled via the `keybindings` config arr
|
|||||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||||
|
|
||||||
## Invisible Subtitle Position Edit Mode
|
## Subtitle Position Edit Mode
|
||||||
|
|
||||||
Enter edit mode to fine-tune invisible overlay alignment with mpv's native subtitles.
|
Enter edit mode to fine-tune subtitle alignment.
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| --------------------- | -------------------------------- |
|
| --------------------- | -------------------------------- |
|
||||||
@@ -86,9 +85,6 @@ When the mpv plugin is installed, all commands use a `y` chord prefix — press
|
|||||||
| `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-i` | Toggle invisible overlay |
|
|
||||||
| `y-I` | Show invisible overlay |
|
|
||||||
| `y-u` | Hide invisible 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 |
|
||||||
@@ -112,7 +108,6 @@ All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electro
|
|||||||
"mineSentence": "CommandOrControl+S",
|
"mineSentence": "CommandOrControl+S",
|
||||||
"copySubtitle": "CommandOrControl+C",
|
"copySubtitle": "CommandOrControl+C",
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
||||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
|
|
||||||
"openJimaku": null, // disabled
|
"openJimaku": null, // disabled
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ SubMiner retries the connection automatically with increasing delays (200 ms, 50
|
|||||||
- first subtitle parse/tokenization bursts
|
- first subtitle parse/tokenization bursts
|
||||||
- media generation (`ffmpeg` audio/image and AVIF paths)
|
- media generation (`ffmpeg` audio/image and AVIF paths)
|
||||||
- media sync and subtitle tooling (`alass`, `ffsubsync`, `whisper` fallback path)
|
- media sync and subtitle tooling (`alass`, `ffsubsync`, `whisper` fallback path)
|
||||||
- `ankiConnect` enrichment and frequent polling
|
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
|
||||||
|
|
||||||
### If playback feels sluggish
|
### If playback feels sluggish
|
||||||
|
|
||||||
@@ -104,11 +104,17 @@ Logged when a malformed JSON line arrives from the mpv socket. Usually harmless
|
|||||||
|
|
||||||
**"AnkiConnect: unable to connect"**
|
**"AnkiConnect: unable to connect"**
|
||||||
|
|
||||||
SubMiner polls AnkiConnect at `http://127.0.0.1:8765` (configurable via `ankiConnect.url`). This error means Anki is not running or the AnkiConnect add-on is not installed.
|
SubMiner connects to the active Anki endpoint:
|
||||||
|
|
||||||
|
- `ankiConnect.url` (direct mode, default `http://127.0.0.1:8765`)
|
||||||
|
- `http://<ankiConnect.proxy.host>:<ankiConnect.proxy.port>` (proxy mode)
|
||||||
|
|
||||||
|
This error means the active endpoint is unavailable, or (in proxy mode) the proxy cannot reach `ankiConnect.proxy.upstreamUrl`.
|
||||||
|
|
||||||
- Install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on in Anki.
|
- Install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on in Anki.
|
||||||
- Make sure Anki is running before you start mining.
|
- Make sure Anki is running before you start mining.
|
||||||
- If you changed the AnkiConnect port, update `ankiConnect.url` in your config.
|
- If you changed the AnkiConnect port, update `ankiConnect.url` (or `ankiConnect.proxy.upstreamUrl` if using proxy mode).
|
||||||
|
- If using external Yomitan/browser clients, confirm they point to your SubMiner proxy URL.
|
||||||
|
|
||||||
SubMiner retries with exponential backoff (up to 5 s) and suppresses repeated error logs after 5 consecutive failures. When Anki comes back, you will see "AnkiConnect connection restored".
|
SubMiner retries with exponential backoff (up to 5 s) and suppresses repeated error logs after 5 consecutive failures. When Anki comes back, you will see "AnkiConnect connection restored".
|
||||||
|
|
||||||
@@ -122,7 +128,7 @@ See [Anki Integration](/anki-integration) for the full field mapping reference.
|
|||||||
|
|
||||||
Shown when SubMiner tries to update a card that no longer exists, or when AnkiConnect rejects the update. Common causes:
|
Shown when SubMiner tries to update a card that no longer exists, or when AnkiConnect rejects the update. Common causes:
|
||||||
|
|
||||||
- The card was deleted in Anki between polling and update.
|
- The card was deleted in Anki between creation and enrichment update.
|
||||||
- The note type changed and a mapped field no longer exists.
|
- The note type changed and a mapped field no longer exists.
|
||||||
|
|
||||||
## Overlay
|
## Overlay
|
||||||
@@ -153,7 +159,7 @@ SubMiner positions the overlay by tracking the mpv window. If tracking fails:
|
|||||||
- Sway: Ensure `swaymsg` is available.
|
- Sway: Ensure `swaymsg` is available.
|
||||||
- X11: Ensure `xdotool` and `xwininfo` are installed.
|
- X11: Ensure `xdotool` and `xwininfo` are installed.
|
||||||
|
|
||||||
If the overlay position is slightly off, use invisible subtitle position edit mode (`Ctrl/Cmd+Shift+P`) to fine-tune the offset with arrow keys, then save with `Enter` or `Ctrl+S`.
|
If the overlay position is slightly off, 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
|
||||||
|
|
||||||
@@ -217,10 +223,10 @@ Media generation has a 30-second timeout (60 seconds for animated AVIF). If your
|
|||||||
|
|
||||||
**"Failed to register global shortcut"**
|
**"Failed to register global shortcut"**
|
||||||
|
|
||||||
Global shortcuts (`Alt+Shift+O`, `Alt+Shift+I`, `Alt+Shift+Y`) may conflict with other applications or desktop environment keybindings.
|
Global shortcuts (`Alt+Shift+O`, `Alt+Shift+Y`) may conflict with other applications or desktop environment keybindings.
|
||||||
|
|
||||||
- Check your DE/WM keybinding settings for conflicts.
|
- Check your DE/WM keybinding settings for conflicts.
|
||||||
- Change the shortcuts in your config under `shortcuts.toggleVisibleOverlayGlobal`, `shortcuts.toggleInvisibleOverlayGlobal`.
|
- Change the shortcut in your config under `shortcuts.toggleVisibleOverlayGlobal`.
|
||||||
- On Wayland, global shortcut registration has limitations depending on the compositor.
|
- On Wayland, global shortcut registration has limitations depending on the compositor.
|
||||||
|
|
||||||
**Overlay keybindings not working**
|
**Overlay keybindings not working**
|
||||||
@@ -273,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 the invisible subtitle offset.
|
- **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`
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ There are two ways to use SubMiner — the `subminer` wrapper script or the mpv
|
|||||||
| Approach | Best For |
|
| Approach | Best For |
|
||||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. Overlay start is explicit (`--start`, `-S`, or `y-s`). |
|
| **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. 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 visible and invisible overlay layers. 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.
|
||||||
|
|
||||||
@@ -68,11 +68,8 @@ SubMiner.AppImage --start --texthooker # Start overlay with texthooker
|
|||||||
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
|
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
|
||||||
SubMiner.AppImage --stop # Stop overlay
|
SubMiner.AppImage --stop # Stop overlay
|
||||||
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
|
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
|
||||||
SubMiner.AppImage --start --toggle-invisible-overlay # Start MPV IPC + toggle invisible layer
|
|
||||||
SubMiner.AppImage --show-visible-overlay # Force show visible overlay
|
SubMiner.AppImage --show-visible-overlay # Force show visible overlay
|
||||||
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
|
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
|
||||||
SubMiner.AppImage --show-invisible-overlay # Force show invisible overlay
|
|
||||||
SubMiner.AppImage --hide-invisible-overlay # Force hide invisible overlay
|
|
||||||
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
||||||
SubMiner.AppImage --start --debug # Alias for --dev
|
SubMiner.AppImage --start --debug # Alias for --dev
|
||||||
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
||||||
@@ -94,6 +91,9 @@ SubMiner.AppImage --help # Show all options
|
|||||||
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
||||||
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop`.
|
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop`.
|
||||||
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
||||||
|
- On Linux, the app now defaults `safeStorage` to `gnome-libsecret` for encrypted token persistence.
|
||||||
|
Launcher pass-through commands also support `--password-store=<backend>` and forward it to the app when present.
|
||||||
|
Override with e.g. `--password-store=basic_text`.
|
||||||
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug`.
|
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug`.
|
||||||
|
|
||||||
### Launcher Subcommands
|
### Launcher Subcommands
|
||||||
@@ -170,7 +170,6 @@ Notes:
|
|||||||
| Keybind | Action |
|
| Keybind | Action |
|
||||||
| ------------- | ------------------------ |
|
| ------------- | ------------------------ |
|
||||||
| `Alt+Shift+O` | Toggle visible overlay |
|
| `Alt+Shift+O` | Toggle visible overlay |
|
||||||
| `Alt+Shift+I` | Toggle invisible 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.
|
||||||
@@ -192,10 +191,10 @@ 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 invisible subtitle position edit mode |
|
| `Ctrl/Cmd+Shift+P` | Toggle subtitle position edit mode |
|
||||||
| `Arrow keys` | Move invisible subtitles while edit mode is active |
|
| `Arrow keys` | Move subtitles while edit mode is active |
|
||||||
| `Enter` / `Ctrl+S` | Save invisible subtitle position in edit mode |
|
| `Enter` / `Ctrl+S` | Save subtitle position in edit mode |
|
||||||
| `Esc` | Cancel invisible subtitle position 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.
|
||||||
|
|||||||
@@ -10,9 +10,16 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appendPasswordStore = (forwarded: string[]): void => {
|
||||||
|
if (args.passwordStore) {
|
||||||
|
forwarded.push('--password-store', args.passwordStore);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (args.jellyfin) {
|
if (args.jellyfin) {
|
||||||
const forwarded = ['--jellyfin'];
|
const forwarded = ['--jellyfin'];
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,12 +42,14 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
password,
|
password,
|
||||||
];
|
];
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.jellyfinLogout) {
|
if (args.jellyfinLogout) {
|
||||||
const forwarded = ['--jellyfin-logout'];
|
const forwarded = ['--jellyfin-logout'];
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +67,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
if (args.jellyfinDiscovery) {
|
if (args.jellyfinDiscovery) {
|
||||||
const forwarded = ['--start'];
|
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);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,15 +194,26 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
if (!state.mpvProc) {
|
const mpvProc = state.mpvProc;
|
||||||
|
if (!mpvProc) {
|
||||||
stopOverlay(args);
|
stopOverlay(args);
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.mpvProc.on('exit', (code) => {
|
|
||||||
|
const finalize = (code: number | null | undefined) => {
|
||||||
stopOverlay(args);
|
stopOverlay(args);
|
||||||
processAdapter.setExitCode(code ?? 0);
|
processAdapter.setExitCode(code ?? 0);
|
||||||
resolve();
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mpvProc.exitCode !== null && mpvProc.exitCode !== undefined) {
|
||||||
|
finalize(mpvProc.exitCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mpvProc.once('exit', (code) => {
|
||||||
|
finalize(code);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
|||||||
texthookerOnly: false,
|
texthookerOnly: false,
|
||||||
useRofi: false,
|
useRofi: false,
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
|
passwordStore: '',
|
||||||
target: '',
|
target: '',
|
||||||
targetKind: '',
|
targetKind: '',
|
||||||
};
|
};
|
||||||
@@ -161,6 +162,7 @@ export function applyRootOptionsToArgs(
|
|||||||
if (typeof options.profile === 'string') parsed.profile = options.profile;
|
if (typeof options.profile === 'string') parsed.profile = options.profile;
|
||||||
if (options.start === true) parsed.startOverlay = true;
|
if (options.start === true) parsed.startOverlay = true;
|
||||||
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
||||||
|
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
||||||
if (options.rofi === true) parsed.useRofi = true;
|
if (options.rofi === true) parsed.useRofi = true;
|
||||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||||
@@ -175,6 +177,9 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
|||||||
if (invocations.jellyfinInvocation.logLevel) {
|
if (invocations.jellyfinInvocation.logLevel) {
|
||||||
parsed.logLevel = parseLogLevel(invocations.jellyfinInvocation.logLevel);
|
parsed.logLevel = parseLogLevel(invocations.jellyfinInvocation.logLevel);
|
||||||
}
|
}
|
||||||
|
if (typeof invocations.jellyfinInvocation.passwordStore === 'string') {
|
||||||
|
parsed.passwordStore = invocations.jellyfinInvocation.passwordStore;
|
||||||
|
}
|
||||||
const action = (invocations.jellyfinInvocation.action || '').toLowerCase();
|
const action = (invocations.jellyfinInvocation.action || '').toLowerCase();
|
||||||
if (action && !['setup', 'discovery', 'play', 'login', 'logout'].includes(action)) {
|
if (action && !['setup', 'discovery', 'play', 'login', 'logout'].includes(action)) {
|
||||||
fail(`Unknown jellyfin action: ${invocations.jellyfinInvocation.action}`);
|
fail(`Unknown jellyfin action: ${invocations.jellyfinInvocation.action}`);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface JellyfinInvocation {
|
|||||||
server?: string;
|
server?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
passwordStore?: string;
|
||||||
logLevel?: string;
|
logLevel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +169,7 @@ export function parseCliPrograms(
|
|||||||
.option('-s, --server <url>', 'Jellyfin server URL')
|
.option('-s, --server <url>', 'Jellyfin server URL')
|
||||||
.option('-u, --username <name>', 'Jellyfin username')
|
.option('-u, --username <name>', 'Jellyfin username')
|
||||||
.option('-w, --password <pass>', 'Jellyfin password')
|
.option('-w, --password <pass>', 'Jellyfin password')
|
||||||
|
.option('--password-store <backend>', 'Pass through Electron safeStorage backend')
|
||||||
.option('--log-level <level>', 'Log level')
|
.option('--log-level <level>', 'Log level')
|
||||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||||
jellyfinInvocation = {
|
jellyfinInvocation = {
|
||||||
@@ -180,6 +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: typeof options.passwordStore === 'string' ? options.passwordStore : undefined,
|
||||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -395,5 +395,6 @@ export async function runJellyfinPlayMenu(
|
|||||||
}
|
}
|
||||||
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);
|
||||||
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,3 +207,33 @@ test('jellyfin login routes credentials to app command', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('jellyfin setup forwards password-store to app command', () => {
|
||||||
|
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(
|
||||||
|
['jf', 'setup', '--password-store', 'gnome-libsecret'],
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(capturePath, 'utf8'),
|
||||||
|
'--jellyfin\n--password-store\ngnome-libsecret\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
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 { waitForUnixSocketReady } from './mpv';
|
import type { Args } from './types';
|
||||||
|
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 } {
|
||||||
@@ -59,3 +60,82 @@ test('waitForUnixSocketReady returns true when socket becomes connectable before
|
|||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||||
|
return {
|
||||||
|
backend: 'x11',
|
||||||
|
directory: '.',
|
||||||
|
recursive: false,
|
||||||
|
profile: '',
|
||||||
|
startOverlay: false,
|
||||||
|
youtubeSubgenMode: 'off',
|
||||||
|
whisperBin: '',
|
||||||
|
whisperModel: '',
|
||||||
|
youtubeSubgenOutDir: '',
|
||||||
|
youtubeSubgenAudioFormat: 'wav',
|
||||||
|
youtubeSubgenKeepTemp: false,
|
||||||
|
youtubePrimarySubLangs: [],
|
||||||
|
youtubeSecondarySubLangs: [],
|
||||||
|
youtubeAudioLangs: [],
|
||||||
|
youtubeWhisperSourceLanguage: 'ja',
|
||||||
|
useTexthooker: false,
|
||||||
|
autoStartOverlay: false,
|
||||||
|
texthookerOnly: false,
|
||||||
|
useRofi: false,
|
||||||
|
logLevel: 'error',
|
||||||
|
passwordStore: '',
|
||||||
|
target: '',
|
||||||
|
targetKind: '',
|
||||||
|
jimakuApiKey: '',
|
||||||
|
jimakuApiKeyCommand: '',
|
||||||
|
jimakuApiBaseUrl: '',
|
||||||
|
jimakuLanguagePreference: 'none',
|
||||||
|
jimakuMaxEntryResults: 10,
|
||||||
|
jellyfin: false,
|
||||||
|
jellyfinLogin: false,
|
||||||
|
jellyfinLogout: false,
|
||||||
|
jellyfinPlay: false,
|
||||||
|
jellyfinDiscovery: false,
|
||||||
|
doctor: false,
|
||||||
|
configPath: false,
|
||||||
|
configShow: false,
|
||||||
|
mpvIdle: false,
|
||||||
|
mpvSocket: false,
|
||||||
|
mpvStatus: false,
|
||||||
|
appPassthrough: false,
|
||||||
|
appArgs: [],
|
||||||
|
jellyfinServer: '',
|
||||||
|
jellyfinUsername: '',
|
||||||
|
jellyfinPassword: '',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('startOverlay resolves without fixed 2s sleep when readiness signals arrive quickly', async () => {
|
||||||
|
const { dir, socketPath } = createTempSocketPath();
|
||||||
|
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||||
|
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
fs.writeFileSync(socketPath, '');
|
||||||
|
const originalCreateConnection = net.createConnection;
|
||||||
|
try {
|
||||||
|
net.createConnection = (() => {
|
||||||
|
const socket = new EventEmitter() as net.Socket;
|
||||||
|
socket.destroy = (() => socket) as net.Socket['destroy'];
|
||||||
|
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
|
||||||
|
setTimeout(() => socket.emit('connect'), 10);
|
||||||
|
return socket;
|
||||||
|
}) as typeof net.createConnection;
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await startOverlay(appPath, makeArgs(), socketPath);
|
||||||
|
const elapsedMs = Date.now() - startedAt;
|
||||||
|
|
||||||
|
assert.ok(elapsedMs < 1200, `expected startOverlay <1200ms, got ${elapsedMs}ms`);
|
||||||
|
} finally {
|
||||||
|
net.createConnection = originalCreateConnection;
|
||||||
|
state.overlayProc = null;
|
||||||
|
state.overlayManagedByLauncher = false;
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export const state = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
|
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
|
||||||
|
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
|
||||||
|
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
|
||||||
|
|
||||||
function readTrackedDetachedMpvPid(): number | null {
|
function readTrackedDetachedMpvPid(): number | null {
|
||||||
try {
|
try {
|
||||||
@@ -498,7 +500,47 @@ export function startMpv(
|
|||||||
state.mpvProc = spawn('mpv', mpvArgs, { stdio: 'inherit' });
|
state.mpvProc = spawn('mpv', mpvArgs, { stdio: 'inherit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startOverlay(appPath: string, args: Args, socketPath: string): Promise<void> {
|
async function waitForOverlayStartCommandSettled(
|
||||||
|
proc: ReturnType<typeof spawn>,
|
||||||
|
logLevel: LogLevel,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
proc.off('exit', onExit);
|
||||||
|
proc.off('error', onError);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onExit = (code: number | null) => {
|
||||||
|
if (typeof code === 'number' && code !== 0) {
|
||||||
|
log('warn', logLevel, `Overlay start command exited with status ${code}`);
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (error: Error) => {
|
||||||
|
log('warn', logLevel, `Overlay start command failed: ${error.message}`);
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
|
||||||
|
proc.once('exit', onExit);
|
||||||
|
proc.once('error', onError);
|
||||||
|
timer = setTimeout(finish, timeoutMs);
|
||||||
|
|
||||||
|
if (proc.exitCode !== null && proc.exitCode !== undefined) {
|
||||||
|
onExit(proc.exitCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startOverlay(appPath: string, args: Args, socketPath: string): Promise<void> {
|
||||||
const backend = detectBackend(args.backend);
|
const backend = detectBackend(args.backend);
|
||||||
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
|
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
|
||||||
|
|
||||||
@@ -512,9 +554,22 @@ export function startOverlay(appPath: string, args: Args, socketPath: string): P
|
|||||||
});
|
});
|
||||||
state.overlayManagedByLauncher = true;
|
state.overlayManagedByLauncher = true;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
const [socketReady] = await Promise.all([
|
||||||
setTimeout(resolve, 2000);
|
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
|
||||||
});
|
waitForOverlayStartCommandSettled(
|
||||||
|
state.overlayProc,
|
||||||
|
args.logLevel,
|
||||||
|
OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!socketReady) {
|
||||||
|
log(
|
||||||
|
'debug',
|
||||||
|
args.logLevel,
|
||||||
|
'Overlay start continuing before mpv socket readiness was confirmed',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function launchTexthookerOnly(appPath: string, args: Args): never {
|
export function launchTexthookerOnly(appPath: string, args: Args): never {
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ test('parseArgs maps jellyfin play action and log-level override', () => {
|
|||||||
assert.equal(parsed.logLevel, 'debug');
|
assert.equal(parsed.logLevel, 'debug');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseArgs forwards jellyfin password-store option', () => {
|
||||||
|
const parsed = parseArgs(
|
||||||
|
['jf', 'setup', '--password-store', 'gnome-libsecret'],
|
||||||
|
'subminer',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(parsed.jellyfin, true);
|
||||||
|
assert.equal(parsed.passwordStore, 'gnome-libsecret');
|
||||||
|
});
|
||||||
|
|
||||||
test('parseArgs maps config show action', () => {
|
test('parseArgs maps config show action', () => {
|
||||||
const parsed = parseArgs(['config', 'show'], 'subminer', {});
|
const parsed = parseArgs(['config', 'show'], 'subminer', {});
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function createSmokeCase(name: string): SmokeCase {
|
|||||||
|
|
||||||
writeExecutable(
|
writeExecutable(
|
||||||
fakeMpvPath,
|
fakeMpvPath,
|
||||||
`#!/usr/bin/env bun
|
`#!/usr/bin/env node
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const net = require('node:net');
|
const net = require('node:net');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
@@ -101,7 +101,7 @@ process.on('SIGTERM', closeAndExit);
|
|||||||
|
|
||||||
writeExecutable(
|
writeExecutable(
|
||||||
fakeAppPath,
|
fakeAppPath,
|
||||||
`#!/usr/bin/env bun
|
`#!/usr/bin/env node
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
|
|
||||||
const logPath = ${JSON.stringify(fakeAppLogPath)};
|
const logPath = ${JSON.stringify(fakeAppLogPath)};
|
||||||
@@ -237,8 +237,20 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
|
|||||||
env,
|
env,
|
||||||
'mpv-status',
|
'mpv-status',
|
||||||
);
|
);
|
||||||
|
const fakeMpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||||
|
const fakeMpvError = fakeMpvEntries.find(
|
||||||
|
(entry): entry is { error: string } => typeof entry.error === 'string',
|
||||||
|
)?.error;
|
||||||
|
const unixSocketDenied =
|
||||||
|
typeof fakeMpvError === 'string' && /eperm|operation not permitted/i.test(fakeMpvError);
|
||||||
|
|
||||||
|
if (unixSocketDenied) {
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.stdout, /socket not ready/i);
|
||||||
|
} else {
|
||||||
assert.equal(result.status, 0);
|
assert.equal(result.status, 0);
|
||||||
assert.match(result.stdout, /socket ready/i);
|
assert.match(result.stdout, /socket ready/i);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (fakeMpv.exitCode === null) {
|
if (fakeMpv.exitCode === null) {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
@@ -262,9 +274,6 @@ test(
|
|||||||
'overlay-start-stop',
|
'overlay-start-stop',
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(result.status, 0);
|
|
||||||
assert.match(result.stdout, /Starting SubMiner overlay/i);
|
|
||||||
|
|
||||||
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
||||||
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
||||||
await waitForJsonLines(appStartPath, 1);
|
await waitForJsonLines(appStartPath, 1);
|
||||||
@@ -273,6 +282,14 @@ test(
|
|||||||
const appStartEntries = readJsonLines(appStartPath);
|
const appStartEntries = readJsonLines(appStartPath);
|
||||||
const appStopEntries = readJsonLines(appStopPath);
|
const appStopEntries = readJsonLines(appStopPath);
|
||||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
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);
|
||||||
|
|
||||||
|
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||||
|
assert.match(result.stdout, /Starting SubMiner overlay/i);
|
||||||
|
|
||||||
assert.equal(appStartEntries.length, 1);
|
assert.equal(appStartEntries.length, 1);
|
||||||
assert.equal(appStopEntries.length, 1);
|
assert.equal(appStopEntries.length, 1);
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export interface Args {
|
|||||||
texthookerOnly: boolean;
|
texthookerOnly: boolean;
|
||||||
useRofi: boolean;
|
useRofi: boolean;
|
||||||
logLevel: LogLevel;
|
logLevel: LogLevel;
|
||||||
|
passwordStore: string;
|
||||||
target: string;
|
target: string;
|
||||||
targetKind: '' | 'file' | 'url';
|
targetKind: '' | 'file' | 'url';
|
||||||
jimakuApiKey: string;
|
jimakuApiKey: string;
|
||||||
|
|||||||
13
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.1.1",
|
"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",
|
||||||
@@ -19,10 +19,11 @@
|
|||||||
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts",
|
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts",
|
||||||
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js",
|
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js",
|
||||||
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
||||||
|
"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",
|
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||||
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/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/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
|
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
|
||||||
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/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/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\"",
|
||||||
@@ -118,10 +119,6 @@
|
|||||||
"from": "vendor/yomitan-jlpt-vocab",
|
"from": "vendor/yomitan-jlpt-vocab",
|
||||||
"to": "yomitan-jlpt-vocab"
|
"to": "yomitan-jlpt-vocab"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"from": "vendor/jiten_freq_global",
|
|
||||||
"to": "jiten_freq_global"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"from": "assets",
|
"from": "assets",
|
||||||
"to": "assets"
|
"to": "assets"
|
||||||
|
|||||||
@@ -21,18 +21,12 @@ texthooker_port=5174
|
|||||||
backend=auto
|
backend=auto
|
||||||
|
|
||||||
# Automatically start overlay when a file is loaded
|
# Automatically start overlay when a file is loaded
|
||||||
auto_start=no
|
# Runs only when mpv input-ipc-server matches socket_path.
|
||||||
|
auto_start=yes
|
||||||
|
|
||||||
# Automatically show visible overlay when overlay starts
|
# Automatically show visible overlay when overlay starts
|
||||||
auto_start_visible_overlay=no
|
# Runs only when mpv input-ipc-server matches socket_path.
|
||||||
|
auto_start_visible_overlay=yes
|
||||||
# Automatically show invisible overlay when overlay starts
|
|
||||||
# Values: platform-default, visible, hidden
|
|
||||||
# platform-default => hidden on Linux, visible on macOS/Windows
|
|
||||||
auto_start_invisible_overlay=platform-default
|
|
||||||
|
|
||||||
# Legacy alias (maps to auto_start_visible_overlay)
|
|
||||||
# auto_start_overlay=no
|
|
||||||
|
|
||||||
# Show OSD messages for overlay status
|
# Show OSD messages for overlay status
|
||||||
osd_messages=yes
|
osd_messages=yes
|
||||||
@@ -68,6 +62,5 @@ aniskip_button_key=y-k
|
|||||||
# OSD hint duration in seconds (shown during first 3s of intro).
|
# OSD hint duration in seconds (shown during first 3s of intro).
|
||||||
aniskip_button_duration=3
|
aniskip_button_duration=3
|
||||||
|
|
||||||
# MPV keybindings provided by plugin/subminer.lua:
|
# MPV keybindings provided by plugin/subminer/main.lua:
|
||||||
# y-s start, y-S stop, y-t toggle visible overlay
|
# y-s start, y-S stop, y-t toggle visible overlay
|
||||||
# y-i toggle invisible overlay, y-I show invisible overlay, y-u hide invisible overlay
|
|
||||||
|
|||||||
1959
plugin/subminer.lua
577
plugin/subminer/aniskip.lua
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
local M = {}
|
||||||
|
local matcher = require("aniskip_match")
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local utils = ctx.utils
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local environment = ctx.environment
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
local request_generation = 0
|
||||||
|
local mal_lookup_cache = {}
|
||||||
|
local payload_cache = {}
|
||||||
|
local title_context_cache = {}
|
||||||
|
|
||||||
|
local function url_encode(text)
|
||||||
|
if type(text) ~= "string" then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
local encoded = text:gsub("\n", " ")
|
||||||
|
encoded = encoded:gsub("([^%w%-_%.~ ])", function(char)
|
||||||
|
return string.format("%%%02X", string.byte(char))
|
||||||
|
end)
|
||||||
|
return encoded:gsub(" ", "%%20")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function run_json_curl_async(url, callback)
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url },
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
if not success or not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then
|
||||||
|
local detail = error or (result and result.stderr) or "curl failed"
|
||||||
|
callback(nil, detail)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local parsed, parse_error = utils.parse_json(result.stdout)
|
||||||
|
if type(parsed) ~= "table" then
|
||||||
|
callback(nil, parse_error or "invalid json")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
callback(parsed, nil)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_episode_hint(text)
|
||||||
|
if type(text) ~= "string" or text == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local patterns = {
|
||||||
|
"[Ss]%d+[Ee](%d+)",
|
||||||
|
"[Ee][Pp]?[%s%._%-]*(%d+)",
|
||||||
|
"[%s%._%-]+(%d+)[%s%._%-]+",
|
||||||
|
}
|
||||||
|
for _, pattern in ipairs(patterns) do
|
||||||
|
local token = text:match(pattern)
|
||||||
|
if token then
|
||||||
|
local episode = tonumber(token)
|
||||||
|
if episode and episode > 0 and episode < 10000 then
|
||||||
|
return episode
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function cleanup_title(raw)
|
||||||
|
if type(raw) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local cleaned = raw
|
||||||
|
cleaned = cleaned:gsub("%b[]", " ")
|
||||||
|
cleaned = cleaned:gsub("%b()", " ")
|
||||||
|
cleaned = cleaned:gsub("[Ss]%d+[Ee]%d+", " ")
|
||||||
|
cleaned = cleaned:gsub("[Ee][Pp]?[%s%._%-]*%d+", " ")
|
||||||
|
cleaned = cleaned:gsub("[%._%-]+", " ")
|
||||||
|
cleaned = cleaned:gsub("%s+", " ")
|
||||||
|
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
|
||||||
|
if cleaned == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return cleaned
|
||||||
|
end
|
||||||
|
|
||||||
|
local function extract_show_title_from_path(media_path)
|
||||||
|
if type(media_path) ~= "string" or media_path == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local normalized = media_path:gsub("\\", "/")
|
||||||
|
local segments = {}
|
||||||
|
for segment in normalized:gmatch("[^/]+") do
|
||||||
|
segments[#segments + 1] = segment
|
||||||
|
end
|
||||||
|
for index = 1, #segments do
|
||||||
|
local segment = segments[index] or ""
|
||||||
|
if segment:match("^[Ss]eason[%s%._%-]*%d+$") or segment:match("^[Ss][%s%._%-]*%d+$") then
|
||||||
|
local prior = segments[index - 1]
|
||||||
|
local cleaned = cleanup_title(prior or "")
|
||||||
|
if cleaned and cleaned ~= "" then
|
||||||
|
return cleaned
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_title_and_episode()
|
||||||
|
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||||
|
local forced_season = tonumber(opts.aniskip_season)
|
||||||
|
local forced_episode = tonumber(opts.aniskip_episode)
|
||||||
|
local media_title = mp.get_property("media-title")
|
||||||
|
local filename = mp.get_property("filename/no-ext") or mp.get_property("filename") or ""
|
||||||
|
local path = mp.get_property("path") or ""
|
||||||
|
local cache_key = table.concat({
|
||||||
|
tostring(forced_title or ""),
|
||||||
|
tostring(forced_season or ""),
|
||||||
|
tostring(forced_episode or ""),
|
||||||
|
tostring(media_title or ""),
|
||||||
|
tostring(filename or ""),
|
||||||
|
tostring(path or ""),
|
||||||
|
}, "\31")
|
||||||
|
local cached = title_context_cache[cache_key]
|
||||||
|
if type(cached) == "table" then
|
||||||
|
return cached.title, cached.episode, cached.season
|
||||||
|
end
|
||||||
|
local path_show_title = extract_show_title_from_path(path)
|
||||||
|
local candidate_title = nil
|
||||||
|
if path_show_title and path_show_title ~= "" then
|
||||||
|
candidate_title = path_show_title
|
||||||
|
elseif forced_title ~= "" then
|
||||||
|
candidate_title = forced_title
|
||||||
|
else
|
||||||
|
candidate_title = cleanup_title(media_title) or cleanup_title(filename) or cleanup_title(path)
|
||||||
|
end
|
||||||
|
local episode = forced_episode or parse_episode_hint(media_title) or parse_episode_hint(filename) or parse_episode_hint(path) or 1
|
||||||
|
title_context_cache[cache_key] = {
|
||||||
|
title = candidate_title,
|
||||||
|
episode = episode,
|
||||||
|
season = forced_season,
|
||||||
|
}
|
||||||
|
return candidate_title, episode, forced_season
|
||||||
|
end
|
||||||
|
|
||||||
|
local function select_best_mal_item(items, title, season)
|
||||||
|
if type(items) ~= "table" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local best_item = nil
|
||||||
|
local best_score = -math.huge
|
||||||
|
for _, item in ipairs(items) do
|
||||||
|
if type(item) == "table" and tonumber(item.id) then
|
||||||
|
local candidate_name = tostring(item.name or "")
|
||||||
|
local score = matcher.title_overlap_score(title, candidate_name) + matcher.season_signal_score(season, candidate_name)
|
||||||
|
if score > best_score then
|
||||||
|
best_score = score
|
||||||
|
best_item = item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return best_item
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_mal_id_async(title, season, request_id, callback)
|
||||||
|
local forced_mal_id = tonumber(opts.aniskip_mal_id)
|
||||||
|
if forced_mal_id and forced_mal_id > 0 then
|
||||||
|
callback(forced_mal_id, "(forced-mal-id)")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(title) == "string" and title:match("^%d+$") then
|
||||||
|
local numeric = tonumber(title)
|
||||||
|
if numeric and numeric > 0 then
|
||||||
|
callback(numeric, title)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if type(title) ~= "string" or title == "" then
|
||||||
|
callback(nil, nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local lookup = title
|
||||||
|
if season and season > 1 then
|
||||||
|
lookup = string.format("%s Season %d", lookup, season)
|
||||||
|
end
|
||||||
|
local cache_key = string.format("%s|%s", lookup:lower(), tostring(season or "-"))
|
||||||
|
local cached = mal_lookup_cache[cache_key]
|
||||||
|
if cached ~= nil then
|
||||||
|
if cached == false then
|
||||||
|
callback(nil, lookup)
|
||||||
|
else
|
||||||
|
callback(cached, lookup)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup)
|
||||||
|
run_json_curl_async(mal_url, function(mal_json, mal_error)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not mal_json then
|
||||||
|
subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error))
|
||||||
|
callback(nil, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local categories = mal_json.categories
|
||||||
|
if type(categories) ~= "table" then
|
||||||
|
mal_lookup_cache[cache_key] = false
|
||||||
|
callback(nil, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local all_items = {}
|
||||||
|
for _, category in ipairs(categories) do
|
||||||
|
if type(category) == "table" and type(category.items) == "table" then
|
||||||
|
for _, item in ipairs(category.items) do
|
||||||
|
all_items[#all_items + 1] = item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local best_item = select_best_mal_item(all_items, title, season)
|
||||||
|
if best_item and tonumber(best_item.id) then
|
||||||
|
local matched_id = tonumber(best_item.id)
|
||||||
|
mal_lookup_cache[cache_key] = matched_id
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
'MAL candidate selected (score-based): id=%s name="%s" season_hint=%s',
|
||||||
|
tostring(best_item.id),
|
||||||
|
tostring(best_item.name or ""),
|
||||||
|
tostring(season or "-")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
callback(matched_id, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
mal_lookup_cache[cache_key] = false
|
||||||
|
callback(nil, lookup)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_intro_chapters(intro_start, intro_end)
|
||||||
|
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local current = mp.get_property_native("chapter-list")
|
||||||
|
local chapters = {}
|
||||||
|
if type(current) == "table" then
|
||||||
|
for _, chapter in ipairs(current) do
|
||||||
|
local title = type(chapter) == "table" and chapter.title or nil
|
||||||
|
if type(title) ~= "string" or not title:match("^AniSkip ") then
|
||||||
|
chapters[#chapters + 1] = chapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
chapters[#chapters + 1] = { time = intro_start, title = "AniSkip Intro Start" }
|
||||||
|
chapters[#chapters + 1] = { time = intro_end, title = "AniSkip Intro End" }
|
||||||
|
table.sort(chapters, function(a, b)
|
||||||
|
local a_time = type(a) == "table" and tonumber(a.time) or 0
|
||||||
|
local b_time = type(b) == "table" and tonumber(b.time) or 0
|
||||||
|
return a_time < b_time
|
||||||
|
end)
|
||||||
|
mp.set_property_native("chapter-list", chapters)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function remove_aniskip_chapters()
|
||||||
|
local current = mp.get_property_native("chapter-list")
|
||||||
|
if type(current) ~= "table" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local chapters = {}
|
||||||
|
local changed = false
|
||||||
|
for _, chapter in ipairs(current) do
|
||||||
|
local title = type(chapter) == "table" and chapter.title or nil
|
||||||
|
if type(title) == "string" and title:match("^AniSkip ") then
|
||||||
|
changed = true
|
||||||
|
else
|
||||||
|
chapters[#chapters + 1] = chapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if changed then
|
||||||
|
mp.set_property_native("chapter-list", chapters)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function reset_aniskip_fields()
|
||||||
|
state.aniskip.prompt_shown = false
|
||||||
|
state.aniskip.found = false
|
||||||
|
state.aniskip.mal_id = nil
|
||||||
|
state.aniskip.title = nil
|
||||||
|
state.aniskip.episode = nil
|
||||||
|
state.aniskip.intro_start = nil
|
||||||
|
state.aniskip.intro_end = nil
|
||||||
|
remove_aniskip_chapters()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clear_aniskip_state()
|
||||||
|
request_generation = request_generation + 1
|
||||||
|
reset_aniskip_fields()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function skip_intro_now()
|
||||||
|
if not state.aniskip.found then
|
||||||
|
show_osd("Intro skip unavailable")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local intro_start = state.aniskip.intro_start
|
||||||
|
local intro_end = state.aniskip.intro_end
|
||||||
|
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
|
||||||
|
show_osd("Intro markers missing")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local now = mp.get_property_number("time-pos")
|
||||||
|
if type(now) ~= "number" then
|
||||||
|
show_osd("Skip unavailable")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local epsilon = 0.35
|
||||||
|
if now < (intro_start - epsilon) or now > (intro_end + epsilon) then
|
||||||
|
show_osd("Skip intro only during intro")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
mp.set_property_number("time-pos", intro_end)
|
||||||
|
show_osd("Skipped intro")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function update_intro_button_visibility()
|
||||||
|
if not opts.aniskip_enabled or not opts.aniskip_show_button or not state.aniskip.found then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local now = mp.get_property_number("time-pos")
|
||||||
|
if type(now) ~= "number" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local in_intro = now >= (state.aniskip.intro_start or -1) and now < (state.aniskip.intro_end or -1)
|
||||||
|
local intro_start = state.aniskip.intro_start or -1
|
||||||
|
local hint_window_end = intro_start + 3
|
||||||
|
if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then
|
||||||
|
local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or "y-k"
|
||||||
|
local message = string.format(opts.aniskip_button_text, key)
|
||||||
|
mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3)
|
||||||
|
state.aniskip.prompt_shown = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function apply_aniskip_payload(mal_id, title, episode, payload)
|
||||||
|
local results = payload and payload.results
|
||||||
|
if type(results) ~= "table" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
for _, item in ipairs(results) do
|
||||||
|
if type(item) == "table" and item.skip_type == "op" and type(item.interval) == "table" then
|
||||||
|
local intro_start = tonumber(item.interval.start_time)
|
||||||
|
local intro_end = tonumber(item.interval.end_time)
|
||||||
|
if intro_start and intro_end and intro_end > intro_start then
|
||||||
|
state.aniskip.found = true
|
||||||
|
state.aniskip.mal_id = mal_id
|
||||||
|
state.aniskip.title = title
|
||||||
|
state.aniskip.episode = episode
|
||||||
|
state.aniskip.intro_start = intro_start
|
||||||
|
state.aniskip.intro_end = intro_end
|
||||||
|
state.aniskip.prompt_shown = false
|
||||||
|
set_intro_chapters(intro_start, intro_end)
|
||||||
|
subminer_log("info", "aniskip", string.format("Intro window %.3f -> %.3f (MAL %d, ep %d)", intro_start, intro_end, mal_id, episode))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_launcher_context()
|
||||||
|
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||||
|
if forced_title ~= "" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local forced_mal_id = tonumber(opts.aniskip_mal_id)
|
||||||
|
if forced_mal_id and forced_mal_id > 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local forced_episode = tonumber(opts.aniskip_episode)
|
||||||
|
if forced_episode and forced_episode > 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local forced_season = tonumber(opts.aniskip_season)
|
||||||
|
if forced_season and forced_season > 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function should_fetch_aniskip_async(trigger_source, callback)
|
||||||
|
if trigger_source == "script-message" or trigger_source == "overlay-start" then
|
||||||
|
callback(true, trigger_source)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if is_launcher_context() then
|
||||||
|
callback(true, "launcher-context")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(environment.is_subminer_app_running_async) == "function" then
|
||||||
|
environment.is_subminer_app_running_async(function(running)
|
||||||
|
if running then
|
||||||
|
callback(true, "subminer-app-running")
|
||||||
|
else
|
||||||
|
callback(false, "subminer-context-missing")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if environment.is_subminer_app_running() then
|
||||||
|
callback(true, "subminer-app-running")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
callback(false, "subminer-context-missing")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_lookup_titles(primary_title)
|
||||||
|
local media_title_fallback = cleanup_title(mp.get_property("media-title"))
|
||||||
|
local filename_fallback = cleanup_title(mp.get_property("filename/no-ext") or mp.get_property("filename") or "")
|
||||||
|
local path_fallback = cleanup_title(mp.get_property("path") or "")
|
||||||
|
local lookup_titles = {}
|
||||||
|
local seen_titles = {}
|
||||||
|
local function push_lookup_title(candidate)
|
||||||
|
if type(candidate) ~= "string" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local trimmed = candidate:match("^%s*(.-)%s*$") or ""
|
||||||
|
if trimmed == "" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local key = trimmed:lower()
|
||||||
|
if seen_titles[key] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
seen_titles[key] = true
|
||||||
|
lookup_titles[#lookup_titles + 1] = trimmed
|
||||||
|
end
|
||||||
|
push_lookup_title(primary_title)
|
||||||
|
push_lookup_title(media_title_fallback)
|
||||||
|
push_lookup_title(filename_fallback)
|
||||||
|
push_lookup_title(path_fallback)
|
||||||
|
return lookup_titles
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, index, last_lookup)
|
||||||
|
local current_index = index or 1
|
||||||
|
local current_lookup = last_lookup
|
||||||
|
if current_index > #lookup_titles then
|
||||||
|
callback(nil, current_lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local lookup_title = lookup_titles[current_index]
|
||||||
|
subminer_log("info", "aniskip", string.format('MAL lookup attempt %d/%d using title="%s"', current_index, #lookup_titles, lookup_title))
|
||||||
|
resolve_mal_id_async(lookup_title, season, request_id, function(mal_id, lookup)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if mal_id then
|
||||||
|
callback(mal_id, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, current_index + 1, lookup or current_lookup)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fetch_payload_for_episode_async(mal_id, episode, request_id, callback)
|
||||||
|
local payload_cache_key = string.format("%d:%d", mal_id, episode)
|
||||||
|
local cached_payload = payload_cache[payload_cache_key]
|
||||||
|
if cached_payload ~= nil then
|
||||||
|
if cached_payload == false then
|
||||||
|
callback(nil, nil, true)
|
||||||
|
else
|
||||||
|
callback(cached_payload, nil, true)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local url = string.format("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", mal_id, episode)
|
||||||
|
subminer_log("info", "aniskip", string.format("AniSkip URL=%s", url))
|
||||||
|
run_json_curl_async(url, function(payload, fetch_error)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not payload then
|
||||||
|
callback(nil, fetch_error, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if payload.found ~= true then
|
||||||
|
payload_cache[payload_cache_key] = false
|
||||||
|
callback(nil, nil, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
payload_cache[payload_cache_key] = payload
|
||||||
|
callback(payload, nil, false)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fetch_aniskip_for_current_media(trigger_source)
|
||||||
|
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
|
||||||
|
if not opts.aniskip_enabled then
|
||||||
|
clear_aniskip_state()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
should_fetch_aniskip_async(trigger, function(allowed, reason)
|
||||||
|
if not allowed then
|
||||||
|
subminer_log("debug", "aniskip", "Skipping lookup: " .. tostring(reason))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
request_generation = request_generation + 1
|
||||||
|
local request_id = request_generation
|
||||||
|
reset_aniskip_fields()
|
||||||
|
local title, episode, season = resolve_title_and_episode()
|
||||||
|
local lookup_titles = resolve_lookup_titles(title)
|
||||||
|
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
'Query context: trigger=%s reason=%s title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)',
|
||||||
|
tostring(trigger),
|
||||||
|
tostring(reason or "-"),
|
||||||
|
tostring(title or ""),
|
||||||
|
tostring(season or "-"),
|
||||||
|
tostring(episode or "-"),
|
||||||
|
tostring(opts.aniskip_title or ""),
|
||||||
|
tostring(opts.aniskip_season or "-"),
|
||||||
|
tostring(opts.aniskip_episode or "-"),
|
||||||
|
tostring(opts.aniskip_mal_id or "-"),
|
||||||
|
#lookup_titles
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resolve_mal_from_candidates_async(lookup_titles, season, request_id, function(mal_id, mal_lookup)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not mal_id then
|
||||||
|
subminer_log("info", "aniskip", string.format('Skipped: MAL id unavailable for query="%s"', tostring(mal_lookup or "")))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
subminer_log("info", "aniskip", string.format('Resolved MAL id=%d using query="%s"', mal_id, tostring(mal_lookup or "")))
|
||||||
|
fetch_payload_for_episode_async(mal_id, episode, request_id, function(payload, fetch_error)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not payload then
|
||||||
|
if fetch_error then
|
||||||
|
subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error))
|
||||||
|
else
|
||||||
|
subminer_log("info", "aniskip", "AniSkip: no skip windows found")
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not apply_aniskip_payload(mal_id, title, episode, payload) then
|
||||||
|
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
clear_aniskip_state = clear_aniskip_state,
|
||||||
|
skip_intro_now = skip_intro_now,
|
||||||
|
update_intro_button_visibility = update_intro_button_visibility,
|
||||||
|
fetch_aniskip_for_current_media = fetch_aniskip_for_current_media,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
150
plugin/subminer/aniskip_match.lua
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local function normalize_for_match(value)
|
||||||
|
if type(value) ~= "string" then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
return value:lower():gsub("[^%w]+", " "):gsub("%s+", " "):match("^%s*(.-)%s*$") or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
local MATCH_STOPWORDS = {
|
||||||
|
the = true,
|
||||||
|
this = true,
|
||||||
|
that = true,
|
||||||
|
world = true,
|
||||||
|
animated = true,
|
||||||
|
series = true,
|
||||||
|
season = true,
|
||||||
|
no = true,
|
||||||
|
on = true,
|
||||||
|
["and"] = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function tokenize_match_words(value)
|
||||||
|
local normalized = normalize_for_match(value)
|
||||||
|
local tokens = {}
|
||||||
|
for token in normalized:gmatch("%S+") do
|
||||||
|
if #token >= 3 and not MATCH_STOPWORDS[token] then
|
||||||
|
tokens[#tokens + 1] = token
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return tokens
|
||||||
|
end
|
||||||
|
|
||||||
|
local function token_set(tokens)
|
||||||
|
local set = {}
|
||||||
|
for _, token in ipairs(tokens) do
|
||||||
|
set[token] = true
|
||||||
|
end
|
||||||
|
return set
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.title_overlap_score(expected_title, candidate_title)
|
||||||
|
local expected = normalize_for_match(expected_title)
|
||||||
|
local candidate = normalize_for_match(candidate_title)
|
||||||
|
if expected == "" or candidate == "" then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
if candidate:find(expected, 1, true) then
|
||||||
|
return 120
|
||||||
|
end
|
||||||
|
local expected_tokens = tokenize_match_words(expected_title)
|
||||||
|
local candidate_tokens = token_set(tokenize_match_words(candidate_title))
|
||||||
|
if #expected_tokens == 0 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
local score = 0
|
||||||
|
local matched = 0
|
||||||
|
for _, token in ipairs(expected_tokens) do
|
||||||
|
if candidate_tokens[token] then
|
||||||
|
score = score + 30
|
||||||
|
matched = matched + 1
|
||||||
|
else
|
||||||
|
score = score - 20
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if matched == 0 then
|
||||||
|
score = score - 80
|
||||||
|
end
|
||||||
|
local coverage = matched / #expected_tokens
|
||||||
|
if #expected_tokens >= 2 then
|
||||||
|
if coverage >= 0.8 then
|
||||||
|
score = score + 30
|
||||||
|
elseif coverage >= 0.6 then
|
||||||
|
score = score + 10
|
||||||
|
else
|
||||||
|
score = score - 50
|
||||||
|
end
|
||||||
|
elseif coverage >= 1 then
|
||||||
|
score = score + 10
|
||||||
|
end
|
||||||
|
return score
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_any_sequel_marker(candidate_title)
|
||||||
|
local normalized = normalize_for_match(candidate_title)
|
||||||
|
if normalized == "" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local markers = {
|
||||||
|
"season 2",
|
||||||
|
"season 3",
|
||||||
|
"season 4",
|
||||||
|
"2nd season",
|
||||||
|
"3rd season",
|
||||||
|
"4th season",
|
||||||
|
"second season",
|
||||||
|
"third season",
|
||||||
|
"fourth season",
|
||||||
|
" ii ",
|
||||||
|
" iii ",
|
||||||
|
" iv ",
|
||||||
|
}
|
||||||
|
local padded = " " .. normalized .. " "
|
||||||
|
for _, marker in ipairs(markers) do
|
||||||
|
if padded:find(marker, 1, true) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.season_signal_score(requested_season, candidate_title)
|
||||||
|
local season = tonumber(requested_season)
|
||||||
|
if not season or season < 1 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
local normalized = " " .. normalize_for_match(candidate_title) .. " "
|
||||||
|
if normalized == " " then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
if season == 1 then
|
||||||
|
return has_any_sequel_marker(candidate_title) and -60 or 20
|
||||||
|
end
|
||||||
|
|
||||||
|
local numeric_marker = string.format(" season %d ", season)
|
||||||
|
local ordinal_marker = string.format(" %dth season ", season)
|
||||||
|
local roman_markers = {
|
||||||
|
[2] = { " ii ", " second season ", " 2nd season " },
|
||||||
|
[3] = { " iii ", " third season ", " 3rd season " },
|
||||||
|
[4] = { " iv ", " fourth season ", " 4th season " },
|
||||||
|
[5] = { " v ", " fifth season ", " 5th season " },
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized:find(numeric_marker, 1, true) or normalized:find(ordinal_marker, 1, true) then
|
||||||
|
return 40
|
||||||
|
end
|
||||||
|
local aliases = roman_markers[season] or {}
|
||||||
|
for _, marker in ipairs(aliases) do
|
||||||
|
if normalized:find(marker, 1, true) then
|
||||||
|
return 40
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if has_any_sequel_marker(candidate_title) then
|
||||||
|
return -20
|
||||||
|
end
|
||||||
|
return 5
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
151
plugin/subminer/binary.lua
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local utils = ctx.utils
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local environment = ctx.environment
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
|
||||||
|
local function normalize_binary_path_candidate(candidate)
|
||||||
|
if type(candidate) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local trimmed = candidate:match("^%s*(.-)%s*$") or ""
|
||||||
|
if trimmed == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if #trimmed >= 2 then
|
||||||
|
local first = trimmed:sub(1, 1)
|
||||||
|
local last = trimmed:sub(-1)
|
||||||
|
if (first == '"' and last == '"') or (first == "'" and last == "'") then
|
||||||
|
trimmed = trimmed:sub(2, -2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return trimmed ~= "" and trimmed or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function binary_candidates_from_app_path(app_path)
|
||||||
|
return {
|
||||||
|
utils.join_path(app_path, "Contents", "MacOS", "SubMiner"),
|
||||||
|
utils.join_path(app_path, "Contents", "MacOS", "subminer"),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function file_exists(path)
|
||||||
|
local info = utils.file_info(path)
|
||||||
|
if not info then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if info.is_dir ~= nil then
|
||||||
|
return not info.is_dir
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_binary_candidate(candidate)
|
||||||
|
local normalized = normalize_binary_path_candidate(candidate)
|
||||||
|
if not normalized then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if file_exists(normalized) then
|
||||||
|
return normalized
|
||||||
|
end
|
||||||
|
|
||||||
|
if not normalized:lower():find("%.app") then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local app_root = normalized
|
||||||
|
if not app_root:lower():match("%.app$") then
|
||||||
|
app_root = normalized:match("(.+%.app)")
|
||||||
|
end
|
||||||
|
if not app_root then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, path in ipairs(binary_candidates_from_app_path(app_root)) do
|
||||||
|
if file_exists(path) then
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_binary_override()
|
||||||
|
local candidates = {
|
||||||
|
resolve_binary_candidate(os.getenv("SUBMINER_APPIMAGE_PATH")),
|
||||||
|
resolve_binary_candidate(os.getenv("SUBMINER_BINARY_PATH")),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path in ipairs(candidates) do
|
||||||
|
if path and path ~= "" then
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_binary()
|
||||||
|
local override = find_binary_override()
|
||||||
|
if override then
|
||||||
|
return override
|
||||||
|
end
|
||||||
|
|
||||||
|
local configured = resolve_binary_candidate(opts.binary_path)
|
||||||
|
if configured then
|
||||||
|
return configured
|
||||||
|
end
|
||||||
|
|
||||||
|
local search_paths = {
|
||||||
|
"/Applications/SubMiner.app/Contents/MacOS/SubMiner",
|
||||||
|
utils.join_path(os.getenv("HOME") or "", "Applications/SubMiner.app/Contents/MacOS/SubMiner"),
|
||||||
|
"C:\\Program Files\\SubMiner\\SubMiner.exe",
|
||||||
|
"C:\\Program Files (x86)\\SubMiner\\SubMiner.exe",
|
||||||
|
"C:\\SubMiner\\SubMiner.exe",
|
||||||
|
utils.join_path(os.getenv("HOME") or "", ".local/bin/SubMiner.AppImage"),
|
||||||
|
"/opt/SubMiner/SubMiner.AppImage",
|
||||||
|
"/usr/local/bin/SubMiner",
|
||||||
|
"/usr/bin/SubMiner",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path in ipairs(search_paths) do
|
||||||
|
if file_exists(path) then
|
||||||
|
subminer_log("info", "binary", "Found binary at: " .. path)
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensure_binary_available()
|
||||||
|
if state.binary_available and state.binary_path and file_exists(state.binary_path) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local discovered = find_binary()
|
||||||
|
if discovered then
|
||||||
|
state.binary_path = discovered
|
||||||
|
state.binary_available = true
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
state.binary_path = nil
|
||||||
|
state.binary_available = false
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalize_binary_path_candidate = normalize_binary_path_candidate,
|
||||||
|
file_exists = file_exists,
|
||||||
|
find_binary = find_binary,
|
||||||
|
ensure_binary_available = ensure_binary_available,
|
||||||
|
is_windows = environment.is_windows,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
74
plugin/subminer/bootstrap.lua
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.init()
|
||||||
|
local input = require("mp.input")
|
||||||
|
local mp = require("mp")
|
||||||
|
local msg = require("mp.msg")
|
||||||
|
local options_lib = require("mp.options")
|
||||||
|
local utils = require("mp.utils")
|
||||||
|
|
||||||
|
local options_helper = require("options")
|
||||||
|
local environment = require("environment").create({ mp = mp })
|
||||||
|
local opts = options_helper.load(options_lib, environment.default_socket_path())
|
||||||
|
local state = require("state").new()
|
||||||
|
|
||||||
|
local ctx = {
|
||||||
|
input = input,
|
||||||
|
mp = mp,
|
||||||
|
msg = msg,
|
||||||
|
utils = utils,
|
||||||
|
opts = opts,
|
||||||
|
state = state,
|
||||||
|
options_helper = options_helper,
|
||||||
|
environment = environment,
|
||||||
|
}
|
||||||
|
|
||||||
|
local instances = {}
|
||||||
|
|
||||||
|
local function lazy_instance(key, factory)
|
||||||
|
if instances[key] == nil then
|
||||||
|
instances[key] = factory()
|
||||||
|
end
|
||||||
|
return instances[key]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function make_lazy_proxy(key, factory)
|
||||||
|
return setmetatable({}, {
|
||||||
|
__index = function(_, member)
|
||||||
|
return lazy_instance(key, factory)[member]
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
ctx.log = make_lazy_proxy("log", function()
|
||||||
|
return require("log").create(ctx)
|
||||||
|
end)
|
||||||
|
ctx.binary = make_lazy_proxy("binary", function()
|
||||||
|
return require("binary").create(ctx)
|
||||||
|
end)
|
||||||
|
ctx.aniskip = make_lazy_proxy("aniskip", function()
|
||||||
|
return require("aniskip").create(ctx)
|
||||||
|
end)
|
||||||
|
ctx.hover = make_lazy_proxy("hover", function()
|
||||||
|
return require("hover").create(ctx)
|
||||||
|
end)
|
||||||
|
ctx.process = make_lazy_proxy("process", function()
|
||||||
|
return require("process").create(ctx)
|
||||||
|
end)
|
||||||
|
ctx.ui = make_lazy_proxy("ui", function()
|
||||||
|
return require("ui").create(ctx)
|
||||||
|
end)
|
||||||
|
ctx.messages = make_lazy_proxy("messages", function()
|
||||||
|
return require("messages").create(ctx)
|
||||||
|
end)
|
||||||
|
ctx.lifecycle = make_lazy_proxy("lifecycle", function()
|
||||||
|
return require("lifecycle").create(ctx)
|
||||||
|
end)
|
||||||
|
|
||||||
|
ctx.ui.register_keybindings()
|
||||||
|
ctx.messages.register_script_messages()
|
||||||
|
ctx.lifecycle.register_lifecycle_hooks()
|
||||||
|
ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded")
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
210
plugin/subminer/environment.lua
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
|
||||||
|
local detected_backend = nil
|
||||||
|
local app_running_cache_value = nil
|
||||||
|
local app_running_cache_time = nil
|
||||||
|
local app_running_check_inflight = false
|
||||||
|
local app_running_waiters = {}
|
||||||
|
local APP_RUNNING_CACHE_TTL_SECONDS = 2
|
||||||
|
|
||||||
|
local function is_windows()
|
||||||
|
return package.config:sub(1, 1) == "\\"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_macos()
|
||||||
|
local platform = mp.get_property("platform") or ""
|
||||||
|
if platform == "macos" or platform == "darwin" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local ostype = os.getenv("OSTYPE") or ""
|
||||||
|
return ostype:find("darwin") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function default_socket_path()
|
||||||
|
if is_windows() then
|
||||||
|
return "\\\\.\\pipe\\subminer-socket"
|
||||||
|
end
|
||||||
|
return "/tmp/subminer-socket"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_linux()
|
||||||
|
return not is_windows() and not is_macos()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function now_seconds()
|
||||||
|
if type(mp.get_time) == "function" then
|
||||||
|
local value = tonumber(mp.get_time())
|
||||||
|
if value then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return os.time()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function process_list_has_subminer(raw_process_list)
|
||||||
|
if type(raw_process_list) ~= "string" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local process_list = raw_process_list:lower()
|
||||||
|
for line in process_list:gmatch("[^\n]+") do
|
||||||
|
if is_windows() then
|
||||||
|
local image = line:match('^"([^"]+)","')
|
||||||
|
if not image then
|
||||||
|
image = line:match('^"([^"]+)"')
|
||||||
|
end
|
||||||
|
if not image then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local argv0 = line:match('^"([^"]+)"') or line:match("^%s*([^%s]+)")
|
||||||
|
if not argv0 then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
if argv0:find("subminer.lua", 1, true) or argv0:find("subminer.conf", 1, true) then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
local exe = argv0:match("([^/\\]+)$") or argv0
|
||||||
|
if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if exe:find("subminer", 1, true) and exe:find("%.lua", 1, true) == nil and exe:find("%.app", 1, true) == nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
::continue::
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function process_scan_command()
|
||||||
|
if is_windows() then
|
||||||
|
return { "tasklist", "/FO", "CSV", "/NH" }
|
||||||
|
end
|
||||||
|
return { "ps", "-A", "-o", "args=" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_process_running()
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = process_scan_command(),
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = false,
|
||||||
|
})
|
||||||
|
if not result or result.status ~= 0 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return process_list_has_subminer(result.stdout)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function flush_app_running_waiters(value)
|
||||||
|
local waiters = app_running_waiters
|
||||||
|
app_running_waiters = {}
|
||||||
|
for _, waiter in ipairs(waiters) do
|
||||||
|
waiter(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_app_running_async(callback, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local force_refresh = opts.force_refresh == true
|
||||||
|
local now = now_seconds()
|
||||||
|
if not force_refresh and app_running_cache_value ~= nil and app_running_cache_time ~= nil then
|
||||||
|
if (now - app_running_cache_time) <= APP_RUNNING_CACHE_TTL_SECONDS then
|
||||||
|
callback(app_running_cache_value)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
app_running_waiters[#app_running_waiters + 1] = callback
|
||||||
|
if app_running_check_inflight then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
app_running_check_inflight = true
|
||||||
|
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = process_scan_command(),
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = false,
|
||||||
|
}, function(success, result)
|
||||||
|
app_running_check_inflight = false
|
||||||
|
local running = false
|
||||||
|
if success and result and result.status == 0 then
|
||||||
|
running = process_list_has_subminer(result.stdout)
|
||||||
|
end
|
||||||
|
app_running_cache_value = running
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
flush_app_running_waiters(running)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_app_running()
|
||||||
|
local running = is_subminer_process_running()
|
||||||
|
app_running_cache_value = running
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
return running
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_subminer_app_running_cache(running)
|
||||||
|
app_running_cache_value = running == true
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function detect_backend()
|
||||||
|
if detected_backend then
|
||||||
|
return detected_backend
|
||||||
|
end
|
||||||
|
|
||||||
|
local backend = nil
|
||||||
|
local subminer_log = ctx.log and ctx.log.subminer_log or function() end
|
||||||
|
|
||||||
|
if is_macos() then
|
||||||
|
backend = "macos"
|
||||||
|
elseif is_windows() then
|
||||||
|
backend = nil
|
||||||
|
elseif os.getenv("HYPRLAND_INSTANCE_SIGNATURE") then
|
||||||
|
backend = "hyprland"
|
||||||
|
elseif os.getenv("SWAYSOCK") then
|
||||||
|
backend = "sway"
|
||||||
|
elseif os.getenv("XDG_SESSION_TYPE") == "x11" or os.getenv("DISPLAY") then
|
||||||
|
backend = "x11"
|
||||||
|
else
|
||||||
|
subminer_log("warn", "backend", "Could not detect window manager, falling back to x11")
|
||||||
|
backend = "x11"
|
||||||
|
end
|
||||||
|
|
||||||
|
detected_backend = backend
|
||||||
|
if backend then
|
||||||
|
subminer_log("info", "backend", "Detected backend: " .. backend)
|
||||||
|
else
|
||||||
|
subminer_log("info", "backend", "No backend detected")
|
||||||
|
end
|
||||||
|
return backend
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
is_windows = is_windows,
|
||||||
|
is_macos = is_macos,
|
||||||
|
is_linux = is_linux,
|
||||||
|
default_socket_path = default_socket_path,
|
||||||
|
is_subminer_process_running = is_subminer_process_running,
|
||||||
|
is_subminer_app_running = is_subminer_app_running,
|
||||||
|
is_subminer_app_running_async = is_subminer_app_running_async,
|
||||||
|
set_subminer_app_running_cache = set_subminer_app_running_cache,
|
||||||
|
detect_backend = detect_backend,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
445
plugin/subminer/hover.lua
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local DEFAULT_HOVER_BASE_COLOR = "FFFFFF"
|
||||||
|
local DEFAULT_HOVER_COLOR = "C6A0F6"
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local msg = ctx.msg
|
||||||
|
local utils = ctx.utils
|
||||||
|
local state = ctx.state
|
||||||
|
|
||||||
|
local function to_hex_color(input)
|
||||||
|
if type(input) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local hex = input:gsub("[%#%']", ""):gsub("^0x", "")
|
||||||
|
if #hex ~= 6 and #hex ~= 3 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if #hex == 3 then
|
||||||
|
return hex:sub(1, 1) .. hex:sub(1, 1) .. hex:sub(2, 2) .. hex:sub(2, 2) .. hex:sub(3, 3) .. hex:sub(3, 3)
|
||||||
|
end
|
||||||
|
return hex
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fix_ass_color(input, fallback)
|
||||||
|
local hex = to_hex_color(input)
|
||||||
|
if not hex then
|
||||||
|
return fallback or DEFAULT_HOVER_BASE_COLOR
|
||||||
|
end
|
||||||
|
local r, g, b = hex:sub(1, 2), hex:sub(3, 4), hex:sub(5, 6)
|
||||||
|
return b .. g .. r
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sanitize_hover_ass_color(input, fallback_rgb)
|
||||||
|
local fallback = fix_ass_color(fallback_rgb or DEFAULT_HOVER_COLOR, DEFAULT_HOVER_COLOR)
|
||||||
|
local converted = fix_ass_color(input, fallback)
|
||||||
|
if converted == "000000" then
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
return converted
|
||||||
|
end
|
||||||
|
|
||||||
|
local function escape_ass_text(text)
|
||||||
|
return (text or ""):gsub("\\", "\\\\"):gsub("{", "\\{"):gsub("}", "\\}"):gsub("\n", "\\N")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_osd_dimensions()
|
||||||
|
local width = mp.get_property_number("osd-width", 0) or 0
|
||||||
|
local height = mp.get_property_number("osd-height", 0) or 0
|
||||||
|
|
||||||
|
if width <= 0 or height <= 0 then
|
||||||
|
local osd_dims = mp.get_property_native("osd-dimensions")
|
||||||
|
if type(osd_dims) == "table" and type(osd_dims.w) == "number" and osd_dims.w > 0 then
|
||||||
|
width = osd_dims.w
|
||||||
|
end
|
||||||
|
if type(osd_dims) == "table" and type(osd_dims.h) == "number" and osd_dims.h > 0 then
|
||||||
|
height = osd_dims.h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if width <= 0 then
|
||||||
|
width = 1280
|
||||||
|
end
|
||||||
|
if height <= 0 then
|
||||||
|
height = 720
|
||||||
|
end
|
||||||
|
|
||||||
|
return width, height
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_metrics()
|
||||||
|
local sub_font_size = mp.get_property_number("sub-font-size", 36) or 36
|
||||||
|
local sub_scale = mp.get_property_number("sub-scale", 1) or 1
|
||||||
|
local sub_scale_by_window = mp.get_property_bool("sub-scale-by-window", true) == true
|
||||||
|
local sub_pos = mp.get_property_number("sub-pos", 100) or 100
|
||||||
|
local sub_margin_y = mp.get_property_number("sub-margin-y", 0) or 0
|
||||||
|
local sub_font = mp.get_property("sub-font", "sans-serif") or "sans-serif"
|
||||||
|
local sub_spacing = mp.get_property_number("sub-spacing", 0) or 0
|
||||||
|
local sub_bold = mp.get_property_bool("sub-bold", false) == true
|
||||||
|
local sub_italic = mp.get_property_bool("sub-italic", false) == true
|
||||||
|
local sub_border_size = mp.get_property_number("sub-border-size", 2) or 2
|
||||||
|
local sub_shadow_offset = mp.get_property_number("sub-shadow-offset", 0) or 0
|
||||||
|
local osd_w, osd_h = resolve_osd_dimensions()
|
||||||
|
local window_scale = 1
|
||||||
|
if sub_scale_by_window and osd_h > 0 then
|
||||||
|
window_scale = osd_h / 720
|
||||||
|
end
|
||||||
|
local effective_margin_y = sub_margin_y * window_scale
|
||||||
|
|
||||||
|
return {
|
||||||
|
font_size = sub_font_size * (sub_scale > 0 and sub_scale or 1) * window_scale,
|
||||||
|
pos = sub_pos,
|
||||||
|
margin_y = effective_margin_y,
|
||||||
|
font = sub_font,
|
||||||
|
spacing = sub_spacing,
|
||||||
|
bold = sub_bold,
|
||||||
|
italic = sub_italic,
|
||||||
|
border = sub_border_size * window_scale,
|
||||||
|
shadow = sub_shadow_offset * window_scale,
|
||||||
|
base_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_BASE_COLOR),
|
||||||
|
hover_color = sanitize_hover_ass_color(nil, DEFAULT_HOVER_COLOR),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_subtitle_ass_property()
|
||||||
|
local ass_text = mp.get_property("sub-text/ass")
|
||||||
|
if type(ass_text) == "string" and ass_text ~= "" then
|
||||||
|
return ass_text
|
||||||
|
end
|
||||||
|
ass_text = mp.get_property("sub-text-ass")
|
||||||
|
if type(ass_text) == "string" and ass_text ~= "" then
|
||||||
|
return ass_text
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function plain_text_and_ass_map(text)
|
||||||
|
local plain = {}
|
||||||
|
local map = {}
|
||||||
|
local plain_len = 0
|
||||||
|
local i = 1
|
||||||
|
local text_len = #text
|
||||||
|
|
||||||
|
while i <= text_len do
|
||||||
|
local ch = text:sub(i, i)
|
||||||
|
if ch == "{" then
|
||||||
|
local close = text:find("}", i + 1, true)
|
||||||
|
if not close then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
i = close + 1
|
||||||
|
elseif ch == "\\" then
|
||||||
|
local esc = text:sub(i + 1, i + 1)
|
||||||
|
if esc == "N" or esc == "n" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "\n"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "h" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = " "
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "{" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "{"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "}" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "}"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "\\" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "\\"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
else
|
||||||
|
local seq_end = i + 1
|
||||||
|
while seq_end <= text_len and text:sub(seq_end, seq_end):match("[%a]") do
|
||||||
|
seq_end = seq_end + 1
|
||||||
|
end
|
||||||
|
if text:sub(seq_end, seq_end) == "(" then
|
||||||
|
local close = text:find(")", seq_end, true)
|
||||||
|
if close then
|
||||||
|
i = close + 1
|
||||||
|
else
|
||||||
|
i = seq_end + 1
|
||||||
|
end
|
||||||
|
else
|
||||||
|
i = seq_end + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = ch
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(plain), map
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_hover_span(payload, plain)
|
||||||
|
local source_len = #plain
|
||||||
|
local cursor = 1
|
||||||
|
for _, token in ipairs(payload.tokens or {}) do
|
||||||
|
if type(token) ~= "table" or type(token.text) ~= "string" or token.text == "" then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local token_text = token.text
|
||||||
|
local start_pos = nil
|
||||||
|
local end_pos = nil
|
||||||
|
|
||||||
|
if type(token.startPos) == "number" and type(token.endPos) == "number" then
|
||||||
|
if token.startPos >= 0 and token.endPos >= token.startPos then
|
||||||
|
local candidate_start = token.startPos + 1
|
||||||
|
local candidate_stop = token.endPos
|
||||||
|
if candidate_start >= 1 and candidate_stop <= source_len and candidate_stop >= candidate_start and plain:sub(candidate_start, candidate_stop) == token_text then
|
||||||
|
start_pos = candidate_start
|
||||||
|
end_pos = candidate_stop
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not start_pos or not end_pos then
|
||||||
|
local fallback_start, fallback_stop = plain:find(token_text, cursor, true)
|
||||||
|
if not fallback_start then
|
||||||
|
fallback_start, fallback_stop = plain:find(token_text, 1, true)
|
||||||
|
end
|
||||||
|
start_pos, end_pos = fallback_start, fallback_stop
|
||||||
|
end
|
||||||
|
|
||||||
|
if start_pos and end_pos then
|
||||||
|
if token.index == payload.hoveredTokenIndex then
|
||||||
|
return start_pos, end_pos
|
||||||
|
end
|
||||||
|
cursor = end_pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
::continue::
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function inject_hover_color_to_ass(raw_ass, plain_map, hover_start, hover_end, hover_color, base_color)
|
||||||
|
if hover_start == nil or hover_end == nil then
|
||||||
|
return raw_ass
|
||||||
|
end
|
||||||
|
|
||||||
|
local raw_open_idx = plain_map[hover_start] or 1
|
||||||
|
local raw_close_idx = plain_map[hover_end + 1] or (#raw_ass + 1)
|
||||||
|
if raw_open_idx < 1 then
|
||||||
|
raw_open_idx = 1
|
||||||
|
end
|
||||||
|
if raw_close_idx < 1 then
|
||||||
|
raw_close_idx = 1
|
||||||
|
end
|
||||||
|
if raw_open_idx > #raw_ass + 1 then
|
||||||
|
raw_open_idx = #raw_ass + 1
|
||||||
|
end
|
||||||
|
if raw_close_idx > #raw_ass + 1 then
|
||||||
|
raw_close_idx = #raw_ass + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local open_tag = string.format("{\\1c&H%s&}", hover_color)
|
||||||
|
local close_tag = string.format("{\\1c&H%s&}", base_color)
|
||||||
|
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
|
||||||
|
|
||||||
|
local function build_hover_subtitle_content(payload)
|
||||||
|
local source_ass = get_subtitle_ass_property()
|
||||||
|
if type(source_ass) == "string" and source_ass ~= "" then
|
||||||
|
state.hover_highlight.cached_ass = source_ass
|
||||||
|
else
|
||||||
|
source_ass = state.hover_highlight.cached_ass
|
||||||
|
end
|
||||||
|
if type(source_ass) ~= "string" or source_ass == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local plain_source, plain_map = plain_text_and_ass_map(source_ass)
|
||||||
|
if type(plain_source) ~= "string" or plain_source == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local hover_start, hover_end = find_hover_span(payload, plain_source)
|
||||||
|
if not hover_start or not hover_end then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local metrics = resolve_metrics()
|
||||||
|
local hover_color = sanitize_hover_ass_color(payload.colors and payload.colors.hover or nil, DEFAULT_HOVER_COLOR)
|
||||||
|
local base_color = fix_ass_color(payload.colors and payload.colors.base or nil, metrics.base_color)
|
||||||
|
return inject_hover_color_to_ass(source_ass, plain_map, hover_start, hover_end, hover_color, base_color)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clear_hover_overlay()
|
||||||
|
if state.hover_highlight.clear_timer then
|
||||||
|
state.hover_highlight.clear_timer:kill()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
end
|
||||||
|
if state.hover_highlight.overlay_active then
|
||||||
|
if type(state.hover_highlight.saved_sub_visibility) == "string" then
|
||||||
|
mp.set_property("sub-visibility", state.hover_highlight.saved_sub_visibility)
|
||||||
|
else
|
||||||
|
mp.set_property("sub-visibility", "yes")
|
||||||
|
end
|
||||||
|
if type(state.hover_highlight.saved_secondary_sub_visibility) == "string" then
|
||||||
|
mp.set_property("secondary-sub-visibility", state.hover_highlight.saved_secondary_sub_visibility)
|
||||||
|
end
|
||||||
|
state.hover_highlight.saved_sub_visibility = nil
|
||||||
|
state.hover_highlight.saved_secondary_sub_visibility = nil
|
||||||
|
state.hover_highlight.overlay_active = false
|
||||||
|
end
|
||||||
|
mp.set_osd_ass(0, 0, "")
|
||||||
|
state.hover_highlight.payload = nil
|
||||||
|
state.hover_highlight.revision = -1
|
||||||
|
state.hover_highlight.cached_ass = nil
|
||||||
|
state.hover_highlight.last_hover_update_ts = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function schedule_hover_clear(delay_seconds)
|
||||||
|
if state.hover_highlight.clear_timer then
|
||||||
|
state.hover_highlight.clear_timer:kill()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
end
|
||||||
|
state.hover_highlight.clear_timer = mp.add_timeout(delay_seconds or 0.08, function()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function render_hover_overlay(payload)
|
||||||
|
if not payload or payload.hoveredTokenIndex == nil or payload.subtitle == nil then
|
||||||
|
clear_hover_overlay()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local ass = build_hover_subtitle_content(payload)
|
||||||
|
if not ass then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local osd_w, osd_h = resolve_osd_dimensions()
|
||||||
|
local metrics = resolve_metrics()
|
||||||
|
local osd_dims = mp.get_property_native("osd-dimensions")
|
||||||
|
local ml = (type(osd_dims) == "table" and type(osd_dims.ml) == "number") and osd_dims.ml or 0
|
||||||
|
local mr = (type(osd_dims) == "table" and type(osd_dims.mr) == "number") and osd_dims.mr or 0
|
||||||
|
local mt = (type(osd_dims) == "table" and type(osd_dims.mt) == "number") and osd_dims.mt or 0
|
||||||
|
local mb = (type(osd_dims) == "table" and type(osd_dims.mb) == "number") and osd_dims.mb or 0
|
||||||
|
local usable_w = math.max(1, osd_w - ml - mr)
|
||||||
|
local usable_h = math.max(1, osd_h - mt - mb)
|
||||||
|
local anchor_x = math.floor(ml + usable_w / 2)
|
||||||
|
local baseline_adjust = (metrics.border + metrics.shadow) * 5
|
||||||
|
local anchor_y = math.floor(mt + (usable_h * metrics.pos / 100) - metrics.margin_y + baseline_adjust)
|
||||||
|
local font_size = math.max(8, metrics.font_size)
|
||||||
|
local anchor_tag = string.format(
|
||||||
|
"{\\an2\\q2\\pos(%d,%d)\\fn%s\\fs%g\\b%d\\i%d\\fsp%g\\bord%g\\shad%g\\1c&H%s&}",
|
||||||
|
anchor_x,
|
||||||
|
anchor_y,
|
||||||
|
escape_ass_text(metrics.font),
|
||||||
|
font_size,
|
||||||
|
metrics.bold and 1 or 0,
|
||||||
|
metrics.italic and 1 or 0,
|
||||||
|
metrics.spacing,
|
||||||
|
metrics.border,
|
||||||
|
metrics.shadow,
|
||||||
|
metrics.base_color
|
||||||
|
)
|
||||||
|
if not state.hover_highlight.overlay_active then
|
||||||
|
state.hover_highlight.saved_sub_visibility = mp.get_property("sub-visibility")
|
||||||
|
state.hover_highlight.saved_secondary_sub_visibility = mp.get_property("secondary-sub-visibility")
|
||||||
|
mp.set_property("sub-visibility", "no")
|
||||||
|
mp.set_property("secondary-sub-visibility", "no")
|
||||||
|
state.hover_highlight.overlay_active = true
|
||||||
|
end
|
||||||
|
mp.set_osd_ass(osd_w, osd_h, anchor_tag .. ass)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handle_hover_message(payload_json)
|
||||||
|
local parsed, parse_error = utils.parse_json(payload_json)
|
||||||
|
if not parsed then
|
||||||
|
msg.warn("Invalid hover-highlight payload: " .. tostring(parse_error))
|
||||||
|
clear_hover_overlay()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(parsed.revision) ~= "number" then
|
||||||
|
clear_hover_overlay()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if parsed.revision < state.hover_highlight.revision then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(parsed.hoveredTokenIndex) == "number" and type(parsed.tokens) == "table" then
|
||||||
|
if state.hover_highlight.clear_timer then
|
||||||
|
state.hover_highlight.clear_timer:kill()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
end
|
||||||
|
state.hover_highlight.revision = parsed.revision
|
||||||
|
state.hover_highlight.payload = parsed
|
||||||
|
state.hover_highlight.last_hover_update_ts = mp.get_time() or 0
|
||||||
|
render_hover_overlay(state.hover_highlight.payload)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local now = mp.get_time() or 0
|
||||||
|
local elapsed_since_hover = now - (state.hover_highlight.last_hover_update_ts or 0)
|
||||||
|
state.hover_highlight.revision = parsed.revision
|
||||||
|
state.hover_highlight.payload = nil
|
||||||
|
if state.hover_highlight.overlay_active then
|
||||||
|
if elapsed_since_hover > 0.35 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
schedule_hover_clear(0.08)
|
||||||
|
else
|
||||||
|
clear_hover_overlay()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
HOVER_MESSAGE_NAME = "subminer-hover-token",
|
||||||
|
HOVER_MESSAGE_NAME_LEGACY = "yomipv-hover-token",
|
||||||
|
handle_hover_message = handle_hover_message,
|
||||||
|
clear_hover_overlay = clear_hover_overlay,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
7
plugin/subminer/init.lua
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.init()
|
||||||
|
require("bootstrap").init()
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
106
plugin/subminer/lifecycle.lua
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local options_helper = ctx.options_helper
|
||||||
|
local process = ctx.process
|
||||||
|
local aniskip = ctx.aniskip
|
||||||
|
local hover = ctx.hover
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
|
||||||
|
local function schedule_aniskip_fetch(trigger_source, delay_seconds)
|
||||||
|
local delay = tonumber(delay_seconds) or 0
|
||||||
|
mp.add_timeout(delay, function()
|
||||||
|
aniskip.fetch_aniskip_for_current_media(trigger_source)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_auto_start_enabled()
|
||||||
|
local raw_auto_start = opts.auto_start
|
||||||
|
if raw_auto_start == nil then
|
||||||
|
raw_auto_start = opts.auto_start_overlay
|
||||||
|
end
|
||||||
|
if raw_auto_start == nil then
|
||||||
|
raw_auto_start = opts["auto-start"]
|
||||||
|
end
|
||||||
|
return options_helper.coerce_bool(raw_auto_start, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_file_loaded()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
|
||||||
|
local should_auto_start = resolve_auto_start_enabled()
|
||||||
|
if should_auto_start then
|
||||||
|
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"lifecycle",
|
||||||
|
"Skipping auto-start: input-ipc-server does not match configured socket_path"
|
||||||
|
)
|
||||||
|
schedule_aniskip_fetch("file-loaded", 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
process.start_overlay({
|
||||||
|
auto_start_trigger = true,
|
||||||
|
socket_path = opts.socket_path,
|
||||||
|
})
|
||||||
|
-- Give the overlay process a moment to initialize before querying AniSkip.
|
||||||
|
schedule_aniskip_fetch("overlay-start", 0.8)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
schedule_aniskip_fetch("file-loaded", 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_shutdown()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
if state.overlay_running or state.texthooker_running then
|
||||||
|
subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process")
|
||||||
|
show_osd("Shutting down...")
|
||||||
|
process.stop_overlay()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function register_lifecycle_hooks()
|
||||||
|
mp.register_event("file-loaded", on_file_loaded)
|
||||||
|
mp.register_event("shutdown", on_shutdown)
|
||||||
|
mp.register_event("file-loaded", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_event("end-file", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_event("shutdown", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_event("end-file", function()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
end)
|
||||||
|
mp.register_event("shutdown", function()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
end)
|
||||||
|
mp.add_hook("on_unload", 10, function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
end)
|
||||||
|
mp.observe_property("sub-start", "native", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.observe_property("time-pos", "number", function()
|
||||||
|
aniskip.update_intro_button_visibility()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
on_file_loaded = on_file_loaded,
|
||||||
|
on_shutdown = on_shutdown,
|
||||||
|
register_lifecycle_hooks = register_lifecycle_hooks,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
60
plugin/subminer/log.lua
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local LOG_LEVEL_PRIORITY = {
|
||||||
|
debug = 10,
|
||||||
|
info = 20,
|
||||||
|
warn = 30,
|
||||||
|
error = 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local msg = ctx.msg
|
||||||
|
local opts = ctx.opts
|
||||||
|
|
||||||
|
local function normalize_log_level(level)
|
||||||
|
local normalized = (level or "info"):lower()
|
||||||
|
if LOG_LEVEL_PRIORITY[normalized] then
|
||||||
|
return normalized
|
||||||
|
end
|
||||||
|
return "info"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function should_log(level)
|
||||||
|
local current = normalize_log_level(opts.log_level)
|
||||||
|
local target = normalize_log_level(level)
|
||||||
|
return LOG_LEVEL_PRIORITY[target] >= LOG_LEVEL_PRIORITY[current]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function subminer_log(level, scope, message)
|
||||||
|
if not should_log(level) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local timestamp = os.date("%Y-%m-%d %H:%M:%S")
|
||||||
|
local line = string.format("[subminer] - %s - %s - [%s] %s", timestamp, string.upper(level), scope, message)
|
||||||
|
if level == "error" then
|
||||||
|
msg.error(line)
|
||||||
|
elseif level == "warn" then
|
||||||
|
msg.warn(line)
|
||||||
|
elseif level == "debug" then
|
||||||
|
msg.debug(line)
|
||||||
|
else
|
||||||
|
msg.info(line)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function show_osd(message)
|
||||||
|
if opts.osd_messages then
|
||||||
|
mp.osd_message("SubMiner: " .. message, 3)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalize_log_level = normalize_log_level,
|
||||||
|
should_log = should_log,
|
||||||
|
subminer_log = subminer_log,
|
||||||
|
show_osd = show_osd,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
25
plugin/subminer/main.lua
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
local mp = require("mp")
|
||||||
|
|
||||||
|
local function current_script_dir()
|
||||||
|
if type(mp.get_script_directory) == "function" then
|
||||||
|
local from_mpv = mp.get_script_directory()
|
||||||
|
if type(from_mpv) == "string" and from_mpv ~= "" then
|
||||||
|
return from_mpv
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local source = debug.getinfo(1, "S").source or ""
|
||||||
|
if source:sub(1, 1) == "@" then
|
||||||
|
local full = source:sub(2)
|
||||||
|
return full:match("^(.*)[/\\][^/\\]+$") or "."
|
||||||
|
end
|
||||||
|
return "."
|
||||||
|
end
|
||||||
|
|
||||||
|
local script_dir = current_script_dir()
|
||||||
|
local module_patterns = script_dir .. "/?.lua;" .. script_dir .. "/?/init.lua;"
|
||||||
|
if not package.path:find(module_patterns, 1, true) then
|
||||||
|
package.path = module_patterns .. package.path
|
||||||
|
end
|
||||||
|
|
||||||
|
require("init").init()
|
||||||
51
plugin/subminer/messages.lua
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local process = ctx.process
|
||||||
|
local aniskip = ctx.aniskip
|
||||||
|
local hover = ctx.hover
|
||||||
|
local ui = ctx.ui
|
||||||
|
|
||||||
|
local function register_script_messages()
|
||||||
|
mp.register_script_message("subminer-start", function(...)
|
||||||
|
process.start_overlay_from_script_message(...)
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-stop", function()
|
||||||
|
process.stop_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-toggle", function()
|
||||||
|
process.toggle_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-menu", function()
|
||||||
|
ui.show_menu()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-options", function()
|
||||||
|
process.open_options()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-restart", function()
|
||||||
|
process.restart_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-status", function()
|
||||||
|
process.check_status()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-aniskip-refresh", function()
|
||||||
|
aniskip.fetch_aniskip_for_current_media("script-message")
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-skip-intro", function()
|
||||||
|
aniskip.skip_intro_now()
|
||||||
|
end)
|
||||||
|
mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json)
|
||||||
|
hover.handle_hover_message(payload_json)
|
||||||
|
end)
|
||||||
|
mp.register_script_message(hover.HOVER_MESSAGE_NAME_LEGACY, function(payload_json)
|
||||||
|
hover.handle_hover_message(payload_json)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
register_script_messages = register_script_messages,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
45
plugin/subminer/options.lua
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.load(options_lib, default_socket_path)
|
||||||
|
local opts = {
|
||||||
|
binary_path = "",
|
||||||
|
socket_path = default_socket_path,
|
||||||
|
texthooker_enabled = true,
|
||||||
|
texthooker_port = 5174,
|
||||||
|
backend = "auto",
|
||||||
|
auto_start = true,
|
||||||
|
auto_start_visible_overlay = false,
|
||||||
|
osd_messages = true,
|
||||||
|
log_level = "info",
|
||||||
|
aniskip_enabled = true,
|
||||||
|
aniskip_title = "",
|
||||||
|
aniskip_season = "",
|
||||||
|
aniskip_mal_id = "",
|
||||||
|
aniskip_episode = "",
|
||||||
|
aniskip_show_button = true,
|
||||||
|
aniskip_button_text = "You can skip by pressing %s",
|
||||||
|
aniskip_button_key = "y-k",
|
||||||
|
aniskip_button_duration = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
options_lib.read_options(opts, "subminer")
|
||||||
|
return opts
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.coerce_bool(value, fallback)
|
||||||
|
if type(value) == "boolean" then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
if type(value) == "string" then
|
||||||
|
local normalized = value:lower()
|
||||||
|
if normalized == "yes" or normalized == "true" or normalized == "1" or normalized == "on" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if normalized == "no" or normalized == "false" or normalized == "0" or normalized == "off" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
390
plugin/subminer/process.lua
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
||||||
|
local OVERLAY_START_MAX_ATTEMPTS = 6
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local binary = ctx.binary
|
||||||
|
local environment = ctx.environment
|
||||||
|
local options_helper = ctx.options_helper
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
local normalize_log_level = ctx.log.normalize_log_level
|
||||||
|
|
||||||
|
local function resolve_visible_overlay_startup()
|
||||||
|
local raw_visible_overlay = opts.auto_start_visible_overlay
|
||||||
|
if raw_visible_overlay == nil then
|
||||||
|
raw_visible_overlay = opts["auto-start-visible-overlay"]
|
||||||
|
end
|
||||||
|
return options_helper.coerce_bool(raw_visible_overlay, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function normalize_socket_path(path)
|
||||||
|
if type(path) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local trimmed = path:match("^%s*(.-)%s*$")
|
||||||
|
if trimmed == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return trimmed
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_matching_mpv_ipc_socket(target_socket_path)
|
||||||
|
local expected_socket = normalize_socket_path(target_socket_path or opts.socket_path)
|
||||||
|
local active_socket = normalize_socket_path(mp.get_property("input-ipc-server"))
|
||||||
|
if expected_socket == nil or active_socket == nil then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return expected_socket == active_socket
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_backend(override_backend)
|
||||||
|
local selected = override_backend
|
||||||
|
if selected == nil or selected == "" then
|
||||||
|
selected = opts.backend
|
||||||
|
end
|
||||||
|
if selected == "auto" then
|
||||||
|
return environment.detect_backend()
|
||||||
|
end
|
||||||
|
return selected
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_command_args(action, overrides)
|
||||||
|
overrides = overrides or {}
|
||||||
|
local args = { state.binary_path }
|
||||||
|
|
||||||
|
table.insert(args, "--" .. action)
|
||||||
|
local log_level = normalize_log_level(overrides.log_level or opts.log_level)
|
||||||
|
if log_level ~= "info" then
|
||||||
|
table.insert(args, "--log-level")
|
||||||
|
table.insert(args, log_level)
|
||||||
|
end
|
||||||
|
|
||||||
|
if action == "start" then
|
||||||
|
local backend = resolve_backend(overrides.backend)
|
||||||
|
if backend and backend ~= "" then
|
||||||
|
table.insert(args, "--backend")
|
||||||
|
table.insert(args, backend)
|
||||||
|
end
|
||||||
|
|
||||||
|
local socket_path = overrides.socket_path or opts.socket_path
|
||||||
|
table.insert(args, "--socket")
|
||||||
|
table.insert(args, socket_path)
|
||||||
|
|
||||||
|
local should_show_visible = resolve_visible_overlay_startup()
|
||||||
|
if should_show_visible and overrides.auto_start_trigger == true then
|
||||||
|
should_show_visible = has_matching_mpv_ipc_socket(socket_path)
|
||||||
|
end
|
||||||
|
if should_show_visible then
|
||||||
|
table.insert(args, "--show-visible-overlay")
|
||||||
|
else
|
||||||
|
table.insert(args, "--hide-visible-overlay")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
local function run_control_command_async(action, overrides, callback)
|
||||||
|
local args = build_command_args(action, overrides)
|
||||||
|
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
local ok = success and (result == nil or result.status == 0)
|
||||||
|
if callback then
|
||||||
|
callback(ok, result, error)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_start_script_message_overrides(...)
|
||||||
|
local overrides = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
local token = select(i, ...)
|
||||||
|
if type(token) == "string" and token ~= "" then
|
||||||
|
local key, value = token:match("^([%w_%-]+)=(.+)$")
|
||||||
|
if key and value then
|
||||||
|
local normalized_key = key:lower()
|
||||||
|
if normalized_key == "backend" then
|
||||||
|
local backend = value:lower()
|
||||||
|
if backend == "auto" or backend == "hyprland" or backend == "sway" or backend == "x11" or backend == "macos" then
|
||||||
|
overrides.backend = backend
|
||||||
|
end
|
||||||
|
elseif normalized_key == "socket" or normalized_key == "socket_path" then
|
||||||
|
overrides.socket_path = value
|
||||||
|
elseif normalized_key == "texthooker" or normalized_key == "texthooker_enabled" then
|
||||||
|
local parsed = options_helper.coerce_bool(value, nil)
|
||||||
|
if parsed ~= nil then
|
||||||
|
overrides.texthooker_enabled = parsed
|
||||||
|
end
|
||||||
|
elseif normalized_key == "log-level" or normalized_key == "log_level" then
|
||||||
|
overrides.log_level = normalize_log_level(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return overrides
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_texthooker_args()
|
||||||
|
local args = { state.binary_path, "--texthooker", "--port", tostring(opts.texthooker_port) }
|
||||||
|
local log_level = normalize_log_level(opts.log_level)
|
||||||
|
if log_level ~= "info" then
|
||||||
|
table.insert(args, "--log-level")
|
||||||
|
table.insert(args, log_level)
|
||||||
|
end
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensure_texthooker_running(callback)
|
||||||
|
if not opts.texthooker_enabled then
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if state.texthooker_running then
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local args = build_texthooker_args()
|
||||||
|
subminer_log("info", "texthooker", "Starting texthooker process: " .. table.concat(args, " "))
|
||||||
|
state.texthooker_running = true
|
||||||
|
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
if not success or (result and result.status ~= 0) then
|
||||||
|
state.texthooker_running = false
|
||||||
|
subminer_log(
|
||||||
|
"warn",
|
||||||
|
"texthooker",
|
||||||
|
"Texthooker process exited unexpectedly: " .. (error or (result and result.stderr) or "unknown error")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Start overlay immediately; overlay start path retries on readiness failures.
|
||||||
|
callback()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function start_overlay(overrides)
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if state.overlay_running then
|
||||||
|
subminer_log("info", "process", "Overlay already running")
|
||||||
|
show_osd("Already running")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
overrides = overrides or {}
|
||||||
|
local texthooker_enabled = overrides.texthooker_enabled
|
||||||
|
if texthooker_enabled == nil then
|
||||||
|
texthooker_enabled = opts.texthooker_enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
local function launch_overlay_with_retry(attempt)
|
||||||
|
local args = build_command_args("start", overrides)
|
||||||
|
if attempt == 1 then
|
||||||
|
subminer_log("info", "process", "Starting overlay: " .. table.concat(args, " "))
|
||||||
|
else
|
||||||
|
subminer_log(
|
||||||
|
"warn",
|
||||||
|
"process",
|
||||||
|
"Retrying overlay start (attempt " .. tostring(attempt) .. "): " .. table.concat(args, " ")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if attempt == 1 then
|
||||||
|
show_osd("Starting...")
|
||||||
|
end
|
||||||
|
state.overlay_running = true
|
||||||
|
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
if not success or (result and result.status ~= 0) then
|
||||||
|
local reason = error or (result and result.stderr) or "unknown error"
|
||||||
|
if attempt < OVERLAY_START_MAX_ATTEMPTS then
|
||||||
|
mp.add_timeout(OVERLAY_START_RETRY_DELAY_SECONDS, function()
|
||||||
|
launch_overlay_with_retry(attempt + 1)
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
state.overlay_running = false
|
||||||
|
subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
|
||||||
|
show_osd("Overlay start failed")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
if texthooker_enabled then
|
||||||
|
ensure_texthooker_running(function()
|
||||||
|
launch_overlay_with_retry(1)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
launch_overlay_with_retry(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function start_overlay_from_script_message(...)
|
||||||
|
local overrides = parse_start_script_message_overrides(...)
|
||||||
|
start_overlay(overrides)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function stop_overlay()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
run_control_command_async("stop", nil, function(ok, result)
|
||||||
|
if ok then
|
||||||
|
subminer_log("info", "process", "Overlay stopped")
|
||||||
|
else
|
||||||
|
subminer_log(
|
||||||
|
"warn",
|
||||||
|
"process",
|
||||||
|
"Stop command returned non-zero status: " .. tostring(result and result.status or "unknown")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
state.overlay_running = false
|
||||||
|
state.texthooker_running = false
|
||||||
|
show_osd("Stopped")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function toggle_overlay()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
run_control_command_async("toggle", nil, function(ok)
|
||||||
|
if not ok then
|
||||||
|
subminer_log("warn", "process", "Toggle command failed")
|
||||||
|
show_osd("Toggle failed")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function open_options()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
run_control_command_async("settings", nil, function(ok)
|
||||||
|
if ok then
|
||||||
|
subminer_log("info", "process", "Options window opened")
|
||||||
|
show_osd("Options opened")
|
||||||
|
else
|
||||||
|
subminer_log("warn", "process", "Failed to open options")
|
||||||
|
show_osd("Failed to open options")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restart_overlay()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
subminer_log("info", "process", "Restarting overlay...")
|
||||||
|
show_osd("Restarting...")
|
||||||
|
|
||||||
|
run_control_command_async("stop", nil, function()
|
||||||
|
state.overlay_running = false
|
||||||
|
state.texthooker_running = false
|
||||||
|
|
||||||
|
ensure_texthooker_running(function()
|
||||||
|
local start_args = build_command_args("start")
|
||||||
|
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
|
||||||
|
|
||||||
|
state.overlay_running = true
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = start_args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
if not success or (result and result.status ~= 0) then
|
||||||
|
state.overlay_running = false
|
||||||
|
subminer_log(
|
||||||
|
"error",
|
||||||
|
"process",
|
||||||
|
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
|
||||||
|
)
|
||||||
|
show_osd("Restart failed")
|
||||||
|
else
|
||||||
|
show_osd("Restarted successfully")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function check_status()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
show_osd("Status: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local status = state.overlay_running and "running" or "stopped"
|
||||||
|
show_osd("Status: overlay is " .. status)
|
||||||
|
subminer_log("info", "process", "Status check: overlay is " .. status)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function check_binary_available()
|
||||||
|
return binary.ensure_binary_available()
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
build_command_args = build_command_args,
|
||||||
|
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
|
||||||
|
run_control_command_async = run_control_command_async,
|
||||||
|
parse_start_script_message_overrides = parse_start_script_message_overrides,
|
||||||
|
ensure_texthooker_running = ensure_texthooker_running,
|
||||||
|
start_overlay = start_overlay,
|
||||||
|
start_overlay_from_script_message = start_overlay_from_script_message,
|
||||||
|
stop_overlay = stop_overlay,
|
||||||
|
toggle_overlay = toggle_overlay,
|
||||||
|
open_options = open_options,
|
||||||
|
restart_overlay = restart_overlay,
|
||||||
|
check_status = check_status,
|
||||||
|
check_binary_available = check_binary_available,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
33
plugin/subminer/state.lua
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.new()
|
||||||
|
return {
|
||||||
|
overlay_running = false,
|
||||||
|
texthooker_running = false,
|
||||||
|
overlay_process = nil,
|
||||||
|
binary_available = false,
|
||||||
|
binary_path = nil,
|
||||||
|
detected_backend = nil,
|
||||||
|
hover_highlight = {
|
||||||
|
revision = -1,
|
||||||
|
payload = nil,
|
||||||
|
saved_sub_visibility = nil,
|
||||||
|
saved_secondary_sub_visibility = nil,
|
||||||
|
overlay_active = false,
|
||||||
|
cached_ass = nil,
|
||||||
|
clear_timer = nil,
|
||||||
|
last_hover_update_ts = 0,
|
||||||
|
},
|
||||||
|
aniskip = {
|
||||||
|
mal_id = nil,
|
||||||
|
title = nil,
|
||||||
|
episode = nil,
|
||||||
|
intro_start = nil,
|
||||||
|
intro_end = nil,
|
||||||
|
found = false,
|
||||||
|
prompt_shown = false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
105
plugin/subminer/ui.lua
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local input = ctx.input
|
||||||
|
local opts = ctx.opts
|
||||||
|
local process = ctx.process
|
||||||
|
local aniskip = ctx.aniskip
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
|
||||||
|
local function ensure_binary_for_menu()
|
||||||
|
if process.check_binary_available() then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function show_menu()
|
||||||
|
if not ensure_binary_for_menu() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local items = {
|
||||||
|
"Start overlay",
|
||||||
|
"Stop overlay",
|
||||||
|
"Toggle overlay",
|
||||||
|
"Open options",
|
||||||
|
"Restart overlay",
|
||||||
|
"Check status",
|
||||||
|
}
|
||||||
|
|
||||||
|
local actions = {
|
||||||
|
function()
|
||||||
|
process.start_overlay()
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
process.stop_overlay()
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
process.toggle_overlay()
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
process.open_options()
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
process.restart_overlay()
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
process.check_status()
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
input.select({
|
||||||
|
prompt = "SubMiner: ",
|
||||||
|
items = items,
|
||||||
|
submit = function(index)
|
||||||
|
if index and actions[index] then
|
||||||
|
actions[index]()
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function register_keybindings()
|
||||||
|
mp.add_key_binding("y-s", "subminer-start", function()
|
||||||
|
process.start_overlay()
|
||||||
|
end)
|
||||||
|
mp.add_key_binding("y-S", "subminer-stop", function()
|
||||||
|
process.stop_overlay()
|
||||||
|
end)
|
||||||
|
mp.add_key_binding("y-t", "subminer-toggle", function()
|
||||||
|
process.toggle_overlay()
|
||||||
|
end)
|
||||||
|
mp.add_key_binding("y-y", "subminer-menu", show_menu)
|
||||||
|
mp.add_key_binding("y-o", "subminer-options", function()
|
||||||
|
process.open_options()
|
||||||
|
end)
|
||||||
|
mp.add_key_binding("y-r", "subminer-restart", function()
|
||||||
|
process.restart_overlay()
|
||||||
|
end)
|
||||||
|
mp.add_key_binding("y-c", "subminer-status", function()
|
||||||
|
process.check_status()
|
||||||
|
end)
|
||||||
|
if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then
|
||||||
|
mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", function()
|
||||||
|
aniskip.skip_intro_now()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
if opts.aniskip_button_key ~= "y-k" then
|
||||||
|
mp.add_key_binding("y-k", "subminer-skip-intro-fallback", function()
|
||||||
|
aniskip.skip_intro_now()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
show_menu = show_menu,
|
||||||
|
register_keybindings = register_keybindings,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
63
scripts/dev-watch.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
electron_args=("$@")
|
||||||
|
if [[ ${#electron_args[@]} -eq 0 ]]; then
|
||||||
|
electron_args=(--start --dev)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v bun >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] bun not found in PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TS_WATCH_PID=""
|
||||||
|
RENDER_WATCH_PID=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local pids=("$TS_WATCH_PID" "$RENDER_WATCH_PID")
|
||||||
|
for pid in "${pids[@]}"; do
|
||||||
|
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
sync_renderer_assets() {
|
||||||
|
mkdir -p dist/renderer
|
||||||
|
cp src/renderer/index.html src/renderer/style.css dist/renderer/
|
||||||
|
mkdir -p dist/renderer/fonts
|
||||||
|
cp -R src/renderer/fonts/. dist/renderer/fonts/
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[INFO] Syncing renderer static assets"
|
||||||
|
sync_renderer_assets
|
||||||
|
|
||||||
|
echo "[INFO] Running initial compile"
|
||||||
|
bun run tsc
|
||||||
|
bun run build:renderer
|
||||||
|
|
||||||
|
echo "[INFO] Starting TypeScript watch"
|
||||||
|
bun run tsc --watch --preserveWatchOutput &
|
||||||
|
TS_WATCH_PID=$!
|
||||||
|
|
||||||
|
echo "[INFO] Starting renderer watch"
|
||||||
|
bunx esbuild src/renderer/renderer.ts \
|
||||||
|
--bundle \
|
||||||
|
--platform=browser \
|
||||||
|
--format=esm \
|
||||||
|
--target=es2022 \
|
||||||
|
--outfile=dist/renderer/renderer.js \
|
||||||
|
--sourcemap \
|
||||||
|
--watch &
|
||||||
|
RENDER_WATCH_PID=$!
|
||||||
|
|
||||||
|
echo "[INFO] Launching Electron with args: ${electron_args[*]}"
|
||||||
|
bun run electron . "${electron_args[@]}"
|
||||||
@@ -33,7 +33,7 @@ interface CliOptions {
|
|||||||
function parseCliArgs(argv: string[]): CliOptions {
|
function parseCliArgs(argv: string[]): CliOptions {
|
||||||
const args = [...argv];
|
const args = [...argv];
|
||||||
let inputParts: string[] = [];
|
let inputParts: string[] = [];
|
||||||
let dictionaryPath = path.join(process.cwd(), 'vendor', 'jiten_freq_global');
|
let dictionaryPath = path.join(process.cwd(), 'vendor', 'frequency-dictionary');
|
||||||
let emitPretty = false;
|
let emitPretty = false;
|
||||||
let emitDiagnostics = false;
|
let emitDiagnostics = false;
|
||||||
let mecabCommand: string | undefined;
|
let mecabCommand: string | undefined;
|
||||||
@@ -394,7 +394,7 @@ function printUsage(): void {
|
|||||||
--color-band-5 <#hex> Frequency band-5 color.
|
--color-band-5 <#hex> Frequency band-5 color.
|
||||||
--color-known <#hex> Known-word color (default: #a6da95).
|
--color-known <#hex> Known-word color (default: #a6da95).
|
||||||
--color-n-plus-one <#hex> N+1 target color (default: #c6a0f6).
|
--color-n-plus-one <#hex> N+1 target color (default: #c6a0f6).
|
||||||
--dictionary <path> Frequency dictionary root path (default: ./vendor/jiten_freq_global)
|
--dictionary <path> Frequency dictionary root path (default: ./vendor/frequency-dictionary)
|
||||||
--mecab-command <path> Optional MeCab binary path (default: mecab)
|
--mecab-command <path> Optional MeCab binary path (default: mecab)
|
||||||
--mecab-dictionary <path> Optional MeCab dictionary directory (default: system default)
|
--mecab-dictionary <path> Optional MeCab dictionary directory (default: system default)
|
||||||
-h, --help Show usage.
|
-h, --help Show usage.
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ Description:
|
|||||||
- <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>.gif (palette-optimised, 15 fps)
|
||||||
|
- <name>-poster.jpg (single frame for video poster fallback)
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-f, --force Overwrite existing output files
|
-f, --force Overwrite existing output files
|
||||||
|
|
||||||
Encoding profile:
|
Encoding profile:
|
||||||
- Crop: 1920x1080 at x=760 y=180
|
- Crop: 1920x1080 at x=760 y=200
|
||||||
- MP4: H.264 + AAC
|
- MP4: H.264 + AAC
|
||||||
- WebM: AV1/VP9 + Opus at 30 fps
|
- WebM: AV1/VP9 + Opus at 30 fps
|
||||||
USAGE
|
USAGE
|
||||||
@@ -74,6 +75,7 @@ base="${filename%.*}"
|
|||||||
mp4_out="$dir/$base.mp4"
|
mp4_out="$dir/$base.mp4"
|
||||||
webm_out="$dir/$base.webm"
|
webm_out="$dir/$base.webm"
|
||||||
gif_out="$dir/$base.gif"
|
gif_out="$dir/$base.gif"
|
||||||
|
poster_out="$dir/$base-poster.jpg"
|
||||||
|
|
||||||
overwrite_flag="-n"
|
overwrite_flag="-n"
|
||||||
if [[ "$force" -eq 1 ]]; then
|
if [[ "$force" -eq 1 ]]; then
|
||||||
@@ -81,7 +83,7 @@ if [[ "$force" -eq 1 ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$force" -eq 0 ]]; then
|
if [[ "$force" -eq 0 ]]; then
|
||||||
for output in "$mp4_out" "$webm_out" "$gif_out"; do
|
for output in "$mp4_out" "$webm_out" "$gif_out" "$poster_out"; 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
|
||||||
@@ -94,7 +96,7 @@ has_encoder() {
|
|||||||
ffmpeg -hide_banner -encoders 2> /dev/null | grep -qE "[[:space:]]${encoder}[[:space:]]"
|
ffmpeg -hide_banner -encoders 2> /dev/null | grep -qE "[[:space:]]${encoder}[[:space:]]"
|
||||||
}
|
}
|
||||||
|
|
||||||
crop_vf="crop=1920:1080:760:180"
|
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"
|
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"
|
||||||
|
|
||||||
@@ -162,7 +164,15 @@ ffmpeg "$overwrite_flag" -i "$input" \
|
|||||||
-vf "$gif_vf" \
|
-vf "$gif_vf" \
|
||||||
"$gif_out"
|
"$gif_out"
|
||||||
|
|
||||||
|
echo "Generating poster: $poster_out"
|
||||||
|
ffmpeg "$overwrite_flag" -ss 00:00:05 -i "$input" \
|
||||||
|
-vf "$crop_vf" \
|
||||||
|
-vframes 1 \
|
||||||
|
-q:v 2 \
|
||||||
|
"$poster_out"
|
||||||
|
|
||||||
echo "Done."
|
echo "Done."
|
||||||
echo "MP4: $mp4_out"
|
echo "MP4: $mp4_out"
|
||||||
echo "WebM: $webm_out"
|
echo "WebM: $webm_out"
|
||||||
echo "GIF: $gif_out"
|
echo "GIF: $gif_out"
|
||||||
|
echo "Poster: $poster_out"
|
||||||
|
|||||||
8
scripts/subminer-dev.sh
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
exec bun run electron . "$@"
|
||||||
128
scripts/test-plugin-process-start-retries.lua
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
local function assert_true(condition, message)
|
||||||
|
if condition then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
error(message)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_flag(call, flag)
|
||||||
|
local args = call.args or {}
|
||||||
|
for _, arg in ipairs(args) do
|
||||||
|
if arg == flag then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_timeout(timeouts, target)
|
||||||
|
for _, value in ipairs(timeouts) do
|
||||||
|
if math.abs(value - target) < 0.0001 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local recorded = {
|
||||||
|
async_calls = {},
|
||||||
|
timeouts = {},
|
||||||
|
logs = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
local start_attempts = 0
|
||||||
|
|
||||||
|
local mp = {}
|
||||||
|
|
||||||
|
function mp.command_native_async(command, callback)
|
||||||
|
recorded.async_calls[#recorded.async_calls + 1] = command
|
||||||
|
|
||||||
|
local success = true
|
||||||
|
local result = { status = 0, stdout = "", stderr = "" }
|
||||||
|
local err = nil
|
||||||
|
|
||||||
|
if has_flag(command, "--start") then
|
||||||
|
start_attempts = start_attempts + 1
|
||||||
|
if start_attempts == 1 then
|
||||||
|
success = false
|
||||||
|
result = { status = 1, stdout = "", stderr = "startup-not-ready" }
|
||||||
|
err = "startup-not-ready"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if callback then
|
||||||
|
callback(success, result, err)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function mp.add_timeout(seconds, callback)
|
||||||
|
recorded.timeouts[#recorded.timeouts + 1] = seconds
|
||||||
|
if callback then
|
||||||
|
callback()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local process_module = dofile("plugin/subminer/process.lua")
|
||||||
|
local process = process_module.create({
|
||||||
|
mp = mp,
|
||||||
|
opts = {
|
||||||
|
backend = "x11",
|
||||||
|
socket_path = "/tmp/subminer.sock",
|
||||||
|
log_level = "debug",
|
||||||
|
texthooker_enabled = true,
|
||||||
|
texthooker_port = 5174,
|
||||||
|
auto_start_visible_overlay = false,
|
||||||
|
},
|
||||||
|
state = {
|
||||||
|
binary_path = "/tmp/subminer",
|
||||||
|
overlay_running = false,
|
||||||
|
texthooker_running = false,
|
||||||
|
},
|
||||||
|
binary = {
|
||||||
|
ensure_binary_available = function()
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
environment = {
|
||||||
|
detect_backend = function()
|
||||||
|
return "x11"
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
options_helper = {
|
||||||
|
coerce_bool = function(value, default_value)
|
||||||
|
if value == true or value == "yes" or value == "true" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if value == false or value == "no" or value == "false" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return default_value
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
log = {
|
||||||
|
subminer_log = function(_level, _scope, line)
|
||||||
|
recorded.logs[#recorded.logs + 1] = line
|
||||||
|
end,
|
||||||
|
show_osd = function(_) end,
|
||||||
|
normalize_log_level = function(value)
|
||||||
|
return value or "info"
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
process.start_overlay()
|
||||||
|
|
||||||
|
assert_true(start_attempts == 2, "expected start overlay command retry after readiness failure")
|
||||||
|
assert_true(not has_timeout(recorded.timeouts, 0.35), "fixed texthooker wait (0.35s) should be removed")
|
||||||
|
assert_true(not has_timeout(recorded.timeouts, 0.6), "fixed startup visibility delay (0.6s) should be removed")
|
||||||
|
|
||||||
|
local retry_timeout_seen = false
|
||||||
|
for _, timeout_seconds in ipairs(recorded.timeouts) do
|
||||||
|
if timeout_seconds > 0 and timeout_seconds <= 0.25 then
|
||||||
|
retry_timeout_seen = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert_true(retry_timeout_seen, "expected shorter bounded retry timeout")
|
||||||
|
|
||||||
|
print("plugin process retry regression tests: OK")
|
||||||
@@ -3,7 +3,9 @@ local function run_plugin_scenario(config)
|
|||||||
|
|
||||||
local recorded = {
|
local recorded = {
|
||||||
async_calls = {},
|
async_calls = {},
|
||||||
|
sync_calls = {},
|
||||||
script_messages = {},
|
script_messages = {},
|
||||||
|
events = {},
|
||||||
osd = {},
|
osd = {},
|
||||||
logs = {},
|
logs = {},
|
||||||
}
|
}
|
||||||
@@ -15,6 +17,9 @@ local function run_plugin_scenario(config)
|
|||||||
if name == "platform" then
|
if name == "platform" then
|
||||||
return config.platform or "linux"
|
return config.platform or "linux"
|
||||||
end
|
end
|
||||||
|
if name == "input-ipc-server" then
|
||||||
|
return config.input_ipc_server or ""
|
||||||
|
end
|
||||||
if name == "filename/no-ext" then
|
if name == "filename/no-ext" then
|
||||||
return config.filename_no_ext or ""
|
return config.filename_no_ext or ""
|
||||||
end
|
end
|
||||||
@@ -34,7 +39,12 @@ local function run_plugin_scenario(config)
|
|||||||
return config.chapter_list or {}
|
return config.chapter_list or {}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function mp.get_script_directory()
|
||||||
|
return "plugin/subminer"
|
||||||
|
end
|
||||||
|
|
||||||
function mp.command_native(command)
|
function mp.command_native(command)
|
||||||
|
recorded.sync_calls[#recorded.sync_calls + 1] = command
|
||||||
local args = command.args or {}
|
local args = command.args or {}
|
||||||
if args[1] == "ps" then
|
if args[1] == "ps" then
|
||||||
return {
|
return {
|
||||||
@@ -44,6 +54,13 @@ local function run_plugin_scenario(config)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
if args[1] == "curl" then
|
if args[1] == "curl" then
|
||||||
|
local url = args[#args] or ""
|
||||||
|
if type(url) == "string" and url:find("myanimelist", 1, true) then
|
||||||
|
return { status = 0, stdout = config.mal_lookup_stdout or "{}", stderr = "" }
|
||||||
|
end
|
||||||
|
if type(url) == "string" and url:find("api.aniskip.com", 1, true) then
|
||||||
|
return { status = 0, stdout = config.aniskip_stdout or "{}", stderr = "" }
|
||||||
|
end
|
||||||
return { status = 0, stdout = "{}", stderr = "" }
|
return { status = 0, stdout = "{}", stderr = "" }
|
||||||
end
|
end
|
||||||
return { status = 0, stdout = "", stderr = "" }
|
return { status = 0, stdout = "", stderr = "" }
|
||||||
@@ -52,6 +69,22 @@ local function run_plugin_scenario(config)
|
|||||||
function mp.command_native_async(command, callback)
|
function mp.command_native_async(command, callback)
|
||||||
recorded.async_calls[#recorded.async_calls + 1] = command
|
recorded.async_calls[#recorded.async_calls + 1] = command
|
||||||
if callback then
|
if callback then
|
||||||
|
local args = command.args or {}
|
||||||
|
if args[1] == "ps" then
|
||||||
|
callback(true, { status = 0, stdout = config.process_list or "", stderr = "" }, nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if args[1] == "curl" then
|
||||||
|
local url = args[#args] or ""
|
||||||
|
if type(url) == "string" and url:find("myanimelist", 1, true) then
|
||||||
|
callback(true, { status = 0, stdout = config.mal_lookup_stdout or "{}", stderr = "" }, nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(url) == "string" and url:find("api.aniskip.com", 1, true) then
|
||||||
|
callback(true, { status = 0, stdout = config.aniskip_stdout or "{}", stderr = "" }, nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
callback(true, { status = 0, stdout = "", stderr = "" }, nil)
|
callback(true, { status = 0, stdout = "", stderr = "" }, nil)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -67,12 +100,18 @@ local function run_plugin_scenario(config)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function mp.add_key_binding(_keys, _name, _fn) end
|
function mp.add_key_binding(_keys, _name, _fn) end
|
||||||
function mp.register_event(_name, _fn) end
|
function mp.register_event(name, fn)
|
||||||
|
if recorded.events[name] == nil then
|
||||||
|
recorded.events[name] = {}
|
||||||
|
end
|
||||||
|
recorded.events[name][#recorded.events[name] + 1] = fn
|
||||||
|
end
|
||||||
function mp.add_hook(_name, _prio, _fn) end
|
function mp.add_hook(_name, _prio, _fn) end
|
||||||
function mp.observe_property(_name, _kind, _fn) end
|
function mp.observe_property(_name, _kind, _fn) end
|
||||||
function mp.osd_message(message, _duration)
|
function mp.osd_message(message, _duration)
|
||||||
recorded.osd[#recorded.osd + 1] = message
|
recorded.osd[#recorded.osd + 1] = message
|
||||||
end
|
end
|
||||||
|
function mp.set_osd_ass(...) end
|
||||||
function mp.get_time()
|
function mp.get_time()
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
@@ -90,8 +129,8 @@ local function run_plugin_scenario(config)
|
|||||||
local utils = {}
|
local utils = {}
|
||||||
|
|
||||||
function options.read_options(target, _name)
|
function options.read_options(target, _name)
|
||||||
if config.socket_path then
|
for key, value in pairs(config.option_overrides or {}) do
|
||||||
target.socket_path = config.socket_path
|
target[key] = value
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -108,7 +147,35 @@ local function run_plugin_scenario(config)
|
|||||||
return table.concat(parts, "/")
|
return table.concat(parts, "/")
|
||||||
end
|
end
|
||||||
|
|
||||||
function utils.parse_json(_json)
|
function utils.parse_json(json)
|
||||||
|
if json == "__MAL_FOUND__" then
|
||||||
|
return {
|
||||||
|
categories = {
|
||||||
|
{
|
||||||
|
items = {
|
||||||
|
{
|
||||||
|
id = 99,
|
||||||
|
name = "Sample Show",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
end
|
||||||
|
if json == "__ANISKIP_FOUND__" then
|
||||||
|
return {
|
||||||
|
found = true,
|
||||||
|
results = {
|
||||||
|
{
|
||||||
|
skip_type = "op",
|
||||||
|
interval = {
|
||||||
|
start_time = 12.3,
|
||||||
|
end_time = 45.6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
end
|
||||||
return {}, nil
|
return {}, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -149,7 +216,7 @@ local function run_plugin_scenario(config)
|
|||||||
return utils
|
return utils
|
||||||
end
|
end
|
||||||
|
|
||||||
local ok, err = pcall(dofile, "plugin/subminer.lua")
|
local ok, err = pcall(dofile, "plugin/subminer/main.lua")
|
||||||
if not ok then
|
if not ok then
|
||||||
return nil, err, recorded
|
return nil, err, recorded
|
||||||
end
|
end
|
||||||
@@ -168,6 +235,49 @@ local function find_start_call(async_calls)
|
|||||||
local args = call.args or {}
|
local args = call.args or {}
|
||||||
for i = 1, #args do
|
for i = 1, #args do
|
||||||
if args[i] == "--start" then
|
if args[i] == "--start" then
|
||||||
|
return call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function call_has_arg(call, target)
|
||||||
|
local args = (call and call.args) or {}
|
||||||
|
for _, value in ipairs(args) do
|
||||||
|
if value == target then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_sync_command(sync_calls, executable)
|
||||||
|
for _, call in ipairs(sync_calls) do
|
||||||
|
local args = call.args or {}
|
||||||
|
if args[1] == executable then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_async_command(async_calls, executable)
|
||||||
|
for _, call in ipairs(async_calls) do
|
||||||
|
local args = call.args or {}
|
||||||
|
if args[1] == executable then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_async_curl_for(async_calls, needle)
|
||||||
|
for _, call in ipairs(async_calls) do
|
||||||
|
local args = call.args or {}
|
||||||
|
if args[1] == "curl" then
|
||||||
|
local url = args[#args] or ""
|
||||||
|
if type(url) == "string" and url:find(needle, 1, true) then
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -175,11 +285,22 @@ local function find_start_call(async_calls)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function fire_event(recorded, name)
|
||||||
|
local listeners = recorded.events[name] or {}
|
||||||
|
for _, listener in ipairs(listeners) do
|
||||||
|
listener()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local binary_path = "/tmp/subminer-binary"
|
local binary_path = "/tmp/subminer-binary"
|
||||||
|
|
||||||
do
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "no",
|
||||||
|
},
|
||||||
files = {
|
files = {
|
||||||
[binary_path] = true,
|
[binary_path] = true,
|
||||||
},
|
},
|
||||||
@@ -187,7 +308,144 @@ do
|
|||||||
assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err))
|
assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err))
|
||||||
assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered")
|
assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered")
|
||||||
recorded.script_messages["subminer-start"]("texthooker=no")
|
recorded.script_messages["subminer-start"]("texthooker=no")
|
||||||
assert_true(find_start_call(recorded.async_calls), "expected cold-start to invoke --start command when process is absent")
|
assert_true(find_start_call(recorded.async_calls) ~= nil, "expected cold-start to invoke --start command when process is absent")
|
||||||
|
assert_true(
|
||||||
|
not has_sync_command(recorded.sync_calls, "ps"),
|
||||||
|
"expected cold-start start command to avoid synchronous process list scan"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "no",
|
||||||
|
},
|
||||||
|
media_title = "Random Movie",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for non-subminer file-load scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
assert_true(not has_sync_command(recorded.sync_calls, "ps"), "file-loaded should avoid synchronous process checks")
|
||||||
|
assert_true(not has_sync_command(recorded.sync_calls, "curl"), "file-loaded should avoid synchronous AniSkip network calls")
|
||||||
|
assert_true(
|
||||||
|
not has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
|
||||||
|
"file-loaded without SubMiner context should skip AniSkip MAL lookup"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
not has_async_curl_for(recorded.async_calls, "api.aniskip.com"),
|
||||||
|
"file-loaded without SubMiner context should skip AniSkip API lookup"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "no",
|
||||||
|
},
|
||||||
|
media_title = "Sample Show S01E01",
|
||||||
|
mal_lookup_stdout = "__MAL_FOUND__",
|
||||||
|
aniskip_stdout = "__ANISKIP_FOUND__",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for script-message AniSkip scenario: " .. tostring(err))
|
||||||
|
assert_true(recorded.script_messages["subminer-aniskip-refresh"] ~= nil, "subminer-aniskip-refresh script message not registered")
|
||||||
|
recorded.script_messages["subminer-aniskip-refresh"]()
|
||||||
|
assert_true(not has_sync_command(recorded.sync_calls, "curl"), "AniSkip refresh should not perform synchronous curl calls")
|
||||||
|
assert_true(has_async_command(recorded.async_calls, "curl"), "AniSkip refresh should perform async curl calls")
|
||||||
|
assert_true(
|
||||||
|
has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
|
||||||
|
"AniSkip refresh should perform MAL lookup even when app is not running"
|
||||||
|
)
|
||||||
|
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 visible auto-start scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
local start_call = find_start_call(recorded.async_calls)
|
||||||
|
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
||||||
|
assert_true(
|
||||||
|
call_has_arg(start_call, "--show-visible-overlay"),
|
||||||
|
"auto-start with visible overlay enabled should pass --show-visible-overlay"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
not call_has_arg(start_call, "--hide-visible-overlay"),
|
||||||
|
"auto-start with visible overlay enabled should not pass --hide-visible-overlay"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "yes",
|
||||||
|
auto_start_visible_overlay = "no",
|
||||||
|
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 hidden auto-start scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
local start_call = find_start_call(recorded.async_calls)
|
||||||
|
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
||||||
|
assert_true(
|
||||||
|
call_has_arg(start_call, "--hide-visible-overlay"),
|
||||||
|
"auto-start with visible overlay disabled should pass --hide-visible-overlay"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
not call_has_arg(start_call, "--show-visible-overlay"),
|
||||||
|
"auto-start with visible overlay disabled should not pass --show-visible-overlay"
|
||||||
|
)
|
||||||
|
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/other.sock",
|
||||||
|
media_title = "Random Movie",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for mismatched socket auto-start scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
local start_call = find_start_call(recorded.async_calls)
|
||||||
|
assert_true(
|
||||||
|
start_call == nil,
|
||||||
|
"auto-start should be skipped when mpv input-ipc-server does not match configured socket_path"
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
print("plugin start gate regression tests: OK")
|
print("plugin start gate regression tests: OK")
|
||||||
|
|||||||
@@ -209,6 +209,24 @@ test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
|
||||||
|
const integration = new AnkiIntegration(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
proxy: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const privateState = integration as unknown as {
|
||||||
|
proxyServer: unknown | null;
|
||||||
|
};
|
||||||
|
assert.equal(privateState.proxyServer, null);
|
||||||
|
});
|
||||||
|
|
||||||
test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => {
|
test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => {
|
||||||
const collaborator = createFieldGroupingMergeCollaborator();
|
const collaborator = createFieldGroupingMergeCollaborator();
|
||||||
|
|
||||||
@@ -266,3 +284,35 @@ test('FieldGroupingMergeCollaborator uses generated media fallback when source l
|
|||||||
|
|
||||||
assert.equal(merged.SentenceAudio, '<span data-group-id="22">[sound:generated.mp3]</span>');
|
assert.equal(merged.SentenceAudio, '<span data-group-id="22">[sound:generated.mp3]</span>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('FieldGroupingMergeCollaborator deduplicates identical sentence, audio, and image values when merging into a new duplicate card', async () => {
|
||||||
|
const collaborator = createFieldGroupingMergeCollaborator();
|
||||||
|
|
||||||
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||||
|
202,
|
||||||
|
101,
|
||||||
|
{
|
||||||
|
noteId: 202,
|
||||||
|
fields: {
|
||||||
|
Sentence: { value: 'same sentence' },
|
||||||
|
SentenceAudio: { value: '[sound:same.mp3]' },
|
||||||
|
Picture: { value: '<img src="same.png">' },
|
||||||
|
ExpressionAudio: { value: '[sound:same.mp3]' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
noteId: 101,
|
||||||
|
fields: {
|
||||||
|
Sentence: { value: 'same sentence' },
|
||||||
|
SentenceAudio: { value: '[sound:same.mp3]' },
|
||||||
|
Picture: { value: '<img src="same.png">' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(merged.Sentence, '<span data-group-id="202">same sentence</span>');
|
||||||
|
assert.equal(merged.SentenceAudio, '<span data-group-id="202">[sound:same.mp3]</span>');
|
||||||
|
assert.equal(merged.Picture, '<img data-group-id="202" src="same.png">');
|
||||||
|
assert.equal(merged.ExpressionAudio, merged.SentenceAudio);
|
||||||
|
});
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
} from './anki-integration/ui-feedback';
|
} from './anki-integration/ui-feedback';
|
||||||
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
||||||
import { PollingRunner } from './anki-integration/polling';
|
import { PollingRunner } from './anki-integration/polling';
|
||||||
|
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
||||||
import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from './anki-integration/duplicate';
|
import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from './anki-integration/duplicate';
|
||||||
import { CardCreationService } from './anki-integration/card-creation';
|
import { CardCreationService } from './anki-integration/card-creation';
|
||||||
import { FieldGroupingService } from './anki-integration/field-grouping';
|
import { FieldGroupingService } from './anki-integration/field-grouping';
|
||||||
@@ -63,6 +64,8 @@ export class AnkiIntegration {
|
|||||||
private timingTracker: SubtitleTimingTracker;
|
private timingTracker: SubtitleTimingTracker;
|
||||||
private config: AnkiConnectConfig;
|
private config: AnkiConnectConfig;
|
||||||
private pollingRunner!: PollingRunner;
|
private pollingRunner!: PollingRunner;
|
||||||
|
private proxyServer: AnkiConnectProxyServer | null = null;
|
||||||
|
private started = false;
|
||||||
private previousNoteIds = new Set<number>();
|
private previousNoteIds = new Set<number>();
|
||||||
private mpvClient: MpvClient;
|
private mpvClient: MpvClient;
|
||||||
private osdCallback: ((text: string) => void) | null = null;
|
private osdCallback: ((text: string) => void) | null = null;
|
||||||
@@ -131,13 +134,46 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private normalizeConfig(config: AnkiConnectConfig): AnkiConnectConfig {
|
private normalizeConfig(config: AnkiConnectConfig): AnkiConnectConfig {
|
||||||
|
const resolvedUrl =
|
||||||
|
typeof config.url === 'string' && config.url.trim().length > 0
|
||||||
|
? config.url.trim()
|
||||||
|
: DEFAULT_ANKI_CONNECT_CONFIG.url;
|
||||||
|
const proxySource =
|
||||||
|
config.proxy && typeof config.proxy === 'object'
|
||||||
|
? (config.proxy as NonNullable<AnkiConnectConfig['proxy']>)
|
||||||
|
: {};
|
||||||
|
const normalizedProxyPort =
|
||||||
|
typeof proxySource.port === 'number' &&
|
||||||
|
Number.isInteger(proxySource.port) &&
|
||||||
|
proxySource.port >= 1 &&
|
||||||
|
proxySource.port <= 65535
|
||||||
|
? proxySource.port
|
||||||
|
: DEFAULT_ANKI_CONNECT_CONFIG.proxy?.port;
|
||||||
|
const normalizedProxyHost =
|
||||||
|
typeof proxySource.host === 'string' && proxySource.host.trim().length > 0
|
||||||
|
? proxySource.host.trim()
|
||||||
|
: DEFAULT_ANKI_CONNECT_CONFIG.proxy?.host;
|
||||||
|
const normalizedProxyUpstreamUrl =
|
||||||
|
typeof proxySource.upstreamUrl === 'string' && proxySource.upstreamUrl.trim().length > 0
|
||||||
|
? proxySource.upstreamUrl.trim()
|
||||||
|
: resolvedUrl;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...DEFAULT_ANKI_CONNECT_CONFIG,
|
...DEFAULT_ANKI_CONNECT_CONFIG,
|
||||||
...config,
|
...config,
|
||||||
|
url: resolvedUrl,
|
||||||
fields: {
|
fields: {
|
||||||
...DEFAULT_ANKI_CONNECT_CONFIG.fields,
|
...DEFAULT_ANKI_CONNECT_CONFIG.fields,
|
||||||
...(config.fields ?? {}),
|
...(config.fields ?? {}),
|
||||||
},
|
},
|
||||||
|
proxy: {
|
||||||
|
...DEFAULT_ANKI_CONNECT_CONFIG.proxy,
|
||||||
|
...(config.proxy ?? {}),
|
||||||
|
enabled: proxySource.enabled === true,
|
||||||
|
host: normalizedProxyHost,
|
||||||
|
port: normalizedProxyPort,
|
||||||
|
upstreamUrl: normalizedProxyUpstreamUrl,
|
||||||
|
},
|
||||||
ai: {
|
ai: {
|
||||||
...DEFAULT_ANKI_CONNECT_CONFIG.ai,
|
...DEFAULT_ANKI_CONNECT_CONFIG.ai,
|
||||||
...(config.openRouter ?? {}),
|
...(config.openRouter ?? {}),
|
||||||
@@ -202,6 +238,27 @@ export class AnkiIntegration {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createProxyServer(): AnkiConnectProxyServer {
|
||||||
|
const { AnkiConnectProxyServer } = require('./anki-integration/anki-connect-proxy') as typeof import('./anki-integration/anki-connect-proxy');
|
||||||
|
return new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
|
||||||
|
processNewCard: (noteId: number) => this.processNewCard(noteId),
|
||||||
|
getDeck: () => this.config.deck,
|
||||||
|
findNotes: async (query, options) =>
|
||||||
|
(await this.client.findNotes(query, options)) as number[],
|
||||||
|
logInfo: (message, ...args) => log.info(message, ...args),
|
||||||
|
logWarn: (message, ...args) => log.warn(message, ...args),
|
||||||
|
logError: (message, ...args) => log.error(message, ...args),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrCreateProxyServer(): AnkiConnectProxyServer {
|
||||||
|
if (!this.proxyServer) {
|
||||||
|
this.proxyServer = this.createProxyServer();
|
||||||
|
}
|
||||||
|
return this.proxyServer;
|
||||||
|
}
|
||||||
|
|
||||||
private createCardCreationService(): CardCreationService {
|
private createCardCreationService(): CardCreationService {
|
||||||
return new CardCreationService({
|
return new CardCreationService({
|
||||||
getConfig: () => this.config,
|
getConfig: () => this.config,
|
||||||
@@ -499,19 +556,63 @@ export class AnkiIntegration {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
private isProxyTransportEnabled(config: AnkiConnectConfig = this.config): boolean {
|
||||||
if (this.pollingRunner.isRunning) {
|
return config.proxy?.enabled === true;
|
||||||
this.stop();
|
}
|
||||||
|
|
||||||
|
private getTransportConfigKey(config: AnkiConnectConfig = this.config): string {
|
||||||
|
if (this.isProxyTransportEnabled(config)) {
|
||||||
|
return [
|
||||||
|
'proxy',
|
||||||
|
config.proxy?.host ?? '',
|
||||||
|
String(config.proxy?.port ?? ''),
|
||||||
|
config.proxy?.upstreamUrl ?? '',
|
||||||
|
].join(':');
|
||||||
|
}
|
||||||
|
return ['polling', String(config.pollingRate ?? DEFAULT_ANKI_CONNECT_CONFIG.pollingRate)].join(
|
||||||
|
':',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startTransport(): void {
|
||||||
|
if (this.isProxyTransportEnabled()) {
|
||||||
|
const proxyHost = this.config.proxy?.host ?? '127.0.0.1';
|
||||||
|
const proxyPort = this.config.proxy?.port ?? 8766;
|
||||||
|
const upstreamUrl = this.config.proxy?.upstreamUrl ?? this.config.url ?? '';
|
||||||
|
this.getOrCreateProxyServer().start({
|
||||||
|
host: proxyHost,
|
||||||
|
port: proxyPort,
|
||||||
|
upstreamUrl,
|
||||||
|
});
|
||||||
|
log.info(
|
||||||
|
`Starting AnkiConnect integration with local proxy: http://${proxyHost}:${proxyPort} -> ${upstreamUrl}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('Starting AnkiConnect integration with polling rate:', this.config.pollingRate);
|
log.info('Starting AnkiConnect integration with polling rate:', this.config.pollingRate);
|
||||||
this.startKnownWordCacheLifecycle();
|
|
||||||
this.pollingRunner.start();
|
this.pollingRunner.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
private stopTransport(): void {
|
||||||
this.pollingRunner.stop();
|
this.pollingRunner.stop();
|
||||||
|
this.proxyServer?.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.started) {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.startKnownWordCacheLifecycle();
|
||||||
|
this.startTransport();
|
||||||
|
this.started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopTransport();
|
||||||
this.stopKnownWordCacheLifecycle();
|
this.stopKnownWordCacheLifecycle();
|
||||||
|
this.started = false;
|
||||||
log.info('Stopped AnkiConnect integration');
|
log.info('Stopped AnkiConnect integration');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1062,8 +1163,9 @@ export class AnkiIntegration {
|
|||||||
|
|
||||||
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
||||||
const wasEnabled = this.config.nPlusOne?.highlightEnabled === true;
|
const wasEnabled = this.config.nPlusOne?.highlightEnabled === true;
|
||||||
const previousPollingRate = this.config.pollingRate;
|
const previousTransportKey = this.getTransportConfigKey(this.config);
|
||||||
this.config = {
|
|
||||||
|
const mergedConfig: AnkiConnectConfig = {
|
||||||
...this.config,
|
...this.config,
|
||||||
...patch,
|
...patch,
|
||||||
nPlusOne:
|
nPlusOne:
|
||||||
@@ -1083,6 +1185,8 @@ export class AnkiIntegration {
|
|||||||
patch.behavior !== undefined
|
patch.behavior !== undefined
|
||||||
? { ...this.config.behavior, ...patch.behavior }
|
? { ...this.config.behavior, ...patch.behavior }
|
||||||
: this.config.behavior,
|
: this.config.behavior,
|
||||||
|
proxy:
|
||||||
|
patch.proxy !== undefined ? { ...this.config.proxy, ...patch.proxy } : this.config.proxy,
|
||||||
metadata:
|
metadata:
|
||||||
patch.metadata !== undefined
|
patch.metadata !== undefined
|
||||||
? { ...this.config.metadata, ...patch.metadata }
|
? { ...this.config.metadata, ...patch.metadata }
|
||||||
@@ -1096,6 +1200,7 @@ export class AnkiIntegration {
|
|||||||
? { ...this.config.isKiku, ...patch.isKiku }
|
? { ...this.config.isKiku, ...patch.isKiku }
|
||||||
: this.config.isKiku,
|
: this.config.isKiku,
|
||||||
};
|
};
|
||||||
|
this.config = this.normalizeConfig(mergedConfig);
|
||||||
|
|
||||||
if (wasEnabled && this.config.nPlusOne?.highlightEnabled === false) {
|
if (wasEnabled && this.config.nPlusOne?.highlightEnabled === false) {
|
||||||
this.stopKnownWordCacheLifecycle();
|
this.stopKnownWordCacheLifecycle();
|
||||||
@@ -1104,12 +1209,10 @@ export class AnkiIntegration {
|
|||||||
this.startKnownWordCacheLifecycle();
|
this.startKnownWordCacheLifecycle();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const nextTransportKey = this.getTransportConfigKey(this.config);
|
||||||
patch.pollingRate !== undefined &&
|
if (this.started && previousTransportKey !== nextTransportKey) {
|
||||||
previousPollingRate !== this.config.pollingRate &&
|
this.stopTransport();
|
||||||
this.pollingRunner.isRunning
|
this.startTransport();
|
||||||
) {
|
|
||||||
this.pollingRunner.start();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
320
src/anki-integration/anki-connect-proxy.test.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { AnkiConnectProxyServer } from './anki-connect-proxy';
|
||||||
|
|
||||||
|
async function waitForCondition(
|
||||||
|
condition: () => boolean,
|
||||||
|
timeoutMs = 2000,
|
||||||
|
intervalMs = 10,
|
||||||
|
): Promise<void> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
if (condition()) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||||
|
}
|
||||||
|
throw new Error('Timed out waiting for condition');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('proxy enqueues addNote result for enrichment', async () => {
|
||||||
|
const processed: number[] = [];
|
||||||
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => true,
|
||||||
|
processNewCard: async (noteId) => {
|
||||||
|
processed.push(noteId);
|
||||||
|
},
|
||||||
|
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: 42, error: null }), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForCondition(() => processed.length === 1);
|
||||||
|
assert.deepEqual(processed, [42]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy enqueues addNote bare numeric response for enrichment', async () => {
|
||||||
|
const processed: number[] = [];
|
||||||
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => true,
|
||||||
|
processNewCard: async (noteId) => {
|
||||||
|
processed.push(noteId);
|
||||||
|
},
|
||||||
|
logInfo: () => undefined,
|
||||||
|
logWarn: () => undefined,
|
||||||
|
logError: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
(proxy as unknown as {
|
||||||
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
|
}).maybeEnqueueFromRequest({ action: 'addNote' }, Buffer.from('42', 'utf8'));
|
||||||
|
|
||||||
|
await waitForCondition(() => processed.length === 1);
|
||||||
|
assert.deepEqual(processed, [42]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy de-duplicates addNotes IDs within the same response', async () => {
|
||||||
|
const processed: number[] = [];
|
||||||
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => true,
|
||||||
|
processNewCard: async (noteId) => {
|
||||||
|
processed.push(noteId);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
},
|
||||||
|
logInfo: () => undefined,
|
||||||
|
logWarn: () => undefined,
|
||||||
|
logError: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
(proxy as unknown as {
|
||||||
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
|
}).maybeEnqueueFromRequest(
|
||||||
|
{ action: 'addNotes' },
|
||||||
|
Buffer.from(JSON.stringify({ result: [101, 102, 101, null], error: null }), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForCondition(() => processed.length === 2);
|
||||||
|
assert.deepEqual(processed, [101, 102]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy enqueues note IDs from multi action addNote/addNotes results', async () => {
|
||||||
|
const processed: number[] = [];
|
||||||
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => true,
|
||||||
|
processNewCard: async (noteId) => {
|
||||||
|
processed.push(noteId);
|
||||||
|
},
|
||||||
|
logInfo: () => undefined,
|
||||||
|
logWarn: () => undefined,
|
||||||
|
logError: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
(proxy as unknown as {
|
||||||
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
|
}).maybeEnqueueFromRequest(
|
||||||
|
{
|
||||||
|
action: 'multi',
|
||||||
|
params: {
|
||||||
|
actions: [
|
||||||
|
{ action: 'version' },
|
||||||
|
{ action: 'addNote' },
|
||||||
|
{ action: 'addNotes' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Buffer.from(JSON.stringify({ result: [6, 777, [888, 777, null]], error: null }), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForCondition(() => processed.length === 2);
|
||||||
|
assert.deepEqual(processed, [777, 888]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy enqueues note IDs from bare multi action results', async () => {
|
||||||
|
const processed: number[] = [];
|
||||||
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => true,
|
||||||
|
processNewCard: async (noteId) => {
|
||||||
|
processed.push(noteId);
|
||||||
|
},
|
||||||
|
logInfo: () => undefined,
|
||||||
|
logWarn: () => undefined,
|
||||||
|
logError: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
(proxy as unknown as {
|
||||||
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
|
}).maybeEnqueueFromRequest(
|
||||||
|
{
|
||||||
|
action: 'multi',
|
||||||
|
params: {
|
||||||
|
actions: [{ action: 'version' }, { action: 'addNote' }, { action: 'addNotes' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Buffer.from(JSON.stringify([6, 777, [888, null]]), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForCondition(() => processed.length === 2);
|
||||||
|
assert.deepEqual(processed, [777, 888]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy enqueues note IDs from multi action envelope results', async () => {
|
||||||
|
const processed: number[] = [];
|
||||||
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => true,
|
||||||
|
processNewCard: async (noteId) => {
|
||||||
|
processed.push(noteId);
|
||||||
|
},
|
||||||
|
logInfo: () => undefined,
|
||||||
|
logWarn: () => undefined,
|
||||||
|
logError: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
(proxy as unknown as {
|
||||||
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
|
}).maybeEnqueueFromRequest(
|
||||||
|
{
|
||||||
|
action: 'multi',
|
||||||
|
params: {
|
||||||
|
actions: [
|
||||||
|
{ action: 'version' },
|
||||||
|
{ action: 'addNote' },
|
||||||
|
{ action: 'addNotes' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
result: [
|
||||||
|
{ result: 6, error: null },
|
||||||
|
{ result: 777, error: null },
|
||||||
|
{ result: [888, 777, null], error: null },
|
||||||
|
],
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForCondition(() => processed.length === 2);
|
||||||
|
assert.deepEqual(processed, [777, 888]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy skips auto-enrichment when auto-update is disabled', async () => {
|
||||||
|
const processed: number[] = [];
|
||||||
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => false,
|
||||||
|
processNewCard: async (noteId) => {
|
||||||
|
processed.push(noteId);
|
||||||
|
},
|
||||||
|
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: 303, error: null }), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
assert.deepEqual(processed, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy ignores addNote when upstream response reports error', async () => {
|
||||||
|
const processed: number[] = [];
|
||||||
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => true,
|
||||||
|
processNewCard: async (noteId) => {
|
||||||
|
processed.push(noteId);
|
||||||
|
},
|
||||||
|
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: 123, error: 'duplicate' }), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
assert.deepEqual(processed, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy falls back to latest added note when addNote returns no IDs', 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: [], error: null }), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForCondition(() => processed.length === 1);
|
||||||
|
assert.deepEqual(findNotesQueries, ['"deck:My \\"Japanese\\" Deck" added:1']);
|
||||||
|
assert.deepEqual(processed, [501]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy does not fallback-enqueue latest note for multi requests without add actions', async () => {
|
||||||
|
const processed: number[] = [];
|
||||||
|
const findNotesQueries: string[] = [];
|
||||||
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => true,
|
||||||
|
processNewCard: async (noteId) => {
|
||||||
|
processed.push(noteId);
|
||||||
|
},
|
||||||
|
getDeck: () => 'Mining',
|
||||||
|
findNotes: async (query) => {
|
||||||
|
findNotesQueries.push(query);
|
||||||
|
return [999];
|
||||||
|
},
|
||||||
|
logInfo: () => undefined,
|
||||||
|
logWarn: () => undefined,
|
||||||
|
logError: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
(proxy as unknown as {
|
||||||
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
|
}).maybeEnqueueFromRequest(
|
||||||
|
{
|
||||||
|
action: 'multi',
|
||||||
|
params: {
|
||||||
|
actions: [{ action: 'version' }, { action: 'deckNames' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Buffer.from(JSON.stringify({ result: [6, ['Default']], error: null }), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
assert.deepEqual(findNotesQueries, []);
|
||||||
|
assert.deepEqual(processed, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy detects self-referential loop configuration', () => {
|
||||||
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => true,
|
||||||
|
processNewCard: async () => undefined,
|
||||||
|
logInfo: () => undefined,
|
||||||
|
logWarn: () => undefined,
|
||||||
|
logError: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = (proxy as unknown as {
|
||||||
|
isSelfReferentialProxy: (options: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
upstreamUrl: string;
|
||||||
|
}) => boolean;
|
||||||
|
}).isSelfReferentialProxy({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 8766,
|
||||||
|
upstreamUrl: 'http://localhost:8766',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, true);
|
||||||
|
});
|
||||||
463
src/anki-integration/anki-connect-proxy.ts
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
import http, { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
interface StartProxyOptions {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
upstreamUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnkiConnectEnvelope {
|
||||||
|
result: unknown;
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnkiConnectProxyServerDeps {
|
||||||
|
shouldAutoUpdateNewCards: () => boolean;
|
||||||
|
processNewCard: (noteId: number) => Promise<void>;
|
||||||
|
getDeck?: () => string | undefined;
|
||||||
|
findNotes?: (
|
||||||
|
query: string,
|
||||||
|
options?: {
|
||||||
|
maxRetries?: number;
|
||||||
|
},
|
||||||
|
) => Promise<number[]>;
|
||||||
|
logInfo: (message: string, ...args: unknown[]) => void;
|
||||||
|
logWarn: (message: string, ...args: unknown[]) => void;
|
||||||
|
logError: (message: string, ...args: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnkiConnectProxyServer {
|
||||||
|
private server: http.Server | null = null;
|
||||||
|
private client: AxiosInstance;
|
||||||
|
private pendingNoteIds: number[] = [];
|
||||||
|
private pendingNoteIdSet = new Set<number>();
|
||||||
|
private inFlightNoteIds = new Set<number>();
|
||||||
|
private processingQueue = false;
|
||||||
|
|
||||||
|
constructor(private readonly deps: AnkiConnectProxyServerDeps) {
|
||||||
|
this.client = axios.create({
|
||||||
|
timeout: 15000,
|
||||||
|
validateStatus: () => true,
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRunning(): boolean {
|
||||||
|
return this.server !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(options: StartProxyOptions): void {
|
||||||
|
this.stop();
|
||||||
|
|
||||||
|
if (this.isSelfReferentialProxy(options)) {
|
||||||
|
this.deps.logError(
|
||||||
|
'[anki-proxy] Proxy upstream points to proxy host/port; refusing to start to avoid loop.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.server = http.createServer((req, res) => {
|
||||||
|
void this.handleRequest(req, res, options.upstreamUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.on('error', (error) => {
|
||||||
|
this.deps.logError('[anki-proxy] Server error:', (error as Error).message);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.listen(options.port, options.host, () => {
|
||||||
|
this.deps.logInfo(
|
||||||
|
`[anki-proxy] Listening on http://${options.host}:${options.port} -> ${options.upstreamUrl}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.close();
|
||||||
|
this.server = null;
|
||||||
|
this.deps.logInfo('[anki-proxy] Stopped');
|
||||||
|
}
|
||||||
|
this.pendingNoteIds = [];
|
||||||
|
this.pendingNoteIdSet.clear();
|
||||||
|
this.inFlightNoteIds.clear();
|
||||||
|
this.processingQueue = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSelfReferentialProxy(options: StartProxyOptions): boolean {
|
||||||
|
try {
|
||||||
|
const upstream = new URL(options.upstreamUrl);
|
||||||
|
const normalizedUpstreamHost = upstream.hostname.toLowerCase();
|
||||||
|
const normalizedBindHost = options.host.toLowerCase();
|
||||||
|
const upstreamPort =
|
||||||
|
upstream.port.length > 0
|
||||||
|
? Number(upstream.port)
|
||||||
|
: upstream.protocol === 'https:'
|
||||||
|
? 443
|
||||||
|
: 80;
|
||||||
|
const hostMatches =
|
||||||
|
normalizedUpstreamHost === normalizedBindHost ||
|
||||||
|
(normalizedUpstreamHost === 'localhost' && normalizedBindHost === '127.0.0.1') ||
|
||||||
|
(normalizedUpstreamHost === '127.0.0.1' && normalizedBindHost === 'localhost');
|
||||||
|
return hostMatches && upstreamPort === options.port;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRequest(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse<IncomingMessage>,
|
||||||
|
upstreamUrl: string,
|
||||||
|
): Promise<void> {
|
||||||
|
this.setCorsHeaders(res);
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.statusCode = 204;
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.method || (req.method !== 'GET' && req.method !== 'POST')) {
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.end('Method Not Allowed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawBody: Buffer = Buffer.alloc(0);
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
rawBody = await this.readRequestBody(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestJson: Record<string, unknown> | null = null;
|
||||||
|
if (req.method === 'POST' && rawBody.length > 0) {
|
||||||
|
requestJson = this.tryParseJson(rawBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetUrl = new URL(req.url || '/', upstreamUrl).toString();
|
||||||
|
const contentType =
|
||||||
|
typeof req.headers['content-type'] === 'string'
|
||||||
|
? req.headers['content-type']
|
||||||
|
: 'application/json';
|
||||||
|
const upstreamResponse = await this.client.request<ArrayBuffer>({
|
||||||
|
url: targetUrl,
|
||||||
|
method: req.method,
|
||||||
|
data: req.method === 'POST' ? rawBody : undefined,
|
||||||
|
headers: {
|
||||||
|
'content-type': contentType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody: Buffer = Buffer.isBuffer(upstreamResponse.data)
|
||||||
|
? upstreamResponse.data
|
||||||
|
: Buffer.from(new Uint8Array(upstreamResponse.data));
|
||||||
|
this.copyUpstreamHeaders(res, upstreamResponse.headers as Record<string, unknown>);
|
||||||
|
res.statusCode = upstreamResponse.status;
|
||||||
|
res.end(responseBody);
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
this.maybeEnqueueFromRequest(requestJson, responseBody);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.deps.logWarn('[anki-proxy] Failed to forward request:', (error as Error).message);
|
||||||
|
res.statusCode = 502;
|
||||||
|
res.end('Bad Gateway');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private maybeEnqueueFromRequest(
|
||||||
|
requestJson: Record<string, unknown> | null,
|
||||||
|
responseBody: Buffer,
|
||||||
|
): void {
|
||||||
|
if (!requestJson || !this.deps.shouldAutoUpdateNewCards()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action =
|
||||||
|
typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? '');
|
||||||
|
if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const shouldFallbackToLatestAdded = this.requestIncludesAddAction(action, requestJson);
|
||||||
|
|
||||||
|
const parsedResponse = this.tryParseJsonValue(responseBody);
|
||||||
|
if (parsedResponse === null || parsedResponse === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseResult = this.extractSuccessfulResult(parsedResponse);
|
||||||
|
if (responseResult === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteIds =
|
||||||
|
action === 'multi'
|
||||||
|
? this.collectMultiResultIds(requestJson, responseResult)
|
||||||
|
: this.collectNoteIdsForAction(action, responseResult);
|
||||||
|
if (noteIds.length === 0 && shouldFallbackToLatestAdded) {
|
||||||
|
void this.enqueueMostRecentAddedNote();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.enqueueNotes(noteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean {
|
||||||
|
if (action === 'addNote' || action === 'addNotes') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action !== 'multi') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const params =
|
||||||
|
requestJson.params && typeof requestJson.params === 'object'
|
||||||
|
? (requestJson.params as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const actions = Array.isArray(params?.actions) ? params.actions : [];
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return actions.some((entry) => {
|
||||||
|
if (!entry || typeof entry !== 'object') return false;
|
||||||
|
const actionName = (entry as Record<string, unknown>).action;
|
||||||
|
return actionName === 'addNote' || actionName === 'addNotes';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enqueueMostRecentAddedNote(): Promise<void> {
|
||||||
|
const findNotes = this.deps.findNotes;
|
||||||
|
if (!findNotes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deck = this.deps.getDeck ? this.deps.getDeck() : undefined;
|
||||||
|
const escapedDeck = deck ? deck.replace(/"/g, '\\"') : null;
|
||||||
|
const query = escapedDeck ? `"deck:${escapedDeck}" added:1` : 'added:1';
|
||||||
|
const noteIds = await findNotes(query, { maxRetries: 0 });
|
||||||
|
if (!noteIds || noteIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const latestNoteId = Math.max(...noteIds);
|
||||||
|
this.deps.logInfo(
|
||||||
|
`[anki-proxy] Falling back to latest added note ${latestNoteId} (response did not include note IDs)`,
|
||||||
|
);
|
||||||
|
this.enqueueNotes([latestNoteId]);
|
||||||
|
} catch (error) {
|
||||||
|
this.deps.logWarn(
|
||||||
|
'[anki-proxy] Failed latest-note fallback lookup:',
|
||||||
|
(error as Error).message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectNoteIdsForAction(action: string, result: unknown): number[] {
|
||||||
|
if (action === 'addNote') {
|
||||||
|
return this.collectSingleResultId(result);
|
||||||
|
}
|
||||||
|
if (action === 'addNotes') {
|
||||||
|
return this.collectBatchResultIds(result);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectMultiResultIds(requestJson: Record<string, unknown>, result: unknown): number[] {
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const params =
|
||||||
|
requestJson.params && typeof requestJson.params === 'object'
|
||||||
|
? (requestJson.params as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const actions = Array.isArray(params?.actions) ? params.actions : [];
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteIds: number[] = [];
|
||||||
|
const count = Math.min(actions.length, result.length);
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const actionEntry = actions[index];
|
||||||
|
if (!actionEntry || typeof actionEntry !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const actionName =
|
||||||
|
typeof (actionEntry as Record<string, unknown>).action === 'string'
|
||||||
|
? ((actionEntry as Record<string, unknown>).action as string)
|
||||||
|
: '';
|
||||||
|
const actionResult = this.extractMultiActionResult(result[index]);
|
||||||
|
if (actionResult === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
noteIds.push(...this.collectNoteIdsForAction(actionName, actionResult));
|
||||||
|
}
|
||||||
|
return noteIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractMultiActionResult(value: unknown): unknown | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = value as Record<string, unknown>;
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(envelope, 'result')) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.error !== null && envelope.error !== undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelope.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectSingleResultId(value: unknown): number[] {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value) && value > 0) {
|
||||||
|
return [value];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectBatchResultIds(value: unknown): number[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value.filter((entry): entry is number => {
|
||||||
|
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueueNotes(noteIds: number[]): void {
|
||||||
|
let enqueuedCount = 0;
|
||||||
|
for (const noteId of noteIds) {
|
||||||
|
if (this.pendingNoteIdSet.has(noteId) || this.inFlightNoteIds.has(noteId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.pendingNoteIds.push(noteId);
|
||||||
|
this.pendingNoteIdSet.add(noteId);
|
||||||
|
enqueuedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enqueuedCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deps.logInfo(`[anki-proxy] Enqueued ${enqueuedCount} note(s) for enrichment`);
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private processQueue(): void {
|
||||||
|
if (this.processingQueue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.processingQueue = true;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
while (this.pendingNoteIds.length > 0) {
|
||||||
|
const noteId = this.pendingNoteIds.shift();
|
||||||
|
if (noteId === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.pendingNoteIdSet.delete(noteId);
|
||||||
|
|
||||||
|
if (!this.deps.shouldAutoUpdateNewCards()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inFlightNoteIds.add(noteId);
|
||||||
|
try {
|
||||||
|
await this.deps.processNewCard(noteId);
|
||||||
|
} catch (error) {
|
||||||
|
this.deps.logWarn(
|
||||||
|
`[anki-proxy] Failed to auto-enrich note ${noteId}:`,
|
||||||
|
(error as Error).message,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.inFlightNoteIds.delete(noteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.processingQueue = false;
|
||||||
|
if (this.pendingNoteIds.length > 0) {
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readRequestBody(req: IncomingMessage): Promise<Buffer> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of req) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryParseJson(rawBody: Buffer): Record<string, unknown> | null {
|
||||||
|
if (rawBody.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawBody.toString('utf8'));
|
||||||
|
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryParseJsonValue(rawBody: Buffer): unknown {
|
||||||
|
if (rawBody.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawBody.toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractSuccessfulResult(value: unknown): unknown | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = value as Partial<AnkiConnectEnvelope>;
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(envelope, 'result')) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (envelope.error !== null && envelope.error !== undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return envelope.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCorsHeaders(res: ServerResponse<IncomingMessage>): void {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
||||||
|
}
|
||||||
|
|
||||||
|
private copyUpstreamHeaders(
|
||||||
|
res: ServerResponse<IncomingMessage>,
|
||||||
|
headers: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key.toLowerCase() === 'content-length') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
res.setHeader(
|
||||||
|
key,
|
||||||
|
value.map((entry) => String(entry)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
res.setHeader(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -302,7 +302,7 @@ export class FieldGroupingMergeCollaborator {
|
|||||||
const unique: { groupId: number; content: string }[] = [];
|
const unique: { groupId: number; content: string }[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const key = `${entry.groupId}::${entry.content}`;
|
const key = entry.content;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
unique.push(entry);
|
unique.push(entry);
|
||||||
@@ -361,6 +361,10 @@ export class FieldGroupingMergeCollaborator {
|
|||||||
return ungrouped;
|
return ungrouped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getPictureDedupKey(tag: string): string {
|
||||||
|
return tag.replace(/\sdata-group-id="[^"]*"/gi, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
private getStrictSpanGroupingFields(): Set<string> {
|
private getStrictSpanGroupingFields(): Set<string> {
|
||||||
const strictFields = new Set(this.strictGroupingFieldDefaults);
|
const strictFields = new Set(this.strictGroupingFieldDefaults);
|
||||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||||
@@ -394,11 +398,12 @@ export class FieldGroupingMergeCollaborator {
|
|||||||
const mergedTags = keepEntries.map((entry) =>
|
const mergedTags = keepEntries.map((entry) =>
|
||||||
this.ensureImageGroupId(entry.tag, entry.groupId),
|
this.ensureImageGroupId(entry.tag, entry.groupId),
|
||||||
);
|
);
|
||||||
const seen = new Set(mergedTags);
|
const seen = new Set(mergedTags.map((tag) => this.getPictureDedupKey(tag)));
|
||||||
for (const entry of sourceEntries) {
|
for (const entry of sourceEntries) {
|
||||||
const normalized = this.ensureImageGroupId(entry.tag, entry.groupId);
|
const normalized = this.ensureImageGroupId(entry.tag, entry.groupId);
|
||||||
if (seen.has(normalized)) continue;
|
const dedupKey = this.getPictureDedupKey(normalized);
|
||||||
seen.add(normalized);
|
if (seen.has(dedupKey)) continue;
|
||||||
|
seen.add(dedupKey);
|
||||||
mergedTags.push(normalized);
|
mergedTags.push(normalized);
|
||||||
}
|
}
|
||||||
return mergedTags.join('');
|
return mergedTags.join('');
|
||||||
@@ -415,9 +420,9 @@ export class FieldGroupingMergeCollaborator {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
const merged = [...keepEntries];
|
const merged = [...keepEntries];
|
||||||
const seen = new Set(keepEntries.map((entry) => `${entry.groupId}::${entry.content}`));
|
const seen = new Set(keepEntries.map((entry) => entry.content));
|
||||||
for (const entry of sourceEntries) {
|
for (const entry of sourceEntries) {
|
||||||
const key = `${entry.groupId}::${entry.content}`;
|
const key = entry.content;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
merged.push(entry);
|
merged.push(entry);
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { FieldGroupingWorkflow } from './field-grouping-workflow';
|
import { FieldGroupingWorkflow } from './field-grouping-workflow';
|
||||||
|
import type { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types';
|
||||||
|
|
||||||
type NoteInfo = {
|
type NoteInfo = {
|
||||||
noteId: number;
|
noteId: number;
|
||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ManualChoice = {
|
||||||
|
keepNoteId: number;
|
||||||
|
deleteNoteId: number;
|
||||||
|
deleteDuplicate: boolean;
|
||||||
|
cancelled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FieldGroupingCallback = (data: {
|
||||||
|
original: KikuDuplicateCardInfo;
|
||||||
|
duplicate: KikuDuplicateCardInfo;
|
||||||
|
}) => Promise<KikuFieldGroupingChoice>;
|
||||||
|
|
||||||
function createWorkflowHarness() {
|
function createWorkflowHarness() {
|
||||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||||
const deleted: number[][] = [];
|
const deleted: number[][] = [];
|
||||||
const statuses: string[] = [];
|
const statuses: string[] = [];
|
||||||
|
const mergeCalls: Array<{
|
||||||
|
keepNoteId: number;
|
||||||
|
deleteNoteId: number;
|
||||||
|
keepNoteInfoNoteId: number;
|
||||||
|
deleteNoteInfoNoteId: number;
|
||||||
|
}> = [];
|
||||||
|
let manualChoice: ManualChoice | null = null;
|
||||||
|
|
||||||
const deps = {
|
const deps = {
|
||||||
client: {
|
client: {
|
||||||
@@ -47,11 +67,28 @@ function createWorkflowHarness() {
|
|||||||
kikuDeleteDuplicateInAuto: true,
|
kikuDeleteDuplicateInAuto: true,
|
||||||
}),
|
}),
|
||||||
getCurrentSubtitleText: () => 'subtitle-text',
|
getCurrentSubtitleText: () => 'subtitle-text',
|
||||||
getFieldGroupingCallback: () => null,
|
getFieldGroupingCallback: (): FieldGroupingCallback | null => {
|
||||||
|
const choice = manualChoice;
|
||||||
|
if (choice === null) return null;
|
||||||
|
return async () => choice;
|
||||||
|
},
|
||||||
setFieldGroupingCallback: () => undefined,
|
setFieldGroupingCallback: () => undefined,
|
||||||
computeFieldGroupingMergedFields: async () => ({
|
computeFieldGroupingMergedFields: async (
|
||||||
|
keepNoteId: number,
|
||||||
|
deleteNoteId: number,
|
||||||
|
keepNoteInfo: NoteInfo,
|
||||||
|
deleteNoteInfo: NoteInfo,
|
||||||
|
) => {
|
||||||
|
mergeCalls.push({
|
||||||
|
keepNoteId,
|
||||||
|
deleteNoteId,
|
||||||
|
keepNoteInfoNoteId: keepNoteInfo.noteId,
|
||||||
|
deleteNoteInfoNoteId: deleteNoteInfo.noteId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
Sentence: 'merged sentence',
|
Sentence: 'merged sentence',
|
||||||
}),
|
};
|
||||||
|
},
|
||||||
extractFields: (fields: Record<string, { value: string }>) => {
|
extractFields: (fields: Record<string, { value: string }>) => {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
for (const [key, value] of Object.entries(fields)) {
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
@@ -77,6 +114,10 @@ function createWorkflowHarness() {
|
|||||||
updates,
|
updates,
|
||||||
deleted,
|
deleted,
|
||||||
statuses,
|
statuses,
|
||||||
|
mergeCalls,
|
||||||
|
setManualChoice: (choice: typeof manualChoice) => {
|
||||||
|
manualChoice = choice;
|
||||||
|
},
|
||||||
deps,
|
deps,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -112,3 +153,31 @@ test('FieldGroupingWorkflow manual mode returns false when callback unavailable'
|
|||||||
assert.equal(handled, false);
|
assert.equal(handled, false);
|
||||||
assert.equal(harness.updates.length, 0);
|
assert.equal(harness.updates.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('FieldGroupingWorkflow manual keep-new uses new note as merge target and old note as source', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.setManualChoice({
|
||||||
|
keepNoteId: 2,
|
||||||
|
deleteNoteId: 1,
|
||||||
|
deleteDuplicate: false,
|
||||||
|
cancelled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handled = await harness.workflow.handleManual(1, 2, {
|
||||||
|
noteId: 2,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'word-2' },
|
||||||
|
Sentence: { value: 'line-2' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, true);
|
||||||
|
assert.deepEqual(harness.mergeCalls, [
|
||||||
|
{
|
||||||
|
keepNoteId: 2,
|
||||||
|
deleteNoteId: 1,
|
||||||
|
keepNoteInfoNoteId: 2,
|
||||||
|
deleteNoteInfoNoteId: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ export class FieldGroupingWorkflow {
|
|||||||
await this.performMerge(
|
await this.performMerge(
|
||||||
originalNoteId,
|
originalNoteId,
|
||||||
newNoteId,
|
newNoteId,
|
||||||
newNoteInfo,
|
|
||||||
this.getExpression(newNoteInfo),
|
this.getExpression(newNoteInfo),
|
||||||
sentenceCardConfig.kikuDeleteDuplicateInAuto,
|
sentenceCardConfig.kikuDeleteDuplicateInAuto,
|
||||||
);
|
);
|
||||||
@@ -112,12 +111,10 @@ export class FieldGroupingWorkflow {
|
|||||||
|
|
||||||
const keepNoteId = choice.keepNoteId;
|
const keepNoteId = choice.keepNoteId;
|
||||||
const deleteNoteId = choice.deleteNoteId;
|
const deleteNoteId = choice.deleteNoteId;
|
||||||
const deleteNoteInfo = deleteNoteId === newNoteId ? newNoteInfo : originalNoteInfo;
|
|
||||||
|
|
||||||
await this.performMerge(
|
await this.performMerge(
|
||||||
keepNoteId,
|
keepNoteId,
|
||||||
deleteNoteId,
|
deleteNoteId,
|
||||||
deleteNoteInfo,
|
|
||||||
expression,
|
expression,
|
||||||
choice.deleteDuplicate,
|
choice.deleteDuplicate,
|
||||||
);
|
);
|
||||||
@@ -132,18 +129,22 @@ export class FieldGroupingWorkflow {
|
|||||||
private async performMerge(
|
private async performMerge(
|
||||||
keepNoteId: number,
|
keepNoteId: number,
|
||||||
deleteNoteId: number,
|
deleteNoteId: number,
|
||||||
deleteNoteInfo: FieldGroupingWorkflowNoteInfo,
|
|
||||||
expression: string,
|
expression: string,
|
||||||
deleteDuplicate = true,
|
deleteDuplicate = true,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const keepNotesInfoResult = await this.deps.client.notesInfo([keepNoteId]);
|
const notesInfoResult = await this.deps.client.notesInfo([keepNoteId, deleteNoteId]);
|
||||||
const keepNotesInfo = keepNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
|
const notesInfo = notesInfoResult as FieldGroupingWorkflowNoteInfo[];
|
||||||
if (!keepNotesInfo || keepNotesInfo.length === 0) {
|
const keepNoteInfo = notesInfo.find((note) => note.noteId === keepNoteId);
|
||||||
|
const deleteNoteInfo = notesInfo.find((note) => note.noteId === deleteNoteId);
|
||||||
|
if (!keepNoteInfo) {
|
||||||
this.deps.logInfo('Keep note not found:', keepNoteId);
|
this.deps.logInfo('Keep note not found:', keepNoteId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!deleteNoteInfo) {
|
||||||
|
this.deps.logInfo('Delete note not found:', deleteNoteId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const keepNoteInfo = keepNotesInfo[0]!;
|
|
||||||
const mergedFields = await this.deps.computeFieldGroupingMergedFields(
|
const mergedFields = await this.deps.computeFieldGroupingMergedFields(
|
||||||
keepNoteId,
|
keepNoteId,
|
||||||
deleteNoteId,
|
deleteNoteId,
|
||||||
|
|||||||
@@ -91,10 +91,14 @@ export class NoteUpdateWorkflow {
|
|||||||
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
||||||
const fields = this.deps.extractFields(noteInfo.fields);
|
const fields = this.deps.extractFields(noteInfo.fields);
|
||||||
|
|
||||||
const expressionText = fields.expression || fields.word || '';
|
const expressionText = (fields.expression || fields.word || '').trim();
|
||||||
if (!expressionText) {
|
const hasExpressionText = expressionText.length > 0;
|
||||||
this.deps.logWarn('No expression/word field found in card:', noteId);
|
if (!hasExpressionText) {
|
||||||
return;
|
// Some note types omit Expression/Word; still run enrichment updates and skip duplicate checks.
|
||||||
|
this.deps.logWarn(
|
||||||
|
'No expression/word field found in card; skipping duplicate checks but continuing update:',
|
||||||
|
noteId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||||
@@ -103,7 +107,7 @@ export class NoteUpdateWorkflow {
|
|||||||
sentenceCardConfig.kikuEnabled &&
|
sentenceCardConfig.kikuEnabled &&
|
||||||
sentenceCardConfig.kikuFieldGrouping !== 'disabled';
|
sentenceCardConfig.kikuFieldGrouping !== 'disabled';
|
||||||
let duplicateNoteId: number | null = null;
|
let duplicateNoteId: number | null = null;
|
||||||
if (shouldRunFieldGrouping) {
|
if (shouldRunFieldGrouping && hasExpressionText) {
|
||||||
duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo);
|
duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,11 +199,11 @@ export class NoteUpdateWorkflow {
|
|||||||
if (updatePerformed) {
|
if (updatePerformed) {
|
||||||
await this.deps.client.updateNoteFields(noteId, updatedFields);
|
await this.deps.client.updateNoteFields(noteId, updatedFields);
|
||||||
await this.deps.addConfiguredTagsToNote(noteId);
|
await this.deps.addConfiguredTagsToNote(noteId);
|
||||||
this.deps.logInfo('Updated card fields for:', expressionText);
|
this.deps.logInfo('Updated card fields for:', hasExpressionText ? expressionText : noteId);
|
||||||
await this.deps.showNotification(noteId, expressionText);
|
await this.deps.showNotification(noteId, hasExpressionText ? expressionText : noteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldRunFieldGrouping && duplicateNoteId !== null) {
|
if (shouldRunFieldGrouping && hasExpressionText && duplicateNoteId !== null) {
|
||||||
let noteInfoForGrouping = noteInfo;
|
let noteInfoForGrouping = noteInfo;
|
||||||
if (updatePerformed) {
|
if (updatePerformed) {
|
||||||
const refreshedInfoResult = await this.deps.client.notesInfo([noteId]);
|
const refreshedInfoResult = await this.deps.client.notesInfo([noteId]);
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ export interface CliArgs {
|
|||||||
stop: boolean;
|
stop: boolean;
|
||||||
toggle: boolean;
|
toggle: boolean;
|
||||||
toggleVisibleOverlay: boolean;
|
toggleVisibleOverlay: boolean;
|
||||||
toggleInvisibleOverlay: boolean;
|
|
||||||
settings: boolean;
|
settings: boolean;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
hide: boolean;
|
hide: boolean;
|
||||||
showVisibleOverlay: boolean;
|
showVisibleOverlay: boolean;
|
||||||
hideVisibleOverlay: boolean;
|
hideVisibleOverlay: boolean;
|
||||||
showInvisibleOverlay: boolean;
|
|
||||||
hideInvisibleOverlay: boolean;
|
|
||||||
copySubtitle: boolean;
|
copySubtitle: boolean;
|
||||||
copySubtitleMultiple: boolean;
|
copySubtitleMultiple: boolean;
|
||||||
mineSentence: boolean;
|
mineSentence: boolean;
|
||||||
@@ -67,14 +64,11 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
stop: false,
|
stop: false,
|
||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
toggleInvisibleOverlay: false,
|
|
||||||
settings: false,
|
settings: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
showVisibleOverlay: false,
|
showVisibleOverlay: false,
|
||||||
hideVisibleOverlay: false,
|
hideVisibleOverlay: false,
|
||||||
showInvisibleOverlay: false,
|
|
||||||
hideInvisibleOverlay: false,
|
|
||||||
copySubtitle: false,
|
copySubtitle: false,
|
||||||
copySubtitleMultiple: false,
|
copySubtitleMultiple: false,
|
||||||
mineSentence: false,
|
mineSentence: false,
|
||||||
@@ -122,14 +116,11 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
else if (arg === '--stop') args.stop = true;
|
else if (arg === '--stop') args.stop = true;
|
||||||
else if (arg === '--toggle') args.toggle = true;
|
else if (arg === '--toggle') args.toggle = true;
|
||||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||||
else if (arg === '--toggle-invisible-overlay') args.toggleInvisibleOverlay = true;
|
|
||||||
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
|
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
|
||||||
else if (arg === '--show') args.show = true;
|
else if (arg === '--show') args.show = true;
|
||||||
else if (arg === '--hide') args.hide = true;
|
else if (arg === '--hide') args.hide = true;
|
||||||
else if (arg === '--show-visible-overlay') args.showVisibleOverlay = true;
|
else if (arg === '--show-visible-overlay') args.showVisibleOverlay = true;
|
||||||
else if (arg === '--hide-visible-overlay') args.hideVisibleOverlay = true;
|
else if (arg === '--hide-visible-overlay') args.hideVisibleOverlay = true;
|
||||||
else if (arg === '--show-invisible-overlay') args.showInvisibleOverlay = true;
|
|
||||||
else if (arg === '--hide-invisible-overlay') args.hideInvisibleOverlay = true;
|
|
||||||
else if (arg === '--copy-subtitle') args.copySubtitle = true;
|
else if (arg === '--copy-subtitle') args.copySubtitle = true;
|
||||||
else if (arg === '--copy-subtitle-multiple') args.copySubtitleMultiple = true;
|
else if (arg === '--copy-subtitle-multiple') args.copySubtitleMultiple = true;
|
||||||
else if (arg === '--mine-sentence') args.mineSentence = true;
|
else if (arg === '--mine-sentence') args.mineSentence = true;
|
||||||
@@ -263,14 +254,11 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.stop ||
|
args.stop ||
|
||||||
args.toggle ||
|
args.toggle ||
|
||||||
args.toggleVisibleOverlay ||
|
args.toggleVisibleOverlay ||
|
||||||
args.toggleInvisibleOverlay ||
|
|
||||||
args.settings ||
|
args.settings ||
|
||||||
args.show ||
|
args.show ||
|
||||||
args.hide ||
|
args.hide ||
|
||||||
args.showVisibleOverlay ||
|
args.showVisibleOverlay ||
|
||||||
args.hideVisibleOverlay ||
|
args.hideVisibleOverlay ||
|
||||||
args.showInvisibleOverlay ||
|
|
||||||
args.hideInvisibleOverlay ||
|
|
||||||
args.copySubtitle ||
|
args.copySubtitle ||
|
||||||
args.copySubtitleMultiple ||
|
args.copySubtitleMultiple ||
|
||||||
args.mineSentence ||
|
args.mineSentence ||
|
||||||
@@ -307,7 +295,6 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.start ||
|
args.start ||
|
||||||
args.toggle ||
|
args.toggle ||
|
||||||
args.toggleVisibleOverlay ||
|
args.toggleVisibleOverlay ||
|
||||||
args.toggleInvisibleOverlay ||
|
|
||||||
args.copySubtitle ||
|
args.copySubtitle ||
|
||||||
args.copySubtitleMultiple ||
|
args.copySubtitleMultiple ||
|
||||||
args.mineSentence ||
|
args.mineSentence ||
|
||||||
@@ -331,13 +318,10 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
|||||||
return (
|
return (
|
||||||
args.toggle ||
|
args.toggle ||
|
||||||
args.toggleVisibleOverlay ||
|
args.toggleVisibleOverlay ||
|
||||||
args.toggleInvisibleOverlay ||
|
|
||||||
args.show ||
|
args.show ||
|
||||||
args.hide ||
|
args.hide ||
|
||||||
args.showVisibleOverlay ||
|
args.showVisibleOverlay ||
|
||||||
args.hideVisibleOverlay ||
|
args.hideVisibleOverlay ||
|
||||||
args.showInvisibleOverlay ||
|
|
||||||
args.hideInvisibleOverlay ||
|
|
||||||
args.copySubtitle ||
|
args.copySubtitle ||
|
||||||
args.copySubtitleMultiple ||
|
args.copySubtitleMultiple ||
|
||||||
args.mineSentence ||
|
args.mineSentence ||
|
||||||
|
|||||||
@@ -17,11 +17,8 @@ ${B}Session${R}
|
|||||||
|
|
||||||
${B}Overlay${R}
|
${B}Overlay${R}
|
||||||
--toggle-visible-overlay Toggle subtitle overlay
|
--toggle-visible-overlay Toggle subtitle overlay
|
||||||
--toggle-invisible-overlay Toggle interactive overlay ${D}(Yomitan lookup)${R}
|
|
||||||
--show-visible-overlay Show subtitle overlay
|
--show-visible-overlay Show subtitle overlay
|
||||||
--hide-visible-overlay Hide subtitle overlay
|
--hide-visible-overlay Hide subtitle overlay
|
||||||
--show-invisible-overlay Show interactive overlay
|
|
||||||
--hide-invisible-overlay Hide interactive overlay
|
|
||||||
--settings Open Yomitan settings window
|
--settings Open Yomitan settings window
|
||||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,31 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||||
assert.equal(config.jellyfin.autoAnnounce, false);
|
assert.equal(config.jellyfin.autoAnnounce, false);
|
||||||
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
|
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
|
||||||
|
assert.equal(config.startupWarmups.lowPowerMode, false);
|
||||||
|
assert.equal(config.startupWarmups.mecab, true);
|
||||||
|
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||||
|
assert.equal(config.startupWarmups.subtitleDictionaries, true);
|
||||||
|
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
|
||||||
assert.equal(config.discordPresence.enabled, false);
|
assert.equal(config.discordPresence.enabled, false);
|
||||||
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.hoverTokenColor, '#c6a0f6');
|
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||||
|
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
|
||||||
|
assert.equal(
|
||||||
|
config.subtitleStyle.fontFamily,
|
||||||
|
'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
|
||||||
|
);
|
||||||
|
assert.equal(config.subtitleStyle.fontWeight, '600');
|
||||||
|
assert.equal(config.subtitleStyle.lineHeight, 1.35);
|
||||||
|
assert.equal(config.subtitleStyle.letterSpacing, '-0.01em');
|
||||||
|
assert.equal(config.subtitleStyle.wordSpacing, 0);
|
||||||
|
assert.equal(config.subtitleStyle.fontKerning, 'normal');
|
||||||
|
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
|
||||||
|
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.secondary.fontFamily, 'Manrope, Inter');
|
||||||
|
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, '');
|
||||||
assert.equal(config.immersionTracking.batchSize, 25);
|
assert.equal(config.immersionTracking.batchSize, 25);
|
||||||
@@ -136,6 +156,44 @@ test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values', () => {
|
||||||
|
const validDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(validDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"hoverTokenBackgroundColor": "#363a4fd6"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const validService = new ConfigService(validDir);
|
||||||
|
assert.equal(validService.getConfig().subtitleStyle.hoverTokenBackgroundColor, '#363a4fd6');
|
||||||
|
|
||||||
|
const invalidDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(invalidDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"hoverTokenBackgroundColor": true
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidService = new ConfigService(invalidDir);
|
||||||
|
assert.equal(
|
||||||
|
invalidService.getConfig().subtitleStyle.hoverTokenBackgroundColor,
|
||||||
|
DEFAULT_CONFIG.subtitleStyle.hoverTokenBackgroundColor,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
invalidService
|
||||||
|
.getWarnings()
|
||||||
|
.some((warning) => warning.path === 'subtitleStyle.hoverTokenBackgroundColor'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('parses anilist.enabled and warns for invalid value', () => {
|
test('parses anilist.enabled and warns for invalid value', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -241,6 +299,72 @@ test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses startup warmup toggles and low-power mode', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"startupWarmups": {
|
||||||
|
"lowPowerMode": true,
|
||||||
|
"mecab": false,
|
||||||
|
"yomitanExtension": true,
|
||||||
|
"subtitleDictionaries": false,
|
||||||
|
"jellyfinRemoteSession": false
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const config = service.getConfig();
|
||||||
|
assert.equal(config.startupWarmups.lowPowerMode, true);
|
||||||
|
assert.equal(config.startupWarmups.mecab, false);
|
||||||
|
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||||
|
assert.equal(config.startupWarmups.subtitleDictionaries, false);
|
||||||
|
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid startup warmup values warn and keep defaults', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"startupWarmups": {
|
||||||
|
"lowPowerMode": "yes",
|
||||||
|
"mecab": 1,
|
||||||
|
"yomitanExtension": null,
|
||||||
|
"subtitleDictionaries": "no",
|
||||||
|
"jellyfinRemoteSession": []
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const config = service.getConfig();
|
||||||
|
const warnings = service.getWarnings();
|
||||||
|
|
||||||
|
assert.equal(config.startupWarmups.lowPowerMode, DEFAULT_CONFIG.startupWarmups.lowPowerMode);
|
||||||
|
assert.equal(config.startupWarmups.mecab, DEFAULT_CONFIG.startupWarmups.mecab);
|
||||||
|
assert.equal(
|
||||||
|
config.startupWarmups.yomitanExtension,
|
||||||
|
DEFAULT_CONFIG.startupWarmups.yomitanExtension,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
config.startupWarmups.subtitleDictionaries,
|
||||||
|
DEFAULT_CONFIG.startupWarmups.subtitleDictionaries,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
config.startupWarmups.jellyfinRemoteSession,
|
||||||
|
DEFAULT_CONFIG.startupWarmups.jellyfinRemoteSession,
|
||||||
|
);
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.lowPowerMode'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.mecab'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.yomitanExtension'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.subtitleDictionaries'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.jellyfinRemoteSession'));
|
||||||
|
});
|
||||||
|
|
||||||
test('parses discordPresence fields and warns for invalid types', () => {
|
test('parses discordPresence fields and warns for invalid types', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -597,20 +721,15 @@ test('warns and ignores unknown top-level config keys', () => {
|
|||||||
assert.ok(warnings.some((warning) => warning.path === 'unknownFeatureFlag'));
|
assert.ok(warnings.some((warning) => warning.path === 'unknownFeatureFlag'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parses invisible overlay config and new global shortcuts', () => {
|
test('parses global shortcuts and startup settings', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(dir, 'config.jsonc'),
|
path.join(dir, 'config.jsonc'),
|
||||||
`{
|
`{
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+U",
|
"toggleVisibleOverlayGlobal": "Alt+Shift+U",
|
||||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
|
|
||||||
"openJimaku": "Ctrl+Alt+J"
|
"openJimaku": "Ctrl+Alt+J"
|
||||||
},
|
},
|
||||||
"invisibleOverlay": {
|
|
||||||
"startupVisibility": "hidden"
|
|
||||||
},
|
|
||||||
"bind_visible_overlay_to_mpv_sub_visibility": false,
|
|
||||||
"youtubeSubgen": {
|
"youtubeSubgen": {
|
||||||
"primarySubLanguages": ["ja", "jpn", "jp"]
|
"primarySubLanguages": ["ja", "jpn", "jp"]
|
||||||
}
|
}
|
||||||
@@ -621,10 +740,7 @@ test('parses invisible overlay config and new global shortcuts', () => {
|
|||||||
const service = new ConfigService(dir);
|
const service = new ConfigService(dir);
|
||||||
const config = service.getConfig();
|
const config = service.getConfig();
|
||||||
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
|
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
|
||||||
assert.equal(config.shortcuts.toggleInvisibleOverlayGlobal, 'Alt+Shift+I');
|
|
||||||
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
|
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
|
||||||
assert.equal(config.invisibleOverlay.startupVisibility, 'hidden');
|
|
||||||
assert.equal(config.bind_visible_overlay_to_mpv_sub_visibility, false);
|
|
||||||
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'jpn', 'jp']);
|
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'jpn', 'jp']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1090,6 +1206,7 @@ test('template generator includes known keys', () => {
|
|||||||
assert.match(output, /"logging":/);
|
assert.match(output, /"logging":/);
|
||||||
assert.match(output, /"websocket":/);
|
assert.match(output, /"websocket":/);
|
||||||
assert.match(output, /"discordPresence":/);
|
assert.match(output, /"discordPresence":/);
|
||||||
|
assert.match(output, /"startupWarmups":/);
|
||||||
assert.match(output, /"youtubeSubgen":/);
|
assert.match(output, /"youtubeSubgen":/);
|
||||||
assert.match(output, /"preserveLineBreaks": false/);
|
assert.match(output, /"preserveLineBreaks": false/);
|
||||||
assert.match(output, /"nPlusOne"\s*:\s*\{/);
|
assert.match(output, /"nPlusOne"\s*:\s*\{/);
|
||||||
|
|||||||