refactor(main): modularize runtime and harden anilist setup flow

This commit is contained in:
2026-02-19 16:04:59 -08:00
parent 58f28b7b55
commit 162be118e1
73 changed files with 4413 additions and 1251 deletions

View File

@@ -27,46 +27,3 @@ You MUST read the overview resource to understand the complete workflow. The inf
</CRITICAL_INSTRUCTION> </CRITICAL_INSTRUCTION>
<!-- BACKLOG.MD MCP GUIDELINES END --> <!-- BACKLOG.MD MCP GUIDELINES END -->
## Subagent Coordination Protocol (`docs/subagents/`)
Purpose: multi-agent coordination across runs; single-agent continuity during long runs.
Layout:
- `docs/subagents/INDEX.md` (active agents table)
- `docs/subagents/collaboration.md` (shared notes)
- `docs/subagents/agents/<agent_id>.md` (one file per agent)
- `docs/subagents/archive/<yyyy-mm>/` (archived histories)
Required behavior (all agents):
1. At run start, read in order:
- `docs/subagents/INDEX.md`
- `docs/subagents/collaboration.md`
- your own file: `docs/subagents/agents/<agent_id>.md`
2. Identify self by stable `agent_id` (runner/env-provided). If missing, create own file from template.
3. Maintain `alias` (short human-readable label) + `mission` (one-line focus).
4. Before coding:
- record intent, planned files, assumptions in your own file.
5. During run:
- update on phase changes (plan -> edit -> test -> handoff),
- heartbeat at least every `HEARTBEAT_MINUTES` (default 5),
- update your own row in `INDEX.md` (`status`, `last_update_utc`),
- append cross-agent notes in `collaboration.md` when needed.
6. Write limits:
- MAY edit own file.
- MAY append to `collaboration.md`.
- MAY edit only own row in `INDEX.md`.
- MUST NOT edit other agent files.
7. At run end:
- record files touched, key decisions, assumptions, blockers, next step for handoff.
8. Conflict handling:
- if another agent touched your target files, add conflict note in `collaboration.md` before continuing.
9. Brevity:
- terse bullets; factual; no long prose.
Suggested env vars:
- `AGENT_ID` (required)
- `AGENT_ALIAS` (required)
- `HEARTBEAT_MINUTES` (optional, default 20)

View File

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

View File

@@ -0,0 +1,43 @@
---
id: TASK-29.3
title: Fix AniList OAuth callback token handling in setup window
status: Done
assignee: []
created_date: '2026-02-19 16:56'
labels:
- anilist
- oauth
- bug
dependencies: []
parent_task_id: TASK-29
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
AniList login flow shows unsupported_grant_type after auth because setup window does not consume callback URL token and persist it.
Need robust callback handling for both query and hash access_token forms and graceful close/success UX.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 AniList setup flow persists access token from callback URL query/hash
- [x] #2 Setup window closes and state updates to resolved when token captured
- [x] #3 No unsafe navigation regressions in AniList setup window
<!-- AC:END -->
## Definition of Done
<!-- DOD:BEGIN -->
- [x] #1 Build passes
- [x] #2 Targeted AniList runtime tests pass
<!-- DOD:END -->
## Implementation Notes
<!-- SECTION:IMPLEMENTATION_NOTES:BEGIN -->
- Added robust AniList callback token parsing for query/hash and `subminer://anilist-setup?...` deep links.
- Added app-level protocol handling (`open-url` + second-instance argv deep link parsing) so browser callback buttons resolve even when setup window is not navigating.
- Added/updated targeted AniList setup runtime tests and verified build + runtime test pass.
<!-- SECTION:IMPLEMENTATION_NOTES:END -->

View File

@@ -4,19 +4,19 @@ title: Add error boundary and recovery in renderer overlay
status: Done status: Done
assignee: [] assignee: []
created_date: '2026-02-14 01:01' created_date: '2026-02-14 01:01'
updated_date: '2026-02-19 21:50' updated_date: '2026-02-19 23:18'
labels: labels:
- renderer - renderer
- reliability - reliability
- error-handling - error-handling
dependencies: [] dependencies: []
priority: medium priority: medium
ordinal: 60000
--- ---
## Description ## Description
<!-- SECTION:DESCRIPTION:BEGIN --> <!-- SECTION:DESCRIPTION:BEGIN -->
Add a top-level error boundary in the renderer orchestrator that catches unhandled errors in modals and rendering logic, displays a user-friendly error message, and recovers without crashing the overlay. Add a top-level error boundary in the renderer orchestrator that catches unhandled errors in modals and rendering logic, displays a user-friendly error message, and recovers without crashing the overlay.
## Motivation ## Motivation
@@ -39,9 +39,7 @@ If a renderer modal throws (e.g., jimaku API timeout, DOM manipulation error, ma
<!-- SECTION:DESCRIPTION:END --> <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [x] #1 Unhandled errors in modal flows are caught and do not crash the overlay. - [x] #1 Unhandled errors in modal flows are caught and do not crash the overlay.
- [x] #2 After an error, the overlay returns to a functional state (subtitles render, shortcuts work). - [x] #2 After an error, the overlay returns to a functional state (subtitles render, shortcuts work).
- [x] #3 A brief toast/notification informs the user that an error occurred. - [x] #3 A brief toast/notification informs the user that an error occurred.
@@ -52,6 +50,7 @@ If a renderer modal throws (e.g., jimaku API timeout, DOM manipulation error, ma
## Implementation Notes ## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
- Added renderer recovery module with guarded callback boundaries and global `window.onerror` / `window.unhandledrejection` handlers. - Added renderer recovery module with guarded callback boundaries and global `window.onerror` / `window.unhandledrejection` handlers.
- Recovery now uses modal close/cancel APIs (including Kiku cancel) to preserve cleanup semantics and avoid hanging pending callbacks. - Recovery now uses modal close/cancel APIs (including Kiku cancel) to preserve cleanup semantics and avoid hanging pending callbacks.
- Added overlay recovery toast UI and contextual recovery logging payloads. - Added overlay recovery toast UI and contextual recovery logging payloads.
@@ -61,3 +60,4 @@ If a renderer modal throws (e.g., jimaku API timeout, DOM manipulation error, ma
- `bun run build` - `bun run build`
- `bun run test:core:dist` - `bun run test:core:dist`
<!-- SECTION:NOTES:END -->

View File

@@ -5,13 +5,14 @@ status: Done
assignee: assignee:
- codex-main - codex-main
created_date: '2026-02-14 02:02' created_date: '2026-02-14 02:02'
updated_date: '2026-02-19 08:21' updated_date: '2026-02-19 23:18'
labels: labels:
- config - config
- developer-experience - developer-experience
- error-handling - error-handling
dependencies: [] dependencies: []
priority: medium priority: medium
ordinal: 66000
--- ---
## Description ## Description

View File

@@ -1,34 +0,0 @@
---
id: TASK-65
title: Add overlay drag-drop playlist loading and clipboard append shortcut
status: Done
assignee: []
created_date: '2026-02-18 13:10'
updated_date: '2026-02-18 13:10'
labels: []
dependencies: []
---
## Description
Implement direct playlist control from the overlay:
- Drag/drop video files onto overlay:
- default drop: replace current playback with dropped set (first `replace`, remainder `append`)
- `Shift` + drop: append all dropped files
- `Ctrl/Cmd+A`: read clipboard text, if it resolves to a supported local video file path, append it to mpv playlist.
## Implementation Steps
- [x] Add TDD coverage for drop path parsing, command mode generation, and clipboard path parsing (`src/core/services/overlay-drop.test.ts`).
- [x] Implement drop/clipboard parser + mpv command-builder utility (`src/core/services/overlay-drop.ts`).
- [x] Wire renderer drag/drop handling and mpv command dispatch (`src/renderer/renderer.ts`).
- [x] Add IPC API for clipboard append flow (`src/types.ts`, `src/preload.ts`, `src/core/services/ipc.ts`, `src/main/dependencies.ts`).
- [x] Implement main-process clipboard validation + append behavior (`src/main.ts`).
- [x] Add fixed keyboard shortcut hook (`Ctrl/Cmd+A`) in renderer keyboard handler (`src/renderer/handlers/keyboard.ts`, `src/renderer/renderer.ts`).
- [x] Update docs for new interaction model (`docs/usage.md`, `docs/configuration.md`).
## Verification
- `bun run build`
- `node --test dist/core/services/overlay-drop.test.js dist/core/services/ipc.test.js`

View File

@@ -4,7 +4,7 @@ title: Run Electron app as background tray service with IPC startup
status: Done status: Done
assignee: [] assignee: []
created_date: '2026-02-18 08:48' created_date: '2026-02-18 08:48'
updated_date: '2026-02-19 21:50' updated_date: '2026-02-19 23:18'
labels: labels:
- electron - electron
- tray - tray
@@ -12,6 +12,7 @@ labels:
- desktop-entry - desktop-entry
dependencies: [] dependencies: []
priority: high priority: high
ordinal: 61000
--- ---
## Description ## Description

View File

@@ -4,7 +4,7 @@ title: Make wrapper stop auto-sending --start by default
status: Done status: Done
assignee: [] assignee: []
created_date: '2026-02-18 09:47' created_date: '2026-02-18 09:47'
updated_date: '2026-02-18 10:02' updated_date: '2026-02-19 23:18'
labels: labels:
- launcher - launcher
- wrapper - wrapper
@@ -12,6 +12,7 @@ labels:
- background-mode - background-mode
dependencies: [] dependencies: []
priority: high priority: high
ordinal: 68000
--- ---
## Description ## Description

View File

@@ -4,12 +4,13 @@ title: Allow trailing commas in JSONC config parsing
status: Done status: Done
assignee: [] assignee: []
created_date: '2026-02-18 10:13' created_date: '2026-02-18 10:13'
updated_date: '2026-02-18 10:13' updated_date: '2026-02-19 23:18'
labels: labels:
- config - config
- jsonc - jsonc
dependencies: [] dependencies: []
priority: medium priority: medium
ordinal: 67000
--- ---
## Description ## Description

View File

@@ -5,13 +5,14 @@ status: Done
assignee: assignee:
- codex-main - codex-main
created_date: '2026-02-18 11:35' created_date: '2026-02-18 11:35'
updated_date: '2026-02-19 08:27' updated_date: '2026-02-19 23:18'
labels: labels:
- config - config
- validation - validation
- safety - safety
dependencies: [] dependencies: []
priority: high priority: high
ordinal: 65000
--- ---
## Description ## Description

View File

@@ -5,13 +5,14 @@ status: Done
assignee: assignee:
- codex-main - codex-main
created_date: '2026-02-18 11:35' created_date: '2026-02-18 11:35'
updated_date: '2026-02-19 09:05' updated_date: '2026-02-19 23:18'
labels: labels:
- config - config
- launcher - launcher
- consistency - consistency
dependencies: [] dependencies: []
priority: high priority: high
ordinal: 63000
--- ---
## Description ## Description

View File

@@ -5,13 +5,14 @@ status: Done
assignee: assignee:
- codex-main - codex-main
created_date: '2026-02-19 08:38' created_date: '2026-02-19 08:38'
updated_date: '2026-02-19 08:40' updated_date: '2026-02-19 23:18'
labels: labels:
- config - config
- anki - anki
- cleanup - cleanup
dependencies: [] dependencies: []
priority: medium priority: medium
ordinal: 64000
--- ---
## Description ## Description

View File

@@ -0,0 +1,56 @@
---
id: TASK-85
title: Refactor large files for maintainability and readability
status: In Progress
assignee: []
created_date: '2026-02-19 09:46'
updated_date: '2026-02-19 10:01'
labels:
- architecture
- refactor
- maintainability
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Several core files are oversized and high-coupling (`src/main.ts`, `src/anki-integration.ts`, `src/config/service.ts`, `src/core/services/immersion-tracker-service.ts`). This task tracks phased, behavior-preserving decomposition plus guardrails and generated-launcher workflow cleanup.
<!-- SECTION:DESCRIPTION:END -->
## Suggestions
<!-- SECTION:SUGGESTIONS:BEGIN -->
- Use seam tests before each extraction.
- Keep `src/main.ts` + `src/anki-integration.ts` as thin composition/coordinator layers.
- Formalize `subminer` as generated artifact only.
<!-- SECTION:SUGGESTIONS:END -->
## Action Steps
<!-- SECTION:PLAN:BEGIN -->
1. Add file-budget guardrails and baseline report.
2. Split `src/main.ts` into runtime domain modules.
3. Split `src/anki-integration.ts` into focused collaborators.
4. Split `src/config/service.ts` by load/migrate/validate/warn phases.
5. Split immersion tracker service by state, persistence, sync responsibilities.
6. Clarify generated launcher artifact workflow and docs.
7. Run full build/test gate and publish maintainability report.
<!-- SECTION:PLAN:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 `src/main.ts` reduced to orchestration-focused module with extracted runtime domains
- [ ] #2 `src/anki-integration.ts` reduced to facade with helper collaborators
- [ ] #3 Config and immersion tracker services decomposed without behavior regressions
- [ ] #4 `subminer` generated artifact ownership/workflow documented and enforced
- [ ] #5 Full build + config/core tests pass after refactor
<!-- AC:END -->
## Definition of Done
<!-- DOD:BEGIN -->
- [ ] #1 Plan at `docs/plans/2026-02-19-repo-maintainability-refactor-plan.md` executed or decomposed into child tasks
- [ ] #2 Regression coverage added for extracted seams
- [ ] #3 Docs updated for architecture and contributor workflow changes
<!-- DOD:END -->

View File

@@ -0,0 +1,38 @@
---
id: TASK-86
title: Include config validation issue details in user notifications
status: Done
assignee: []
created_date: '2026-02-19 17:24'
updated_date: '2026-02-19 23:18'
labels: []
dependencies: []
priority: medium
ordinal: 62000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When config validation finds non-fatal issues, users should see concise per-issue details in notification body (not only issue count) so they can fix config quickly without checking logs.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Startup notification body includes per-issue details (path + message) for config validation warnings.
- [x] #2 Hot-reload validation warning notifications include per-issue details in notification body.
- [x] #3 Notification text remains concise and does not exceed practical desktop notification limits.
- [x] #4 Automated tests cover notification body formatting with detailed issues.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added `buildConfigWarningNotificationBody` to format concise multi-line warning details (path+message, line limit + overflow count). Startup warnings now use this formatter for desktop notification body. Config hot-reload runtime now emits non-fatal validation warnings via `onValidationWarnings(configPath, warnings)` and main process surfaces them through desktop notifications. Added tests for formatter output, startup notification body content, and hot-reload warning callback behavior.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Config validation notifications now include concrete issue details in the notification body instead of only a count. Startup and hot-reload warning paths both surface per-issue `path: message` lines with concise truncation safeguards. Added regression tests covering formatter output and both notification paths.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,37 @@
---
id: TASK-87
title: Fix plugin --start flow so tokenization/frequency/N+1 initialize correctly
status: Done
assignee: []
created_date: '2026-02-19 19:05'
updated_date: '2026-02-19 23:18'
labels: []
dependencies: []
priority: medium
ordinal: 69000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Plugin startup flow (`--texthooker` then `--start`) can miss tokenization initialization, which makes frequency highlighting and N+1 appear broken even when dictionaries/config are correct. Ensure `--start` initializes overlay runtime when needed, including second-instance handoff mode.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `--start` initializes overlay runtime when runtime is not yet initialized.
- [x] #2 `second-instance --start` is ignored only when overlay runtime is already initialized.
- [x] #3 Regression tests cover both `--start` behaviors and pass.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause: subtitle tokenization path in `src/main.ts` only runs when overlay windows exist; command runtime did not initialize overlay runtime for `--start`, especially visible in plugin handoff (`--texthooker` -> `--start`). Updated `src/core/services/cli-command.ts` to initialize overlay runtime for `--start`, and narrowed second-instance ignore behavior to already-initialized runtime only. Added regression tests in `src/core/services/cli-command.test.ts`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed plugin/start regression by ensuring `--start` initializes overlay runtime when needed and no longer gets dropped in second-instance handoff before runtime init. Added/updated CLI command tests; targeted suite passes.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -5,26 +5,27 @@
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
*/ */
{ {
// ========================================== // ==========================================
// Overlay Auto-Start // Overlay Auto-Start
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles. // When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
// ========================================== // ==========================================
"auto_start_overlay": false, "auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
// ========================================== // ==========================================
// Visible Overlay Subtitle Binding // Visible Overlay Subtitle Binding
// Control whether visible overlay toggles also toggle MPV subtitle visibility. // Control whether visible overlay toggles also toggle MPV subtitle visibility.
// When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged. // When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.
// ========================================== // ==========================================
"bind_visible_overlay_to_mpv_sub_visibility": true, "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.
// ========================================== // ==========================================
"texthooker": { "texthooker": {
"openBrowser": true, "openBrowser": true // Open browser setting. Values: true | false
}, }, // Control whether browser opens automatically for texthooker.
// ========================================== // ==========================================
// WebSocket Server // WebSocket Server
@@ -32,9 +33,9 @@
// Auto mode disables built-in server if mpv_websocket is detected. // Auto mode disables built-in server if mpv_websocket is detected.
// ========================================== // ==========================================
"websocket": { "websocket": {
"enabled": "auto", "enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
"port": 6677, "port": 6677 // Built-in subtitle websocket server port.
}, }, // Built-in WebSocket server broadcasts subtitle text to connected clients.
// ========================================== // ==========================================
// Logging // Logging
@@ -42,8 +43,8 @@
// Set to debug for full runtime diagnostics. // Set to debug for full runtime diagnostics.
// ========================================== // ==========================================
"logging": { "logging": {
"level": "info", "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
}, }, // Controls logging verbosity.
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
@@ -52,93 +53,93 @@
// Most other AnkiConnect settings still require restart. // Most other AnkiConnect settings still require restart.
// ========================================== // ==========================================
"ankiConnect": { "ankiConnect": {
"enabled": false, "enabled": false, // Enable AnkiConnect integration. Values: true | false
"url": "http://127.0.0.1:8765", "url": "http://127.0.0.1:8765", // Url setting.
"pollingRate": 3000, "pollingRate": 3000, // Polling interval in milliseconds.
"tags": ["SubMiner"], "tags": [
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"fields": { "fields": {
"audio": "ExpressionAudio", "audio": "ExpressionAudio", // Audio setting.
"image": "Picture", "image": "Picture", // Image setting.
"sentence": "Sentence", "sentence": "Sentence", // Sentence setting.
"miscInfo": "MiscInfo", "miscInfo": "MiscInfo", // Misc info setting.
"translation": "SelectionText", "translation": "SelectionText" // Translation setting.
}, }, // Fields setting.
"ai": { "ai": {
"enabled": false, "enabled": false, // Enabled setting. Values: true | false
"alwaysUseAiTranslation": false, "alwaysUseAiTranslation": false, // Always use ai translation setting. Values: true | false
"apiKey": "", "apiKey": "", // Api key setting.
"model": "openai/gpt-4o-mini", "model": "openai/gpt-4o-mini", // Model setting.
"baseUrl": "https://openrouter.ai/api", "baseUrl": "https://openrouter.ai/api", // Base url setting.
"targetLanguage": "English", "targetLanguage": "English", // Target language setting.
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", "systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting.
}, }, // Ai setting.
"media": { "media": {
"generateAudio": true, "generateAudio": true, // Generate audio setting. Values: true | false
"generateImage": true, "generateImage": true, // Generate image setting. Values: true | false
"imageType": "static", "imageType": "static", // Image type setting.
"imageFormat": "jpg", "imageFormat": "jpg", // Image format setting.
"imageQuality": 92, "imageQuality": 92, // Image quality setting.
"animatedFps": 10, "animatedFps": 10, // Animated fps setting.
"animatedMaxWidth": 640, "animatedMaxWidth": 640, // Animated max width setting.
"animatedCrf": 35, "animatedCrf": 35, // Animated crf setting.
"audioPadding": 0.5, "audioPadding": 0.5, // Audio padding setting.
"fallbackDuration": 3, "fallbackDuration": 3, // Fallback duration setting.
"maxMediaDuration": 30, "maxMediaDuration": 30 // Max media duration setting.
}, }, // Media setting.
"behavior": { "behavior": {
"overwriteAudio": true, "overwriteAudio": true, // Overwrite audio setting. Values: true | false
"overwriteImage": true, "overwriteImage": true, // Overwrite image setting. Values: true | false
"mediaInsertMode": "append", "mediaInsertMode": "append", // Media insert mode setting.
"highlightWord": true, "highlightWord": true, // Highlight word setting. Values: true | false
"notificationType": "osd", "notificationType": "osd", // Notification type setting.
"autoUpdateNewCards": true, "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, }, // Behavior setting.
"nPlusOne": { "nPlusOne": {
"highlightEnabled": false, "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"matchMode": "headword", "matchMode": "headword", // Known-word matching strategy for N+1 highlighting. Values: headword | surface
"decks": [], "decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
"minSentenceWords": 3, "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
"nPlusOne": "#c6a0f6", "nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
"knownWord": "#a6da95", "knownWord": "#a6da95" // Color used for legacy known-word highlights.
}, }, // N plus one setting.
"metadata": { "metadata": {
"pattern": "[SubMiner] %f (%t)", "pattern": "[SubMiner] %f (%t)" // Pattern setting.
}, }, // Metadata setting.
"isLapis": { "isLapis": {
"enabled": false, "enabled": false, // Enabled setting. Values: true | false
"sentenceCardModel": "Japanese sentences", "sentenceCardModel": "Japanese sentences" // Sentence card model setting.
}, }, // Is lapis setting.
"isKiku": { "isKiku": {
"enabled": false, "enabled": false, // Enabled setting. Values: true | false
"fieldGrouping": "disabled", "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true, "deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
}, } // Is kiku setting.
}, }, // Automatic Anki updates and media generation options.
// ========================================== // ==========================================
// Keyboard Shortcuts // Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable. // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// Fixed (non-configurable) overlay shortcuts:
// - Ctrl/Cmd+A: append clipboard video path to MPV playlist
// Hot-reload: shortcut changes apply live and update the session help modal on reopen. // Hot-reload: shortcut changes apply live and update the session help modal on reopen.
// ========================================== // ==========================================
"shortcuts": { "shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O", "toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", "toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
"copySubtitle": "CommandOrControl+C", "copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
"copySubtitleMultiple": "CommandOrControl+Shift+C", "copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
"updateLastCardFromClipboard": "CommandOrControl+V", "updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
"triggerFieldGrouping": "CommandOrControl+G", "triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting.
"triggerSubsync": "Ctrl+Alt+S", "triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting.
"mineSentence": "CommandOrControl+S", "mineSentence": "CommandOrControl+S", // Mine sentence setting.
"mineSentenceMultiple": "CommandOrControl+Shift+S", "mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting.
"multiCopyTimeoutMs": 3000, "multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
"toggleSecondarySub": "CommandOrControl+Shift+V", "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
"openJimaku": "Ctrl+Shift+J", "openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
}, }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================
// Invisible Overlay // Invisible Overlay
@@ -147,8 +148,8 @@
// This edit-mode shortcut is fixed and is not currently configurable. // This edit-mode shortcut is fixed and is not currently configurable.
// ========================================== // ==========================================
"invisibleOverlay": { "invisibleOverlay": {
"startupVisibility": "platform-default", "startupVisibility": "platform-default" // Startup visibility setting.
}, }, // Startup behavior for the invisible interactive subtitle mining layer.
// ========================================== // ==========================================
// Keybindings (MPV Commands) // Keybindings (MPV Commands)
@@ -156,7 +157,7 @@
// Set command to null to disable a default keybinding. // Set command to null to disable a default keybinding.
// Hot-reload: keybinding changes apply live and update the session help modal on reopen. // Hot-reload: keybinding changes apply live and update the session help modal on reopen.
// ========================================== // ==========================================
"keybindings": [], "keybindings": [], // Extra keybindings that are merged with built-in defaults.
// ========================================== // ==========================================
// Subtitle Appearance // Subtitle Appearance
@@ -164,39 +165,45 @@
// Hot-reload: subtitle style changes apply live without restarting SubMiner. // Hot-reload: subtitle style changes apply live without restarting SubMiner.
// ========================================== // ==========================================
"subtitleStyle": { "subtitleStyle": {
"enableJlpt": false, "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif", "fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif", // Font family setting.
"fontSize": 35, "fontSize": 35, // Font size setting.
"fontColor": "#cad3f5", "fontColor": "#cad3f5", // Font color setting.
"fontWeight": "normal", "fontWeight": "normal", // Font weight setting.
"fontStyle": "normal", "fontStyle": "normal", // Font style setting.
"backgroundColor": "rgba(54, 58, 79, 0.5)", "backgroundColor": "rgba(54, 58, 79, 0.5)", // Background color setting.
"nPlusOneColor": "#c6a0f6", "nPlusOneColor": "#c6a0f6", // N plus one color setting.
"knownWordColor": "#a6da95", "knownWordColor": "#a6da95", // Known word color setting.
"jlptColors": { "jlptColors": {
"N1": "#ed8796", "N1": "#ed8796", // N1 setting.
"N2": "#f5a97f", "N2": "#f5a97f", // N2 setting.
"N3": "#f9e2af", "N3": "#f9e2af", // N3 setting.
"N4": "#a6e3a1", "N4": "#a6e3a1", // N4 setting.
"N5": "#8aadf4", "N5": "#8aadf4" // N5 setting.
}, }, // Jlpt colors setting.
"frequencyDictionary": { "frequencyDictionary": {
"enabled": false, "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
"sourcePath": "", "sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used.
"topX": 1000, "topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
"mode": "single", "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
"singleColor": "#f5a97f", "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], "bandedColors": [
}, "#ed8796",
"#f5a97f",
"#f9e2af",
"#a6e3a1",
"#8aadf4"
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting.
"secondary": { "secondary": {
"fontSize": 24, "fontSize": 24, // Font size setting.
"fontColor": "#ffffff", "fontColor": "#ffffff", // Font color setting.
"backgroundColor": "transparent", "backgroundColor": "transparent", // Background color setting.
"fontWeight": "normal", "fontWeight": "normal", // Font weight setting.
"fontStyle": "normal", "fontStyle": "normal", // Font style setting.
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif", "fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif" // Font family setting.
}, } // Secondary setting.
}, }, // Primary and secondary subtitle styling.
// ========================================== // ==========================================
// Secondary Subtitles // Secondary Subtitles
@@ -205,59 +212,62 @@
// Hot-reload: defaultMode updates live while SubMiner is running. // Hot-reload: defaultMode updates live while SubMiner is running.
// ========================================== // ==========================================
"secondarySub": { "secondarySub": {
"secondarySubLanguages": [], "secondarySubLanguages": [], // Secondary sub languages setting.
"autoLoadSecondarySub": false, "autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
"defaultMode": "hover", "defaultMode": "hover" // Default mode setting.
}, }, // Dual subtitle track options.
// ========================================== // ==========================================
// Auto Subtitle Sync // Auto Subtitle Sync
// Subsync engine and executable paths. // Subsync engine and executable paths.
// ========================================== // ==========================================
"subsync": { "subsync": {
"defaultMode": "auto", "defaultMode": "auto", // Subsync default mode. Values: auto | manual
"alass_path": "", "alass_path": "", // Alass path setting.
"ffsubsync_path": "", "ffsubsync_path": "", // Ffsubsync path setting.
"ffmpeg_path": "", "ffmpeg_path": "" // Ffmpeg path setting.
}, }, // Subsync engine and executable paths.
// ========================================== // ==========================================
// Subtitle Position // Subtitle Position
// Initial vertical subtitle position from the bottom. // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
"subtitlePosition": { "subtitlePosition": {
"yPercent": 10, "yPercent": 10 // Y percent setting.
}, }, // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
// Jimaku // Jimaku
// Jimaku API configuration and defaults. // Jimaku API configuration and defaults.
// ========================================== // ==========================================
"jimaku": { "jimaku": {
"apiBaseUrl": "https://jimaku.cc", "apiBaseUrl": "https://jimaku.cc", // Api base url setting.
"languagePreference": "ja", "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
"maxEntryResults": 10, "maxEntryResults": 10 // Maximum Jimaku search results returned.
}, }, // Jimaku API configuration and defaults.
// ========================================== // ==========================================
// YouTube Subtitle Generation // YouTube Subtitle Generation
// Defaults for subminer YouTube subtitle extraction/transcription mode. // Defaults for subminer YouTube subtitle extraction/transcription mode.
// ========================================== // ==========================================
"youtubeSubgen": { "youtubeSubgen": {
"mode": "automatic", "mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
"whisperBin": "", "whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
"whisperModel": "", "whisperModel": "", // Path to whisper model used for fallback transcription.
"primarySubLanguages": ["ja", "jpn"], "primarySubLanguages": [
}, "ja",
"jpn"
] // Comma-separated primary subtitle language priority used by the launcher.
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
// ========================================== // ==========================================
// Anilist // Anilist
// Anilist API credentials and update behavior. // Anilist API credentials and update behavior.
// ========================================== // ==========================================
"anilist": { "anilist": {
"enabled": false, "enabled": false, // Enable AniList post-watch progress updates. Values: true | false
"accessToken": "", "accessToken": "" // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
}, }, // Anilist API credentials and update behavior.
// ========================================== // ==========================================
// Jellyfin // Jellyfin
@@ -265,25 +275,33 @@
// Access token is stored in config and should be treated as a secret. // Access token is stored in config and should be treated as a secret.
// ========================================== // ==========================================
"jellyfin": { "jellyfin": {
"enabled": false, "enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
"serverUrl": "", "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"username": "", "username": "", // Default Jellyfin username used during CLI login.
"accessToken": "", "accessToken": "", // Access token setting.
"userId": "", "userId": "", // User id setting.
"deviceId": "subminer", "deviceId": "subminer", // Device id setting.
"clientName": "SubMiner", "clientName": "SubMiner", // Client name setting.
"clientVersion": "0.1.0", "clientVersion": "0.1.0", // Client version setting.
"defaultLibraryId": "", "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
"remoteControlEnabled": true, "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
"autoAnnounce": false, "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
"remoteControlDeviceName": "SubMiner", "remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
"pullPictures": false, "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
"iconCacheDir": "/tmp/subminer-jellyfin-icons", "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
"directPlayPreferred": true, "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], "directPlayContainers": [
"transcodeVideoCodec": "h264", "mkv",
}, "mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
// ========================================== // ==========================================
// Immersion Tracking // Immersion Tracking
@@ -292,19 +310,19 @@
// Policy tuning is available for queue, flush, and retention values. // Policy tuning is available for queue, flush, and retention values.
// ========================================== // ==========================================
"immersionTracking": { "immersionTracking": {
"enabled": true, "enabled": true, // Enable immersion tracking for mined subtitle metadata. Values: true | false
"dbPath": "", "dbPath": "", // Optional SQLite database path for immersion tracking. Empty value uses the default app data path.
"batchSize": 25, "batchSize": 25, // Buffered telemetry/event writes per SQLite transaction.
"flushIntervalMs": 500, "flushIntervalMs": 500, // Max delay before queue flush in milliseconds.
"queueCap": 1000, "queueCap": 1000, // In-memory write queue cap before overflow policy applies.
"payloadCapBytes": 256, "payloadCapBytes": 256, // Max JSON payload size per event before truncation.
"maintenanceIntervalMs": 86400000, "maintenanceIntervalMs": 86400000, // Maintenance cadence (prune + rollup + vacuum checks).
"retention": { "retention": {
"eventsDays": 7, "eventsDays": 7, // Raw event retention window in days.
"telemetryDays": 30, "telemetryDays": 30, // Telemetry retention window in days.
"dailyRollupsDays": 365, "dailyRollupsDays": 365, // Daily rollup retention window in days.
"monthlyRollupsDays": 1825, "monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
"vacuumIntervalDays": 7, "vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
}, } // Retention setting.
}, } // Enable/disable immersion tracking.
} }

View File

@@ -71,9 +71,11 @@ export default {
text: 'Reference', text: 'Reference',
items: [ items: [
{ text: 'Configuration', link: '/configuration' }, { text: 'Configuration', link: '/configuration' },
{ text: 'Immersion Tracking', link: '/immersion-tracking' }, { text: 'Keyboard Shortcuts', link: '/shortcuts' },
{ text: 'Anki Integration', link: '/anki-integration' }, { text: 'Anki Integration', link: '/anki-integration' },
{ text: 'Jellyfin Integration', link: '/jellyfin-integration' }, { text: 'Jellyfin Integration', link: '/jellyfin-integration' },
{ text: 'Immersion Tracking', link: '/immersion-tracking' },
{ text: 'JLPT Vocabulary', link: '/jlpt-vocab-bundle' },
{ text: 'MPV Plugin', link: '/mpv-plugin' }, { text: 'MPV Plugin', link: '/mpv-plugin' },
{ text: 'Troubleshooting', link: '/troubleshooting' }, { text: 'Troubleshooting', link: '/troubleshooting' },
], ],

View File

@@ -15,15 +15,17 @@ make docs-preview # Preview built site at http://localhost:4173
### Getting Started ### Getting Started
- [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`), 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, overlay layers, card creation
### Reference ### Reference
- [Configuration](/configuration) — Full config file reference and option details - [Configuration](/configuration) — Full config file reference and option details
- [Immersion Tracking](/immersion-tracking) — SQLite schema, retention/rollup policies, query templates, and extension points - [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, 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
- [JLPT Vocabulary](/jlpt-vocab-bundle) — Bundled term-meta bank for JLPT level underlining and frequency highlighting
- [MPV Plugin](/mpv-plugin) — Chord keybindings, subminer.conf options, script messages - [MPV Plugin](/mpv-plugin) — Chord keybindings, subminer.conf options, script messages
- [Troubleshooting](/troubleshooting) — Common issues and solutions by category - [Troubleshooting](/troubleshooting) — Common issues and solutions by category

View File

@@ -413,13 +413,13 @@ Set `openBrowser` to `false` to only print the URL without opening a browser.
### AniList ### AniList
AniList integration is opt-in and disabled by default. Enable it and provide an access token to allow SubMiner to update your watched episode progress after playback. AniList integration is opt-in and disabled by default. Enable it to allow SubMiner to update watched episode progress after playback.
```json ```json
{ {
"anilist": { "anilist": {
"enabled": true, "enabled": true,
"accessToken": "YOUR_ANILIST_ACCESS_TOKEN" "accessToken": ""
} }
} }
``` ```
@@ -427,7 +427,7 @@ AniList integration is opt-in and disabled by default. Enable it and provide an
| Option | Values | Description | | Option | Values | Description |
| ------------- | --------------- | ----------------------------------------------------------------------------------- | | ------------- | --------------- | ----------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | | `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | AniList access token used for authenticated GraphQL updates (default: empty string) | | `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior. When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior.
@@ -442,14 +442,13 @@ Current post-watch behavior:
Setup flow details: Setup flow details:
1. Set `anilist.enabled` to `true`. 1. Set `anilist.enabled` to `true`.
2. Leave `anilist.accessToken` empty and restart SubMiner to trigger setup. 2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
3. Approve access in AniList (browser window or system browser fallback). 3. Approve access in AniList.
4. Copy the returned token and paste it into `anilist.accessToken`. 4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically.
5. Save config and restart SubMiner.
Token + detection notes: Token + detection notes:
- `anilist.accessToken` can be set directly in config; SubMiner also stores the token locally for reuse if config token is later blank. - `anilist.accessToken` can be set directly in config; when blank, SubMiner uses the locally stored encrypted token from setup.
- Detection quality is best when `guessit` is installed and available on `PATH`. - Detection quality is best when `guessit` is installed and available on `PATH`.
- When `guessit` cannot parse or is missing, SubMiner falls back automatically to internal filename parsing. - When `guessit` cannot parse or is missing, SubMiner falls back automatically to internal filename parsing.

21
docs/file-size-budgets.md Normal file
View File

@@ -0,0 +1,21 @@
# File Size Budgets
Purpose: keep large modules from becoming maintenance bottlenecks.
## Current Budget
- TypeScript source files in `src/` and `launcher/`
- Soft budget: `500` LOC
- Excludes generated bundle artifacts (for example `subminer`)
## Commands
- Warning mode (non-blocking): `bun run check:file-budgets`
- Strict mode (CI/local gate): `bun run check:file-budgets:strict`
- Custom limit: `bun run scripts/check-file-budgets.ts --limit 650`
## Policy
- If file exceeds budget, prefer extracting domain module(s) first.
- Keep composition/orchestration files focused on wiring.
- Do not hand-edit generated artifacts; refactor source modules.

View File

@@ -39,17 +39,17 @@ features:
src: /assets/dual-layer.svg src: /assets/dual-layer.svg
alt: Dual layer icon alt: Dual layer icon
title: Dual-Layer Subtitles title: Dual-Layer Subtitles
details: Interactive visible overlay plus an invisible layer aligned with mpv's own rendering for seamless click-through lookup. details: Interactive visible overlay plus an invisible layer aligned with mpv's own rendering for seamless click-through lookup. Both are independently controllable.
- icon: - icon:
src: /assets/highlight.svg src: /assets/highlight.svg
alt: Highlight icon alt: Highlight icon
title: N+1 Highlighting title: N+1 Highlighting
details: Marks words you already know from your Anki deck so you can spot new vocabulary and identify N+1 sentences at a glance. details: Marks words you already know from your Anki deck so you can spot new vocabulary and identify N+1 sentences at a glance.
- icon: - icon:
src: /assets/texthooker.svg src: /assets/tokenization.svg
alt: Texthooker icon alt: Tokenization icon
title: Texthooker & WebSocket title: Immersion Tracking
details: Built-in texthooker page that receives subtitles over WebSocket — use it as a clipboard inserter or connect external tools. details: Every subtitle line, word, and mined card is logged to local SQLite. Daily and monthly rollups let you measure your progress over time.
- icon: - icon:
src: /assets/subtitle-download.svg src: /assets/subtitle-download.svg
alt: Subtitle download icon alt: Subtitle download icon
@@ -60,6 +60,11 @@ features:
alt: Keyboard icon alt: Keyboard icon
title: Keyboard-Driven title: Keyboard-Driven
details: Mine sentences, copy subtitles, cycle display modes, and trigger field grouping — all from configurable shortcuts. details: Mine sentences, copy subtitles, cycle display modes, and trigger field grouping — all from configurable shortcuts.
- icon:
src: /assets/texthooker.svg
alt: Texthooker icon
title: Texthooker & WebSocket
details: Built-in texthooker page that receives subtitles over WebSocket — use it as a clipboard inserter or connect external tools.
--- ---
<style> <style>
@@ -104,14 +109,20 @@ features:
.workflow-steps { .workflow-steps {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 1px; gap: 1px;
background: var(--vp-c-divider); background: var(--vp-c-divider);
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
} }
@media (max-width: 768px) { @media (max-width: 960px) {
.workflow-steps {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 640px) {
.workflow-steps { .workflow-steps {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -158,7 +169,7 @@ features:
<div class="workflow-step"> <div class="workflow-step">
<div class="step-number">02</div> <div class="step-number">02</div>
<div class="step-title">Look Up</div> <div class="step-title">Look Up</div>
<div class="step-desc">Hover over a word in the subtitle overlay and hold Shift to trigger a Yomitan lookup.</div> <div class="step-desc">Hover over a word in the subtitle overlay to trigger a Yomitan lookup — no browser or tab-switching needed.</div>
</div> </div>
<div class="workflow-step"> <div class="workflow-step">
<div class="step-number">03</div> <div class="step-number">03</div>
@@ -170,6 +181,11 @@ features:
<div class="step-title">Enrich</div> <div class="step-title">Enrich</div>
<div class="step-desc">SubMiner fills in the sentence, audio clip, screenshot, and translation — no extra steps.</div> <div class="step-desc">SubMiner fills in the sentence, audio clip, screenshot, and translation — no extra steps.</div>
</div> </div>
<div class="workflow-step">
<div class="step-number">05</div>
<div class="step-title">Track</div>
<div class="step-desc">Every line seen and card mined is logged to local SQLite. Daily and monthly rollups let you measure immersion over time.</div>
</div>
</div> </div>
</div> </div>

View File

@@ -5,26 +5,27 @@
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
*/ */
{ {
// ========================================== // ==========================================
// Overlay Auto-Start // Overlay Auto-Start
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles. // When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
// ========================================== // ==========================================
"auto_start_overlay": false, "auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
// ========================================== // ==========================================
// Visible Overlay Subtitle Binding // Visible Overlay Subtitle Binding
// Control whether visible overlay toggles also toggle MPV subtitle visibility. // Control whether visible overlay toggles also toggle MPV subtitle visibility.
// When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged. // When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.
// ========================================== // ==========================================
"bind_visible_overlay_to_mpv_sub_visibility": true, "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.
// ========================================== // ==========================================
"texthooker": { "texthooker": {
"openBrowser": true, "openBrowser": true // Open browser setting. Values: true | false
}, }, // Control whether browser opens automatically for texthooker.
// ========================================== // ==========================================
// WebSocket Server // WebSocket Server
@@ -32,9 +33,9 @@
// Auto mode disables built-in server if mpv_websocket is detected. // Auto mode disables built-in server if mpv_websocket is detected.
// ========================================== // ==========================================
"websocket": { "websocket": {
"enabled": "auto", "enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
"port": 6677, "port": 6677 // Built-in subtitle websocket server port.
}, }, // Built-in WebSocket server broadcasts subtitle text to connected clients.
// ========================================== // ==========================================
// Logging // Logging
@@ -42,8 +43,8 @@
// Set to debug for full runtime diagnostics. // Set to debug for full runtime diagnostics.
// ========================================== // ==========================================
"logging": { "logging": {
"level": "info", "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
}, }, // Controls logging verbosity.
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
@@ -52,93 +53,93 @@
// Most other AnkiConnect settings still require restart. // Most other AnkiConnect settings still require restart.
// ========================================== // ==========================================
"ankiConnect": { "ankiConnect": {
"enabled": false, "enabled": false, // Enable AnkiConnect integration. Values: true | false
"url": "http://127.0.0.1:8765", "url": "http://127.0.0.1:8765", // Url setting.
"pollingRate": 3000, "pollingRate": 3000, // Polling interval in milliseconds.
"tags": ["SubMiner"], "tags": [
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"fields": { "fields": {
"audio": "ExpressionAudio", "audio": "ExpressionAudio", // Audio setting.
"image": "Picture", "image": "Picture", // Image setting.
"sentence": "Sentence", "sentence": "Sentence", // Sentence setting.
"miscInfo": "MiscInfo", "miscInfo": "MiscInfo", // Misc info setting.
"translation": "SelectionText", "translation": "SelectionText" // Translation setting.
}, }, // Fields setting.
"ai": { "ai": {
"enabled": false, "enabled": false, // Enabled setting. Values: true | false
"alwaysUseAiTranslation": false, "alwaysUseAiTranslation": false, // Always use ai translation setting. Values: true | false
"apiKey": "", "apiKey": "", // Api key setting.
"model": "openai/gpt-4o-mini", "model": "openai/gpt-4o-mini", // Model setting.
"baseUrl": "https://openrouter.ai/api", "baseUrl": "https://openrouter.ai/api", // Base url setting.
"targetLanguage": "English", "targetLanguage": "English", // Target language setting.
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", "systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting.
}, }, // Ai setting.
"media": { "media": {
"generateAudio": true, "generateAudio": true, // Generate audio setting. Values: true | false
"generateImage": true, "generateImage": true, // Generate image setting. Values: true | false
"imageType": "static", "imageType": "static", // Image type setting.
"imageFormat": "jpg", "imageFormat": "jpg", // Image format setting.
"imageQuality": 92, "imageQuality": 92, // Image quality setting.
"animatedFps": 10, "animatedFps": 10, // Animated fps setting.
"animatedMaxWidth": 640, "animatedMaxWidth": 640, // Animated max width setting.
"animatedCrf": 35, "animatedCrf": 35, // Animated crf setting.
"audioPadding": 0.5, "audioPadding": 0.5, // Audio padding setting.
"fallbackDuration": 3, "fallbackDuration": 3, // Fallback duration setting.
"maxMediaDuration": 30, "maxMediaDuration": 30 // Max media duration setting.
}, }, // Media setting.
"behavior": { "behavior": {
"overwriteAudio": true, "overwriteAudio": true, // Overwrite audio setting. Values: true | false
"overwriteImage": true, "overwriteImage": true, // Overwrite image setting. Values: true | false
"mediaInsertMode": "append", "mediaInsertMode": "append", // Media insert mode setting.
"highlightWord": true, "highlightWord": true, // Highlight word setting. Values: true | false
"notificationType": "osd", "notificationType": "osd", // Notification type setting.
"autoUpdateNewCards": true, "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, }, // Behavior setting.
"nPlusOne": { "nPlusOne": {
"highlightEnabled": false, "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"matchMode": "headword", "matchMode": "headword", // Known-word matching strategy for N+1 highlighting. Values: headword | surface
"decks": [], "decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
"minSentenceWords": 3, "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
"nPlusOne": "#c6a0f6", "nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
"knownWord": "#a6da95", "knownWord": "#a6da95" // Color used for legacy known-word highlights.
}, }, // N plus one setting.
"metadata": { "metadata": {
"pattern": "[SubMiner] %f (%t)", "pattern": "[SubMiner] %f (%t)" // Pattern setting.
}, }, // Metadata setting.
"isLapis": { "isLapis": {
"enabled": false, "enabled": false, // Enabled setting. Values: true | false
"sentenceCardModel": "Japanese sentences", "sentenceCardModel": "Japanese sentences" // Sentence card model setting.
}, }, // Is lapis setting.
"isKiku": { "isKiku": {
"enabled": false, "enabled": false, // Enabled setting. Values: true | false
"fieldGrouping": "disabled", "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true, "deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
}, } // Is kiku setting.
}, }, // Automatic Anki updates and media generation options.
// ========================================== // ==========================================
// Keyboard Shortcuts // Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable. // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// Fixed (non-configurable) overlay shortcuts:
// - Ctrl/Cmd+A: append clipboard video path to MPV playlist
// Hot-reload: shortcut changes apply live and update the session help modal on reopen. // Hot-reload: shortcut changes apply live and update the session help modal on reopen.
// ========================================== // ==========================================
"shortcuts": { "shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O", "toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", "toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
"copySubtitle": "CommandOrControl+C", "copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
"copySubtitleMultiple": "CommandOrControl+Shift+C", "copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
"updateLastCardFromClipboard": "CommandOrControl+V", "updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
"triggerFieldGrouping": "CommandOrControl+G", "triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting.
"triggerSubsync": "Ctrl+Alt+S", "triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting.
"mineSentence": "CommandOrControl+S", "mineSentence": "CommandOrControl+S", // Mine sentence setting.
"mineSentenceMultiple": "CommandOrControl+Shift+S", "mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting.
"multiCopyTimeoutMs": 3000, "multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
"toggleSecondarySub": "CommandOrControl+Shift+V", "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
"openJimaku": "Ctrl+Shift+J", "openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
}, }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================
// Invisible Overlay // Invisible Overlay
@@ -147,8 +148,8 @@
// This edit-mode shortcut is fixed and is not currently configurable. // This edit-mode shortcut is fixed and is not currently configurable.
// ========================================== // ==========================================
"invisibleOverlay": { "invisibleOverlay": {
"startupVisibility": "platform-default", "startupVisibility": "platform-default" // Startup visibility setting.
}, }, // Startup behavior for the invisible interactive subtitle mining layer.
// ========================================== // ==========================================
// Keybindings (MPV Commands) // Keybindings (MPV Commands)
@@ -156,7 +157,7 @@
// Set command to null to disable a default keybinding. // Set command to null to disable a default keybinding.
// Hot-reload: keybinding changes apply live and update the session help modal on reopen. // Hot-reload: keybinding changes apply live and update the session help modal on reopen.
// ========================================== // ==========================================
"keybindings": [], "keybindings": [], // Extra keybindings that are merged with built-in defaults.
// ========================================== // ==========================================
// Subtitle Appearance // Subtitle Appearance
@@ -164,39 +165,45 @@
// Hot-reload: subtitle style changes apply live without restarting SubMiner. // Hot-reload: subtitle style changes apply live without restarting SubMiner.
// ========================================== // ==========================================
"subtitleStyle": { "subtitleStyle": {
"enableJlpt": false, "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif", "fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif", // Font family setting.
"fontSize": 35, "fontSize": 35, // Font size setting.
"fontColor": "#cad3f5", "fontColor": "#cad3f5", // Font color setting.
"fontWeight": "normal", "fontWeight": "normal", // Font weight setting.
"fontStyle": "normal", "fontStyle": "normal", // Font style setting.
"backgroundColor": "rgba(54, 58, 79, 0.5)", "backgroundColor": "rgba(54, 58, 79, 0.5)", // Background color setting.
"nPlusOneColor": "#c6a0f6", "nPlusOneColor": "#c6a0f6", // N plus one color setting.
"knownWordColor": "#a6da95", "knownWordColor": "#a6da95", // Known word color setting.
"jlptColors": { "jlptColors": {
"N1": "#ed8796", "N1": "#ed8796", // N1 setting.
"N2": "#f5a97f", "N2": "#f5a97f", // N2 setting.
"N3": "#f9e2af", "N3": "#f9e2af", // N3 setting.
"N4": "#a6e3a1", "N4": "#a6e3a1", // N4 setting.
"N5": "#8aadf4", "N5": "#8aadf4" // N5 setting.
}, }, // Jlpt colors setting.
"frequencyDictionary": { "frequencyDictionary": {
"enabled": false, "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
"sourcePath": "", "sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used.
"topX": 1000, "topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
"mode": "single", "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
"singleColor": "#f5a97f", "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], "bandedColors": [
}, "#ed8796",
"#f5a97f",
"#f9e2af",
"#a6e3a1",
"#8aadf4"
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting.
"secondary": { "secondary": {
"fontSize": 24, "fontSize": 24, // Font size setting.
"fontColor": "#ffffff", "fontColor": "#ffffff", // Font color setting.
"backgroundColor": "transparent", "backgroundColor": "transparent", // Background color setting.
"fontWeight": "normal", "fontWeight": "normal", // Font weight setting.
"fontStyle": "normal", "fontStyle": "normal", // Font style setting.
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif", "fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif" // Font family setting.
}, } // Secondary setting.
}, }, // Primary and secondary subtitle styling.
// ========================================== // ==========================================
// Secondary Subtitles // Secondary Subtitles
@@ -205,59 +212,62 @@
// Hot-reload: defaultMode updates live while SubMiner is running. // Hot-reload: defaultMode updates live while SubMiner is running.
// ========================================== // ==========================================
"secondarySub": { "secondarySub": {
"secondarySubLanguages": [], "secondarySubLanguages": [], // Secondary sub languages setting.
"autoLoadSecondarySub": false, "autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
"defaultMode": "hover", "defaultMode": "hover" // Default mode setting.
}, }, // Dual subtitle track options.
// ========================================== // ==========================================
// Auto Subtitle Sync // Auto Subtitle Sync
// Subsync engine and executable paths. // Subsync engine and executable paths.
// ========================================== // ==========================================
"subsync": { "subsync": {
"defaultMode": "auto", "defaultMode": "auto", // Subsync default mode. Values: auto | manual
"alass_path": "", "alass_path": "", // Alass path setting.
"ffsubsync_path": "", "ffsubsync_path": "", // Ffsubsync path setting.
"ffmpeg_path": "", "ffmpeg_path": "" // Ffmpeg path setting.
}, }, // Subsync engine and executable paths.
// ========================================== // ==========================================
// Subtitle Position // Subtitle Position
// Initial vertical subtitle position from the bottom. // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
"subtitlePosition": { "subtitlePosition": {
"yPercent": 10, "yPercent": 10 // Y percent setting.
}, }, // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
// Jimaku // Jimaku
// Jimaku API configuration and defaults. // Jimaku API configuration and defaults.
// ========================================== // ==========================================
"jimaku": { "jimaku": {
"apiBaseUrl": "https://jimaku.cc", "apiBaseUrl": "https://jimaku.cc", // Api base url setting.
"languagePreference": "ja", "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
"maxEntryResults": 10, "maxEntryResults": 10 // Maximum Jimaku search results returned.
}, }, // Jimaku API configuration and defaults.
// ========================================== // ==========================================
// YouTube Subtitle Generation // YouTube Subtitle Generation
// Defaults for subminer YouTube subtitle extraction/transcription mode. // Defaults for subminer YouTube subtitle extraction/transcription mode.
// ========================================== // ==========================================
"youtubeSubgen": { "youtubeSubgen": {
"mode": "automatic", "mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
"whisperBin": "", "whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
"whisperModel": "", "whisperModel": "", // Path to whisper model used for fallback transcription.
"primarySubLanguages": ["ja", "jpn"], "primarySubLanguages": [
}, "ja",
"jpn"
] // Comma-separated primary subtitle language priority used by the launcher.
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
// ========================================== // ==========================================
// Anilist // Anilist
// Anilist API credentials and update behavior. // Anilist API credentials and update behavior.
// ========================================== // ==========================================
"anilist": { "anilist": {
"enabled": false, "enabled": false, // Enable AniList post-watch progress updates. Values: true | false
"accessToken": "", "accessToken": "" // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
}, }, // Anilist API credentials and update behavior.
// ========================================== // ==========================================
// Jellyfin // Jellyfin
@@ -265,25 +275,33 @@
// Access token is stored in config and should be treated as a secret. // Access token is stored in config and should be treated as a secret.
// ========================================== // ==========================================
"jellyfin": { "jellyfin": {
"enabled": false, "enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
"serverUrl": "", "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"username": "", "username": "", // Default Jellyfin username used during CLI login.
"accessToken": "", "accessToken": "", // Access token setting.
"userId": "", "userId": "", // User id setting.
"deviceId": "subminer", "deviceId": "subminer", // Device id setting.
"clientName": "SubMiner", "clientName": "SubMiner", // Client name setting.
"clientVersion": "0.1.0", "clientVersion": "0.1.0", // Client version setting.
"defaultLibraryId": "", "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
"remoteControlEnabled": true, "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
"autoAnnounce": false, "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
"remoteControlDeviceName": "SubMiner", "remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
"pullPictures": false, "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
"iconCacheDir": "/tmp/subminer-jellyfin-icons", "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
"directPlayPreferred": true, "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], "directPlayContainers": [
"transcodeVideoCodec": "h264", "mkv",
}, "mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
// ========================================== // ==========================================
// Immersion Tracking // Immersion Tracking
@@ -292,19 +310,19 @@
// Policy tuning is available for queue, flush, and retention values. // Policy tuning is available for queue, flush, and retention values.
// ========================================== // ==========================================
"immersionTracking": { "immersionTracking": {
"enabled": true, "enabled": true, // Enable immersion tracking for mined subtitle metadata. Values: true | false
"dbPath": "", "dbPath": "", // Optional SQLite database path for immersion tracking. Empty value uses the default app data path.
"batchSize": 25, "batchSize": 25, // Buffered telemetry/event writes per SQLite transaction.
"flushIntervalMs": 500, "flushIntervalMs": 500, // Max delay before queue flush in milliseconds.
"queueCap": 1000, "queueCap": 1000, // In-memory write queue cap before overflow policy applies.
"payloadCapBytes": 256, "payloadCapBytes": 256, // Max JSON payload size per event before truncation.
"maintenanceIntervalMs": 86400000, "maintenanceIntervalMs": 86400000, // Maintenance cadence (prune + rollup + vacuum checks).
"retention": { "retention": {
"eventsDays": 7, "eventsDays": 7, // Raw event retention window in days.
"telemetryDays": 30, "telemetryDays": 30, // Telemetry retention window in days.
"dailyRollupsDays": 365, "dailyRollupsDays": 365, // Daily rollup retention window in days.
"monthlyRollupsDays": 1825, "monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
"vacuumIntervalDays": 7, "vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
}, } // Retention setting.
}, } // Enable/disable immersion tracking.
} }

132
docs/shortcuts.md Normal file
View File

@@ -0,0 +1,132 @@
# Keyboard Shortcuts
All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindings`. Set any shortcut to `null` to disable it.
## Global Shortcuts
These work system-wide regardless of which window has focus.
| Shortcut | Action | Configurable |
| -------- | ------ | ------------ |
| `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) |
::: tip
Global shortcuts are registered with the OS. If they conflict with another application, update them in `shortcuts` config and restart SubMiner.
:::
## Mining Shortcuts
These work when the overlay window has focus.
| Shortcut | Action | Config key |
| -------- | ------ | ---------- |
| `Ctrl/Cmd+S` | Mine current subtitle as sentence card | `shortcuts.mineSentence` |
| `Ctrl/Cmd+Shift+S` | Mine multiple lines (press 19 to select count) | `shortcuts.mineSentenceMultiple` |
| `Ctrl/Cmd+C` | Copy current subtitle text | `shortcuts.copySubtitle` |
| `Ctrl/Cmd+Shift+C` | Copy multiple lines (press 19 to select count) | `shortcuts.copySubtitleMultiple` |
| `Ctrl/Cmd+V` | Update last Anki card from clipboard text | `shortcuts.updateLastCardFromClipboard` |
| `Ctrl/Cmd+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` |
| `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` |
The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1``9` to select how many recent subtitle lines to combine.
## Overlay Controls
These control playback and subtitle display. They require overlay window focus.
| Shortcut | Action |
| -------- | ------ |
| `Space` | Toggle mpv pause |
| `ArrowRight` | Seek forward 5 seconds |
| `ArrowLeft` | Seek backward 5 seconds |
| `ArrowUp` | Seek forward 60 seconds |
| `ArrowDown` | Seek backward 60 seconds |
| `Shift+H` | Jump to previous subtitle |
| `Shift+L` | Jump to next subtitle |
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
| `Q` | Quit mpv |
| `Ctrl+W` | Quit mpv |
| `Right-click` | Toggle pause (outside subtitle area) |
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
These keybindings can be overridden or disabled via the `keybindings` config array.
## Subtitle & Feature Shortcuts
| Shortcut | Action | Config key |
| -------- | ------ | ---------- |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
## Invisible Subtitle Position Edit Mode
Enter edit mode to fine-tune invisible overlay alignment with mpv's native subtitles.
| Shortcut | Action |
| -------- | ------ |
| `Ctrl/Cmd+Shift+P` | Toggle position edit mode |
| `ArrowKeys` or `hjkl` | Nudge position by 1 px |
| `Shift+Arrow` | Nudge position by 4 px |
| `Enter` or `Ctrl+S` | Save position and exit edit mode |
| `Esc` | Cancel and discard changes |
## MPV Plugin Chords
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
| Chord | Action |
| ----- | ------ |
| `y-y` | Open SubMiner menu (OSD) |
| `y-s` | Start overlay |
| `y-S` | Stop 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-r` | Restart overlay |
| `y-c` | Check overlay status |
| `y-d` | Toggle overlay DevTools (dev/debug use) |
## Drag-and-Drop
| Gesture | Action |
| ------- | ------ |
| Drop file(s) onto overlay | Replace current mpv playlist with dropped files |
| `Shift` + drop file(s) | Append all dropped files to current mpv playlist |
## Customizing Shortcuts
All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/api/accelerator), for example `"CommandOrControl+Shift+M"`. Use `null` to disable a shortcut.
```jsonc
{
"shortcuts": {
"mineSentence": "CommandOrControl+S",
"copySubtitle": "CommandOrControl+C",
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
"openJimaku": null // disabled
}
}
```
The `keybindings` array overrides or extends the overlay's built-in key handling for mpv commands:
```jsonc
{
"keybindings": [
{ "key": "f", "command": ["cycle", "fullscreen"] },
{ "key": "m", "command": ["cycle", "mute"] },
{ "key": "Space", "command": null } // disable default Space → pause
]
}
```
Both `shortcuts` and `keybindings` are [hot-reloadable](/configuration#hot-reload-behavior) — changes take effect without restarting SubMiner.

View File

@@ -4,4 +4,7 @@ Read first. Keep concise.
| agent_id | alias | mission | status | file | last_update_utc | | agent_id | alias | mission | status | file | last_update_utc |
| ------------ | -------------- | ---------------------------------------------------- | --------- | ------------------------------------- | ---------------------- | | ------------ | -------------- | ---------------------------------------------------- | --------- | ------------------------------------- | ---------------------- |
| `codex-main` | `planner-exec` | `Unify config path resolution across app + launcher` | `handoff` | `docs/subagents/agents/codex-main.md` | `2026-02-19T09:05:26Z` | | `codex-main` | `planner-exec` | `Fix frequency/N+1 regression in plugin --start flow` | `in_progress` | `docs/subagents/agents/codex-main.md` | `2026-02-19T19:36:46Z` |
| `codex-config-validation-20260219T172015Z-iiyf` | `codex-config-validation` | `Find root cause of config validation error for ~/.config/SubMiner/config.jsonc` | `completed` | `docs/subagents/agents/codex-config-validation-20260219T172015Z-iiyf.md` | `2026-02-19T17:26:17Z` |
| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T00:00:55Z` |
| `codex-anilist-deeplink-20260219T233926Z` | `anilist-deeplink` | `Fix external subminer:// AniList callback handling from browser` | `done` | `docs/subagents/agents/codex-anilist-deeplink-20260219T233926Z.md` | `2026-02-19T23:59:21Z` |

View File

@@ -1,11 +1,11 @@
# Agent: <agent_id> # Agent: `<agent_id>`
- alias: <short label> - alias: `<short label>`
- mission: <one-line focus> - mission: `<one-line focus>`
- status: <planning|editing|testing|blocked|handoff|done> - status: `<planning|editing|testing|blocked|handoff|done>`
- branch: <name> - branch: `<name>`
- started_at: <UTC ISO> - started_at: `<UTC ISO>`
- heartbeat_minutes: <n> - heartbeat_minutes: `<n>`
## Current Work (newest first) ## Current Work (newest first)
- [YYYY-MM-DDTHH:MM:SSZ] intent: ... - [YYYY-MM-DDTHH:MM:SSZ] intent: ...

View File

@@ -0,0 +1,38 @@
# Agent: codex-anilist-deeplink-20260219T233926Z
- alias: anilist-deeplink
- mission: Fix external subminer:// AniList callback handling from browser
- status: done
- branch: main
- started_at: 2026-02-19T23:39:26Z
- heartbeat_minutes: 5
## Current Work (newest first)
- [2026-02-19T23:59:21Z] handoff: updated backlog/docs/config artifacts for AniList flow completion; TASK-29.3 marked Done with AC/DoD checked and implementation notes.
- [2026-02-19T23:59:21Z] test: `bun run generate:config-example` pass (includes build), `bun test src/main/runtime/anilist-setup.test.ts` pass (11/11).
- [2026-02-19T23:42:42Z] progress: added app-level protocol registration (`setAsDefaultProtocolClient`) plus `open-url` and `second-instance` deep-link listeners; AniList token consumption moved to shared handler so browser callback `subminer://anilist-setup?access_token=...` saves token even when setup window is not navigating.
- [2026-02-19T23:42:42Z] test: `bun test src/main/runtime/anilist-setup.test.ts` pass (11/11); `bun run build` pass.
- [2026-02-19T23:39:26Z] intent: fix AniList callback deep-link handoff from external browser to app protocol handler; verify token is consumed and setup resolves without requiring in-window nav interception.
- [2026-02-19T23:39:26Z] planned files: `src/main.ts`, `src/main/runtime/anilist-setup.ts`, `src/main/runtime/anilist-setup.test.ts`, `backlog/tasks/task-29.3 - Fix-AniList-OAuth-callback-token-handling-in-setup-window.md`.
- [2026-02-19T23:39:26Z] assumptions: worker emits valid `subminer://anilist-setup?access_token=...` URL; failure is missing app-level protocol registration/open-url handling.
## Files Touched
- `docs/subagents/INDEX.md`
- `docs/subagents/agents/codex-anilist-deeplink-20260219T233926Z.md`
- `src/main.ts`
- `src/main/runtime/anilist-setup.ts`
- `src/main/runtime/anilist-setup.test.ts`
- `backlog/tasks/task-29.3 - Fix-AniList-OAuth-callback-token-handling-in-setup-window.md`
- `docs/configuration.md`
- `src/config/definitions.ts`
- `config.example.jsonc`
- `docs/public/config.example.jsonc`
## Assumptions
- Existing TASK-29.3 covers this bugfix; no new backlog ticket needed.
## Open Questions / Blockers
- none
## Next Step
- Wait for next user direction.

View File

@@ -0,0 +1,39 @@
# Agent Log
- agent_id: `codex-config-validation-20260219T172015Z-iiyf`
- alias: `codex-config-validation`
- mission: `Find root cause of config validation error for ~/.config/SubMiner/config.jsonc`
- status: `completed`
- last_update_utc: `2026-02-19T17:26:17Z`
## Intent
- inspect validation code path + expected schema
- inspect user config values
- map failing field/type; report exact issue
## Planned Files
- `~/.config/SubMiner/config.jsonc`
- `src/**` (validation/schema)
## Assumptions
- config error produced by current repo binary/schema
- user wants diagnosis only; no edits unless asked
## Run Notes
- phase: `inspect -> reproduce -> handoff`
- strict parse: ok (`reloadConfigStrict` succeeded)
- warnings:
- `ankiConnect.openRouter` deprecated; use `ankiConnect.ai`
- `ankiConnect.isLapis.sentenceCardSentenceField` deprecated; fixed internal value
- `ankiConnect.isLapis.sentenceCardAudioField` deprecated; fixed internal value
## Handoff
- touched files: `docs/subagents/INDEX.md`, `docs/subagents/agents/codex-config-validation-20260219T172015Z-iiyf.md`, `src/main/config-validation.ts`, `src/main/runtime/startup-config.ts`, `src/core/services/config-hot-reload.ts`, `src/main.ts`, `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, `src/core/services/config-hot-reload.test.ts`
- key decision: report warnings as likely user-visible "config validation issue(s)" root cause; then implement detailed warning bodies for startup + hot-reload notifications
- blocker: none
- next step: optional tune max-lines/format for notification bodies

View File

@@ -1,14 +1,40 @@
# Agent: codex-main # Agent: codex-main
- alias: planner-exec - alias: planner-exec
- mission: Unify config path resolution across app + launcher - mission: Fix frequency/N+1 regression in plugin --start flow
- status: handoff - status: in_progress
- branch: main - branch: main
- started_at: 2026-02-19T08:06:28Z - started_at: 2026-02-19T08:06:28Z
- heartbeat_minutes: 20 - heartbeat_minutes: 20
## Current Work (newest first) ## Current Work (newest first)
- [2026-02-19T19:36:46Z] progress: config confirmed frequency enabled (`~/.config/SubMiner/config.jsonc`); likely mode-latch issue after initial `--texthooker`.
- [2026-02-19T19:36:46Z] change: in `src/main.ts`, `handleCliCommand` now disables `texthookerOnlyMode` on follow-up `--start`/overlay commands and triggers background warmups.
- [2026-02-19T19:36:46Z] test: `bun run build` pass; `bun test src/core/services/cli-command.test.ts src/cli/args.test.ts src/core/services/startup-bootstrap.test.ts` pass (28/28).
- [2026-02-19T19:29:00Z] progress: found second-instance toggle gap: MPV connect was gated to initial-source toggles only; plugin toggle handoff could show overlay without subtitle stream/tokenization.
- [2026-02-19T19:29:00Z] change: updated `src/core/services/cli-command.ts` so toggle/toggle-visible/toggle-invisible trigger MPV connect regardless of source when app is already running.
- [2026-02-19T19:29:00Z] test: added `handleCliCommand connects MPV for toggle on second-instance`; `bun test src/core/services/cli-command.test.ts` pass (19/19).
- [2026-02-19T19:24:18Z] progress: root cause confirmed: subtitle tokenization is skipped when no overlay windows (`src/main.ts`), and `--start` command path did not initialize overlay runtime.
- [2026-02-19T19:24:18Z] change: `src/core/services/cli-command.ts` now initializes overlay runtime for `--start`; second-instance `--start` is ignored only when overlay runtime is already initialized.
- [2026-02-19T19:24:18Z] test: `src/core/services/cli-command.test.ts` covers start-driven init + second-instance behaviors; `bun test src/core/services/cli-command.test.ts` pass (18/18).
- [2026-02-19T19:04:58Z] intent: investigate report that frequency tracking resolves from app config location instead of active app binary root during just-built binary tests; add regression test first, then patch runtime path resolution.
- [2026-02-19T19:04:58Z] planned files: `src/main/frequency-dictionary-runtime.ts`, `src/core/services/frequency-dictionary.ts`, `src/core/services/frequency-dictionary.test.ts`, `backlog/tasks/*` (ticket link).
- [2026-02-19T19:04:58Z] assumptions: runtime should prefer binary-adjacent frequency assets when `SUBMINER_BINARY_PATH` points to a built app binary; config-path-based defaults should remain fallback.
- [2026-02-19T19:05:00Z] backlog: linked work to `TASK-87` (`backlog/tasks/task-87 - Resolve-frequency-storage-path-relative-to-active-app-binary.md`).
- [2026-02-19T16:58:00Z] intent: investigate AniList login regression (`unsupported_grant_type`), add callback token-consumption tests first, then wire `openAnilistSetupWindow` navigation handlers to persist OAuth token from callback URL/hash.
- [2026-02-19T16:54:18Z] handoff: implemented inline same-line option comments in config template output, including enum/boolean value lists where known; regenerated `config.example.jsonc` and `docs/public/config.example.jsonc`.
- [2026-02-19T16:54:18Z] test: `bun run build && node --test dist/config/config.test.js` -> pass (33/33); macOS helper compile sandbox-denied cache path fallback unchanged.
- [2026-02-19T16:51:29Z] intent: user requested inline same-line comments for each example config option with short purpose + allowed values where enum-like; target `config.example.jsonc` (+ `docs/public/config.example.jsonc` parity if needed).
- [2026-02-19T10:24:42Z] progress: continued TASK-85 decomposition slices in `src/main.ts`; extracted config-derived runtime wrappers, AniList setup helpers, clipboard queue runtime, AniList state runtime, immersion media runtime, startup config runtime, and main subsync runtime under `src/main/runtime/`; updated call sites.
- [2026-02-19T10:24:42Z] test: `bun run build` pass (sandbox Swift cache fallback unchanged), `node --test dist/main/runtime/anilist-setup.test.js dist/main/runtime/clipboard-queue.test.js dist/main/runtime/anilist-state.test.js dist/main/runtime/immersion-media.test.js dist/main/runtime/immersion-startup.test.js dist/main/runtime/startup-config.test.js dist/main/config-validation.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js` -> pass (35/35).
- [2026-02-19T10:05:25Z] progress: continued TASK-85 refactor execution; extracted app-ready config runtime handlers to `src/main/runtime/startup-config.ts` + tests and immersion tracker startup handler to `src/main/runtime/immersion-startup.ts` + tests; wired `src/main.ts` call sites to factories; `src/main.ts` now 3250 LOC.
- [2026-02-19T10:05:25Z] test: `bun run build` pass (same macOS helper sandbox fallback), `node --test dist/main/runtime/immersion-startup.test.js dist/main/runtime/startup-config.test.js dist/main/config-validation.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js` -> pass (24/24).
- [2026-02-19T09:55:47Z] handoff: initialized Backlog non-interactive (`backlog init --defaults --integration-mode mcp --auto-open-browser false`), moved TASK-85 to In Progress, completed Task 2 guardrail slice (`scripts/check-file-budgets.ts`, docs, package scripts), and started Task 3 with first `src/main.ts` extraction (`src/main/config-validation.ts` + tests); build/tests pass.
- [2026-02-19T09:55:47Z] test: `bun run check:file-budgets` (warn list), `bun run check:file-budgets:strict` (expected fail), `bun run build` (pass; macOS helper falls back to source due sandbox cache permission), `node --test dist/main/config-validation.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js` (15/15 pass).
- [2026-02-19T09:47:52Z] handoff: delivered concrete maintainability plan at `docs/plans/2026-02-19-repo-maintainability-refactor-plan.md` plus umbrella ticket `TASK-85`; ready for execution-mode choice.
- [2026-02-19T09:45:26Z] intent: user requested concrete maintainability/readability plan via writing-plans + refactor skills; focus hotspots `src/main.ts`, `src/anki-integration.ts`, `plugin/subminer.lua`, generated `subminer` launcher artifact strategy.
- [2026-02-19T09:45:26Z] progress: scanned LOC hotspots and module graph; confirmed `subminer` is generated/ignored build artifact (`make build-launcher`) and major live hotspots are `src/main.ts` (3316 LOC), `src/anki-integration.ts` (1703 LOC), `src/config/service.ts` (1565 LOC), `src/core/services/immersion-tracker-service.ts` (1470 LOC).
- [2026-02-19T09:05:26Z] handoff: completed TASK-70 (Done); unified config path resolution into shared `src/config/path-resolution.ts`, wired app+launcher call sites, added precedence tests, verified build/tests/launcher bundle, and checked AC/DoD in Backlog. - [2026-02-19T09:05:26Z] handoff: completed TASK-70 (Done); unified config path resolution into shared `src/config/path-resolution.ts`, wired app+launcher call sites, added precedence tests, verified build/tests/launcher bundle, and checked AC/DoD in Backlog.
- [2026-02-19T09:05:26Z] test: `bun run build && node --test dist/config/path-resolution.test.js dist/config/config.test.js && bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=/tmp/subminer-task70` -> pass. - [2026-02-19T09:05:26Z] test: `bun run build && node --test dist/config/path-resolution.test.js dist/config/config.test.js && bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=/tmp/subminer-task70` -> pass.
- [2026-02-19T08:57:36Z] progress: plan saved to `docs/plans/2026-02-19-task-70-unify-config-path-resolution.md`, mirrored to backlog TASK-70 `planSet`; moving to edit/test execution. - [2026-02-19T08:57:36Z] progress: plan saved to `docs/plans/2026-02-19-task-70-unify-config-path-resolution.md`, mirrored to backlog TASK-70 `planSet`; moving to edit/test execution.
@@ -62,6 +88,13 @@
- `docs/mining-workflow.md` - `docs/mining-workflow.md`
- `backlog/tasks/task-83 - Simplify-isLapis-sentence-card-field-config-to-fixed-field-names.md` - `backlog/tasks/task-83 - Simplify-isLapis-sentence-card-field-config-to-fixed-field-names.md`
- `backlog/tasks/task-38 - Add-user-friendly-config-validation-errors-on-startup.md` - `backlog/tasks/task-38 - Add-user-friendly-config-validation-errors-on-startup.md`
- `docs/plans/2026-02-19-repo-maintainability-refactor-plan.md`
- `backlog/tasks/task-85 - Refactor-large-files-for-maintainability-and-readability.md`
- `backlog/config.yml`
- `scripts/check-file-budgets.ts`
- `docs/file-size-budgets.md`
- `src/main/config-validation.ts`
- `src/main/config-validation.test.ts`
## Assumptions ## Assumptions
@@ -73,4 +106,17 @@
## Next Step ## Next Step
- Await user review; optional next step is commit for TASK-70 changes. - Continue Task 3: extract next `src/main.ts` runtime slice (startup reload/logging branch) into `src/main/runtime/*` with seam tests, then re-run build + targeted core tests.
- [2026-02-19T17:20:00Z] intent: improve launcher help output so top-level help auto-lists available subcommands from command registry; add/adjust tests if present.
- [2026-02-19T16:56:43Z] intent: improve launcher help output so top-level help auto-lists available subcommands from command registry; add/adjust tests if present.
- [2026-02-19T16:59:23Z] progress: added auto-generated root help subcommand section by rendering Commander subcommand registry (`launcher/config.ts`) and covered with launcher help regression test (`launcher/config.test.ts`).
- [2026-02-19T16:59:23Z] test: `bun test launcher/config.test.ts` pass; `make build-launcher && ./subminer -h` shows Commands list with jellyfin/yt/doctor/config/mpv/texthooker.
- [2026-02-19T16:59:23Z] handoff: completed requested launcher help improvement; top-level `-h` now includes available subcommands without hard-coded help string updates.
- [2026-02-19T17:07:25Z] progress: added launcher passthrough subcommand `app|bin` that forwards raw args directly to SubMiner binary (`launcher/config.ts`, `launcher/main.ts`, `launcher/types.ts`).
- [2026-02-19T17:07:25Z] test: `bun test launcher/config.test.ts launcher/parse-args.test.ts` pass; passthrough smoke via stub binary env (`SUBMINER_APPIMAGE_PATH=/tmp/subminer-app-stub.sh bun run launcher/main.ts app --anilist --anilist-status`) forwards raw args; `make build-launcher && ./subminer -h` shows `app|bin`.
- [2026-02-19T17:07:25Z] handoff: user-requested direct app/binary passthrough implemented; supports AniList trigger via `subminer app --anilist`.
- [2026-02-19T17:34:30Z] progress: hardened `app|bin` passthrough to bypass Commander parsing and forward raw argv suffix verbatim after subcommand token (`launcher/config.ts`).
- [2026-02-19T17:34:30Z] test: `bun test launcher/config.test.ts launcher/parse-args.test.ts` pass; stub verification confirms exact forwarding for `app --start --anilist-setup -h`; rebuilt wrapper via `make build-launcher`.

View File

@@ -0,0 +1,49 @@
# Agent: codex-task85-20260219T233711Z-46hc
- alias: codex-task85
- mission: Resume TASK-85 maintainability refactor from latest handoff point
- status: in_progress
- branch: main
- started_at: 2026-02-19T23:37:11Z
- heartbeat_minutes: 5
## Current Work (newest first)
- [2026-02-20T00:00:55Z] progress: extracted AniList setup/protocol handlers (`notifyAnilistSetup`, `consumeAnilistSetupTokenFromUrl`, `handleAnilistSetupProtocolUrl`, `registerSubminerProtocolClient`) from `src/main.ts` into `src/main/runtime/anilist-setup-protocol.ts`; rewired protocol registration and callback wiring; `src/main.ts` now 2988 LOC.
- [2026-02-20T00:00:55Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/anilist-setup-protocol.test.js dist/main/runtime/jellyfin-remote-connection.test.js dist/main/runtime/jellyfin-remote-playback.test.js dist/main/runtime/jellyfin-remote-commands.test.js dist/main/runtime/config-hot-reload-handlers.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js` pass (35/35).
- [2026-02-19T23:58:32Z] progress: extracted Jellyfin MPV connection/bootstrap handlers (`waitForMpvConnected`, `launchMpvIdleForJellyfinPlayback`, `ensureMpvConnectedForJellyfinPlayback`) from `src/main.ts` into `src/main/runtime/jellyfin-remote-connection.ts`; rewired call sites; `src/main.ts` now 2996 LOC.
- [2026-02-19T23:58:32Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-remote-connection.test.js dist/main/runtime/jellyfin-remote-playback.test.js dist/main/runtime/jellyfin-remote-commands.test.js dist/main/runtime/config-hot-reload-handlers.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js` pass (31/31).
- [2026-02-19T23:50:09Z] progress: extracted Jellyfin remote playback reporting logic (`secondsToJellyfinTicks`, `reportJellyfinRemoteProgress`, `reportJellyfinRemoteStopped`) from `src/main.ts` into `src/main/runtime/jellyfin-remote-playback.ts`; rewired main runtime handlers.
- [2026-02-19T23:50:09Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-remote-playback.test.js dist/main/runtime/jellyfin-remote-commands.test.js dist/main/runtime/config-hot-reload-handlers.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js` pass (28/28).
- [2026-02-19T23:48:01Z] progress: extracted Jellyfin remote command/session parsing handlers from `src/main.ts` into `src/main/runtime/jellyfin-remote-commands.ts` (`getConfiguredJellyfinSession`, `handleJellyfinRemotePlay`, `handleJellyfinRemotePlaystate`, `handleJellyfinRemoteGeneralCommand` factories); rewired `src/main.ts` to use handler factories.
- [2026-02-19T23:48:01Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-remote-commands.test.js dist/main/runtime/config-hot-reload-handlers.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js` pass (24/24).
- [2026-02-19T23:40:59Z] progress: extracted config hot-reload apply/message callbacks from `src/main.ts` into `src/main/runtime/config-hot-reload-handlers.ts`; `src/main.ts` now 3096 LOC (down from 3116 at session start).
- [2026-02-19T23:40:59Z] test: `bun run build` pass (expected macOS helper Swift cache fallback in sandbox) + `node --test dist/main/runtime/config-hot-reload-handlers.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js` pass (19/19).
- [2026-02-19T23:37:11Z] intent: review TASK-85 state + subagent handoff, then continue next refactor slice from prior checkpoint.
- [2026-02-19T23:37:11Z] planned files: `src/main.ts`, `src/main/runtime/*`, `src/main/runtime/*.test.ts`, `docs/subagents/INDEX.md`, `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md`.
- [2026-02-19T23:37:11Z] assumptions: backlog MCP not initialized in this workspace; continue using existing `backlog/tasks/task-85 - Refactor-large-files-for-maintainability-and-readability.md` as source of truth.
## Files Touched
- `docs/subagents/INDEX.md`
- `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md`
- `src/main.ts`
- `src/main/runtime/config-hot-reload-handlers.ts`
- `src/main/runtime/config-hot-reload-handlers.test.ts`
- `src/main/runtime/jellyfin-remote-commands.ts`
- `src/main/runtime/jellyfin-remote-commands.test.ts`
- `src/main/runtime/jellyfin-remote-playback.ts`
- `src/main/runtime/jellyfin-remote-playback.test.ts`
- `src/main/runtime/jellyfin-remote-connection.ts`
- `src/main/runtime/jellyfin-remote-connection.test.ts`
- `src/main/runtime/anilist-setup-protocol.ts`
- `src/main/runtime/anilist-setup-protocol.test.ts`
## Open Questions / Blockers
- none
## Next Step
- identify next extractable `src/main.ts` domain slice, add seam test, extract to `src/main/runtime/*`, run build + targeted tests.
- extract next `src/main.ts` slice likely Jellyfin setup UI branch (`openJellyfinSetupWindow` form handling + config patch helpers) into `src/main/runtime/jellyfin-setup-window.ts` with seam tests.

View File

@@ -4,3 +4,4 @@ Shared notes. Append-only.
- [YYYY-MM-DDTHH:MM:SSZ] [agent_id|alias] note, question, dependency, conflict, decision. - [YYYY-MM-DDTHH:MM:SSZ] [agent_id|alias] note, question, dependency, conflict, decision.
- [2026-02-19T08:21:11Z] [codex-main|planner-exec] conflict note: `docs/subagents/INDEX.md` and `docs/subagents/agents/codex-main.md` were externally updated to TASK-69 while TASK-38 work was in-flight; reconciled own row/file back to TASK-38 handoff state. - [2026-02-19T08:21:11Z] [codex-main|planner-exec] conflict note: `docs/subagents/INDEX.md` and `docs/subagents/agents/codex-main.md` were externally updated to TASK-69 while TASK-38 work was in-flight; reconciled own row/file back to TASK-38 handoff state.
- [2026-02-20T00:01:40Z] [codex-anilist-deeplink|anilist-deeplink] preparing commit; scoping staged set to repo changes, excluding external reference dirs (vendor/yomitan-jlpt-vocab, mpv-anilist-updater).

View File

@@ -68,7 +68,7 @@ Shown when SubMiner tries to update a card that no longer exists, or when AnkiCo
- Renderer errors now trigger an automatic recovery path. You should see a short toast ("Renderer error recovered. Overlay is still running."). - Renderer errors now trigger an automatic recovery path. You should see a short toast ("Renderer error recovered. Overlay is still running.").
- Recovery closes any open modal and restores click-through/shortcuts automatically without interrupting mpv playback. - Recovery closes any open modal and restores click-through/shortcuts automatically without interrupting mpv playback.
- If errors keep recurring, open DevTools (`Alt+Shift+I`) and inspect the `renderer overlay recovery` error payload for stack trace + modal/subtitle context. - If errors keep recurring, toggle the overlay's DevTools using the `y-d` mpv chord (or `F12` when running with `--dev`) and inspect the `renderer overlay recovery` error payload for stack trace + modal/subtitle context.
**Overlay is on the wrong monitor or position** **Overlay is on the wrong monitor or position**

View File

@@ -58,6 +58,7 @@ subminer mpv socket # Print active mpv socket path
subminer mpv status # Exit 0 if socket is ready, else exit 1 subminer mpv status # Exit 0 if socket is ready, else exit 1
subminer mpv idle # Launch detached idle mpv with SubMiner defaults subminer mpv idle # Launch detached idle mpv with SubMiner defaults
subminer texthooker # Launch texthooker-only mode subminer texthooker # Launch texthooker-only mode
subminer app --anilist # Pass args directly to SubMiner binary (example: AniList login flow)
subminer yt -o ~/subs https://youtu.be/... # YouTube subcommand: output directory shortcut subminer yt -o ~/subs https://youtu.be/... # YouTube subcommand: output directory shortcut
subminer yt --mode preprocess --whisper-bin /path/to/whisper-cli --whisper-model /path/to/model.bin https://youtu.be/... # Pre-generate subtitle tracks before playback subminer yt --mode preprocess --whisper-bin /path/to/whisper-cli --whisper-model /path/to/model.bin https://youtu.be/... # Pre-generate subtitle tracks before playback
@@ -103,6 +104,7 @@ SubMiner.AppImage --help # Show all options
- `subminer config`: config helpers (`path`, `show`). - `subminer config`: config helpers (`path`, `show`).
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`). - `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). - `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`).
- `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage.
- Subcommand help pages are available (for example `subminer jellyfin -h`, `subminer yt -h`). - Subcommand help pages are available (for example `subminer jellyfin -h`, `subminer yt -h`).
Use subcommands for Jellyfin/YouTube command families (`subminer jellyfin ...`, `subminer yt ...`). Use subcommands for Jellyfin/YouTube command families (`subminer jellyfin ...`, `subminer yt ...`).

21
launcher/config.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
test('launcher root help lists subcommands', () => {
const output = execFileSync(
'bun',
['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'],
{ encoding: 'utf8' },
);
assert.match(output, /Commands:/);
assert.match(output, /jellyfin\|jf/);
assert.match(output, /yt\|youtube/);
assert.match(output, /doctor/);
assert.match(output, /config/);
assert.match(output, /mpv/);
assert.match(output, /texthooker/);
assert.match(output, /app\|bin/);
});

View File

@@ -260,7 +260,28 @@ function applyRootOptions(program: Command): void {
.option('-T, --no-texthooker', 'Disable texthooker-ui server'); .option('-T, --no-texthooker', 'Disable texthooker-ui server');
} }
function buildSubcommandHelpText(program: Command): string {
const subcommands = program.commands
.filter((command) => command.name() !== 'help')
.map((command) => {
const aliases = command.aliases();
const term = aliases.length > 0 ? `${command.name()}|${aliases[0]}` : command.name();
return { term, description: command.description() };
});
if (subcommands.length === 0) return '';
const longestTerm = Math.max(...subcommands.map((entry) => entry.term.length));
const lines = subcommands.map(
(entry) => ` ${entry.term.padEnd(longestTerm)} ${entry.description || ''}`.trimEnd(),
);
return `\nCommands:\n${lines.join('\n')}\n`;
}
function hasTopLevelCommand(argv: string[]): boolean { function hasTopLevelCommand(argv: string[]): boolean {
return getTopLevelCommand(argv) !== null;
}
function getTopLevelCommand(argv: string[]): { name: string; index: number } | null {
const commandNames = new Set([ const commandNames = new Set([
'jellyfin', 'jellyfin',
'jf', 'jf',
@@ -270,6 +291,8 @@ function hasTopLevelCommand(argv: string[]): boolean {
'config', 'config',
'mpv', 'mpv',
'texthooker', 'texthooker',
'app',
'bin',
'help', 'help',
]); ]);
const optionsWithValue = new Set([ const optionsWithValue = new Set([
@@ -283,16 +306,16 @@ function hasTopLevelCommand(argv: string[]): boolean {
]); ]);
for (let i = 0; i < argv.length; i += 1) { for (let i = 0; i < argv.length; i += 1) {
const token = argv[i] || ''; const token = argv[i] || '';
if (token === '--') return false; if (token === '--') return null;
if (token.startsWith('-')) { if (token.startsWith('-')) {
if (optionsWithValue.has(token)) { if (optionsWithValue.has(token)) {
i += 1; i += 1;
} }
continue; continue;
} }
return commandNames.has(token); return commandNames.has(token) ? { name: token, index: i } : null;
} }
return false; return null;
} }
export function parseArgs( export function parseArgs(
@@ -300,6 +323,7 @@ export function parseArgs(
scriptName: string, scriptName: string,
launcherConfig: LauncherYoutubeSubgenConfig, launcherConfig: LauncherYoutubeSubgenConfig,
): Args { ): Args {
const topLevelCommand = getTopLevelCommand(argv);
const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || '').toLowerCase(); const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || '').toLowerCase();
const defaultMode: YoutubeSubgenMode = const defaultMode: YoutubeSubgenMode =
envMode === 'preprocess' || envMode === 'off' || envMode === 'automatic' envMode === 'preprocess' || envMode === 'off' || envMode === 'automatic'
@@ -350,6 +374,8 @@ export function parseArgs(
mpvIdle: false, mpvIdle: false,
mpvSocket: false, mpvSocket: false,
mpvStatus: false, mpvStatus: false,
appPassthrough: false,
appArgs: [],
jellyfinServer: '', jellyfinServer: '',
jellyfinUsername: '', jellyfinUsername: '',
jellyfinPassword: '', jellyfinPassword: '',
@@ -374,6 +400,11 @@ export function parseArgs(
parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference; parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference;
if (launcherConfig.jimakuMaxEntryResults !== undefined) if (launcherConfig.jimakuMaxEntryResults !== undefined)
parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults; parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults;
if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) {
parsed.appPassthrough = true;
parsed.appArgs = argv.slice(topLevelCommand.index + 1);
return parsed;
}
let jellyfinInvocation: { let jellyfinInvocation: {
action?: string; action?: string;
@@ -399,6 +430,7 @@ export function parseArgs(
} | null = null; } | null = null;
let configInvocation: { action: string; logLevel?: string } | null = null; let configInvocation: { action: string; logLevel?: string } | null = null;
let mpvInvocation: { action: string; logLevel?: string } | null = null; let mpvInvocation: { action: string; logLevel?: string } | null = null;
let appInvocation: { appArgs: string[] } | null = null;
let doctorLogLevel: string | null = null; let doctorLogLevel: string | null = null;
let texthookerLogLevel: string | null = null; let texthookerLogLevel: string | null = null;
@@ -522,6 +554,21 @@ export function parseArgs(
texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
}); });
commandProgram
.command('app')
.alias('bin')
.description('Pass arguments directly to SubMiner binary')
.allowUnknownOption(true)
.allowExcessArguments(true)
.argument('[appArgs...]', 'Arguments forwarded to SubMiner app binary')
.action((appArgs: string[] | undefined) => {
appInvocation = {
appArgs: Array.isArray(appArgs) ? appArgs : [],
};
});
rootProgram.addHelpText('after', buildSubcommandHelpText(commandProgram));
const selectedProgram = hasTopLevelCommand(argv) ? commandProgram : rootProgram; const selectedProgram = hasTopLevelCommand(argv) ? commandProgram : rootProgram;
try { try {
selectedProgram.parse(['node', scriptName, ...argv]); selectedProgram.parse(['node', scriptName, ...argv]);
@@ -644,5 +691,10 @@ export function parseArgs(
else fail(`Unknown mpv action: ${mpvInvocation.action}`); else fail(`Unknown mpv action: ${mpvInvocation.action}`);
} }
if (appInvocation !== null) {
parsed.appPassthrough = true;
parsed.appArgs = appInvocation.appArgs;
}
return parsed; return parsed;
} }

View File

@@ -218,6 +218,10 @@ async function main(): Promise<void> {
} }
state.appPath = appPath; state.appPath = appPath;
if (args.appPassthrough) {
runAppCommandWithInherit(appPath, args.appArgs);
}
if (args.mpvIdle) { if (args.mpvIdle) {
await launchMpvIdleDetached(mpvSocketPath, appPath, args); await launchMpvIdleDetached(mpvSocketPath, appPath, args);
const ready = await waitForUnixSocketReady(mpvSocketPath, 8000); const ready = await waitForUnixSocketReady(mpvSocketPath, 8000);

View File

@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseArgs } from './config';
test('parseArgs captures passthrough args for app subcommand', () => {
const parsed = parseArgs(['app', '--anilist', '--log-level', 'debug'], 'subminer', {});
assert.equal(parsed.appPassthrough, true);
assert.deepEqual(parsed.appArgs, ['--anilist', '--log-level', 'debug']);
});
test('parseArgs supports bin alias for app subcommand', () => {
const parsed = parseArgs(['bin', '--anilist-status'], 'subminer', {});
assert.equal(parsed.appPassthrough, true);
assert.deepEqual(parsed.appArgs, ['--anilist-status']);
});
test('parseArgs keeps all args after app verbatim', () => {
const parsed = parseArgs(['app', '--start', '--anilist-setup', '-h'], 'subminer', {});
assert.equal(parsed.appPassthrough, true);
assert.deepEqual(parsed.appArgs, ['--start', '--anilist-setup', '-h']);
});

View File

@@ -91,6 +91,8 @@ export interface Args {
mpvIdle: boolean; mpvIdle: boolean;
mpvSocket: boolean; mpvSocket: boolean;
mpvStatus: boolean; mpvStatus: boolean;
appPassthrough: boolean;
appArgs: string[];
jellyfinServer: string; jellyfinServer: string;
jellyfinUsername: string; jellyfinUsername: string;
jellyfinPassword: string; jellyfinPassword: string;

View File

@@ -16,6 +16,8 @@
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"check:file-budgets": "bun run scripts/check-file-budgets.ts",
"check:file-budgets:strict": "bun run scripts/check-file-budgets.ts --strict",
"test:config:dist": "node --test dist/config/config.test.js dist/config/path-resolution.test.js", "test:config:dist": "node --test dist/config/config.test.js dist/config/path-resolution.test.js",
"test:core:dist": "node --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/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/tokenizer.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": "node --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/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/tokenizer.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:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",

View File

@@ -0,0 +1,100 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
type FileBudgetResult = {
file: string;
lines: number;
};
const DEFAULT_LINE_LIMIT = 500;
const TARGET_DIRS = ['src', 'launcher'];
const TARGET_EXTENSIONS = new Set(['.ts']);
const IGNORE_NAMES = new Set(['.DS_Store']);
function parseArgs(argv: string[]): { strict: boolean; limit: number } {
let strict = false;
let limit = DEFAULT_LINE_LIMIT;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--strict') {
strict = true;
continue;
}
if (arg === '--limit') {
const raw = argv[i + 1];
const parsed = Number.parseInt(raw ?? '', 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Invalid --limit value: ${raw ?? '<missing>'}`);
}
limit = parsed;
i += 1;
continue;
}
}
return { strict, limit };
}
function resolveFilesWithRipgrep(): string[] {
const rg = spawnSync('rg', ['--files', ...TARGET_DIRS], {
cwd: process.cwd(),
encoding: 'utf8',
});
if (rg.status !== 0) {
throw new Error(`rg --files failed:\n${rg.stderr || rg.stdout}`);
}
return rg.stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.filter((file) => TARGET_EXTENSIONS.has(path.extname(file)))
.filter((file) => !IGNORE_NAMES.has(path.basename(file)));
}
function countLines(content: string): number {
if (content.length === 0) return 0;
return content.split('\n').length;
}
function collectOverBudgetFiles(files: string[], limit: number): FileBudgetResult[] {
const results: FileBudgetResult[] = [];
for (const file of files) {
const content = fs.readFileSync(file, 'utf8');
const lines = countLines(content);
if (lines > limit) {
results.push({ file, lines });
}
}
return results.sort((a, b) => b.lines - a.lines || a.file.localeCompare(b.file));
}
function printReport(overBudget: FileBudgetResult[], limit: number, strict: boolean): void {
const mode = strict ? 'strict' : 'warning';
if (overBudget.length === 0) {
console.log(`[OK] file budget check (${mode}) — no files over ${limit} LOC`);
return;
}
const heading = strict ? '[FAIL]' : '[WARN]';
console.log(`${heading} file budget check (${mode}) — ${overBudget.length} files over ${limit} LOC`);
for (const item of overBudget) {
console.log(` - ${item.file}: ${item.lines} LOC`);
}
console.log(' Hint: split by runtime/domain boundaries; keep composition roots thin.');
}
function main(): void {
const { strict, limit } = parseArgs(process.argv.slice(2));
const files = resolveFilesWithRipgrep();
const overBudget = collectOverBudgetFiles(files, limit);
printReport(overBudget, limit, strict);
if (strict && overBudget.length > 0) {
process.exitCode = 1;
}
}
main();

View File

@@ -889,4 +889,16 @@ test('template generator includes known keys', () => {
assert.match(output, /"knownWord": "#a6da95"/); assert.match(output, /"knownWord": "#a6da95"/);
assert.match(output, /"minSentenceWords": 3/); assert.match(output, /"minSentenceWords": 3/);
assert.match(output, /auto-generated from src\/config\/definitions.ts/); assert.match(output, /auto-generated from src\/config\/definitions.ts/);
assert.match(
output,
/"level": "info",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/,
);
assert.match(
output,
/"enabled": "auto",? \/\/ Built-in subtitle websocket server mode\. Values: auto \| true \| false/,
);
assert.match(
output,
/"enabled": false,? \/\/ Enable AnkiConnect integration\. Values: true \| false/,
);
}); });

View File

@@ -503,7 +503,8 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
path: 'anilist.accessToken', path: 'anilist.accessToken',
kind: 'string', kind: 'string',
defaultValue: DEFAULT_CONFIG.anilist.accessToken, defaultValue: DEFAULT_CONFIG.anilist.accessToken,
description: 'AniList access token used for post-watch updates.', description:
'Optional explicit AniList access token override; leave empty to use locally stored token from setup.',
}, },
{ {
path: 'jellyfin.enabled', path: 'jellyfin.enabled',

View File

@@ -1,7 +1,46 @@
import { ResolvedConfig } from '../types'; import { ResolvedConfig } from '../types';
import { CONFIG_TEMPLATE_SECTIONS, DEFAULT_CONFIG, deepCloneConfig } from './definitions'; import {
CONFIG_OPTION_REGISTRY,
CONFIG_TEMPLATE_SECTIONS,
DEFAULT_CONFIG,
deepCloneConfig,
} from './definitions';
function renderValue(value: unknown, indent = 0): string { const OPTION_REGISTRY_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map(
CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']),
);
function normalizeCommentText(value: string): string {
return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim();
}
function humanizeKey(key: string): string {
const spaced = key
.replace(/_/g, ' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.toLowerCase();
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
function buildInlineOptionComment(path: string, value: unknown): string {
const registryEntry = OPTION_REGISTRY_BY_PATH.get(path);
const baseDescription = registryEntry?.description ?? TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY.get(path);
const description =
baseDescription && baseDescription.trim().length > 0
? normalizeCommentText(baseDescription)
: `${humanizeKey(path.split('.').at(-1) ?? path)} setting.`;
if (registryEntry?.enumValues?.length) {
return `${description} Values: ${registryEntry.enumValues.join(' | ')}`;
}
if (typeof value === 'boolean') {
return `${description} Values: true | false`;
}
return description;
}
function renderValue(value: unknown, indent = 0, path = ''): string {
const pad = ' '.repeat(indent); const pad = ' '.repeat(indent);
const nextPad = ' '.repeat(indent + 2); const nextPad = ' '.repeat(indent + 2);
@@ -11,7 +50,7 @@ function renderValue(value: unknown, indent = 0): string {
if (Array.isArray(value)) { if (Array.isArray(value)) {
if (value.length === 0) return '[]'; if (value.length === 0) return '[]';
const items = value.map((item) => `${nextPad}${renderValue(item, indent + 2)}`); const items = value.map((item) => `${nextPad}${renderValue(item, indent + 2, `${path}[]`)}`);
return `\n${items.join(',\n')}\n${pad}`.replace(/^/, '[').concat(']'); return `\n${items.join(',\n')}\n${pad}`.replace(/^/, '[').concat(']');
} }
@@ -20,10 +59,18 @@ function renderValue(value: unknown, indent = 0): string {
([, child]) => child !== undefined, ([, child]) => child !== undefined,
); );
if (entries.length === 0) return '{}'; if (entries.length === 0) return '{}';
const lines = entries.map( const lines = entries.map(([key, child], index) => {
([key, child]) => `${nextPad}${JSON.stringify(key)}: ${renderValue(child, indent + 2)}`, const isLast = index === entries.length - 1;
); const trailingComma = isLast ? '' : ',';
return `\n${lines.join(',\n')}\n${pad}`.replace(/^/, '{').concat('}'); const childPath = path ? `${path}.${key}` : key;
const renderedChild = renderValue(child, indent + 2, childPath);
const comment = buildInlineOptionComment(childPath, child);
if (renderedChild.startsWith('\n')) {
return `${nextPad}${JSON.stringify(key)}: /* ${comment} */ ${renderedChild}${trailingComma}`;
}
return `${nextPad}${JSON.stringify(key)}: ${renderedChild}${trailingComma} // ${comment}`;
});
return `\n${lines.join('\n')}\n${pad}`.replace(/^/, '{').concat('}');
} }
return 'null'; return 'null';
@@ -41,7 +88,17 @@ function renderSection(
lines.push(` // ${comment}`); lines.push(` // ${comment}`);
} }
lines.push(' // =========================================='); lines.push(' // ==========================================');
lines.push(` ${JSON.stringify(key)}: ${renderValue(value, 2)}${isLast ? '' : ','}`); const inlineComment = buildInlineOptionComment(String(key), value);
const renderedValue = renderValue(value, 2, String(key));
if (renderedValue.startsWith('\n')) {
lines.push(
` ${JSON.stringify(key)}: /* ${inlineComment} */ ${renderedValue}${isLast ? '' : ','}`,
);
} else {
lines.push(
` ${JSON.stringify(key)}: ${renderedValue}${isLast ? '' : ','} // ${inlineComment}`,
);
}
return lines.join('\n'); return lines.join('\n');
} }

View File

@@ -197,8 +197,10 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
return { deps, calls, osd }; return { deps, calls, osd };
} }
test('handleCliCommand ignores --start for second-instance without actions', () => { test('handleCliCommand ignores --start for second-instance when overlay runtime is already initialized', () => {
const { deps, calls } = createDeps(); const { deps, calls } = createDeps({
isOverlayRuntimeInitialized: () => true,
});
const args = makeArgs({ start: true }); const args = makeArgs({ start: true });
handleCliCommand(args, 'second-instance', deps); handleCliCommand(args, 'second-instance', deps);
@@ -210,6 +212,23 @@ test('handleCliCommand ignores --start for second-instance without actions', ()
); );
}); });
test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => {
const { deps, calls } = createDeps();
const args = makeArgs({ start: true });
handleCliCommand(args, 'second-instance', deps);
assert.equal(
calls.some((value) => value === 'log:Ignoring --start because SubMiner is already running.'),
false,
);
assert.ok(calls.includes('setMpvClientSocketPath:/tmp/subminer.sock'));
assert.equal(
calls.some((value) => value.includes('connectMpvClient')),
true,
);
});
test('handleCliCommand runs texthooker flow with browser open', () => { test('handleCliCommand runs texthooker flow with browser open', () => {
const { deps, calls } = createDeps(); const { deps, calls } = createDeps();
const args = makeArgs({ texthooker: true }); const args = makeArgs({ texthooker: true });
@@ -239,6 +258,7 @@ test('handleCliCommand applies socket path and connects on start', () => {
handleCliCommand(makeArgs({ start: true, socketPath: '/tmp/custom.sock' }), 'initial', deps); handleCliCommand(makeArgs({ start: true, socketPath: '/tmp/custom.sock' }), 'initial', deps);
assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(calls.includes('setMpvSocketPath:/tmp/custom.sock')); assert.ok(calls.includes('setMpvSocketPath:/tmp/custom.sock'));
assert.ok(calls.includes('setMpvClientSocketPath:/tmp/custom.sock')); assert.ok(calls.includes('setMpvClientSocketPath:/tmp/custom.sock'));
assert.ok(calls.includes('connectMpvClient')); assert.ok(calls.includes('connectMpvClient'));
@@ -304,6 +324,16 @@ test('handleCliCommand still runs non-start actions on second-instance', () => {
); );
}); });
test('handleCliCommand connects MPV for toggle on second-instance', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ toggle: true }), 'second-instance', deps);
assert.ok(calls.includes('toggleVisibleOverlay'));
assert.equal(
calls.some((value) => value === 'connectMpvClient'),
true,
);
});
test('handleCliCommand handles visibility and utility command dispatches', () => { test('handleCliCommand handles visibility and utility command dispatches', () => {
const cases: Array<{ const cases: Array<{
args: Partial<CliArgs>; args: Partial<CliArgs>;

View File

@@ -275,7 +275,11 @@ export function handleCliCommand(
args.jellyfinRemoteAnnounce || args.jellyfinRemoteAnnounce ||
args.texthooker || args.texthooker ||
args.help; args.help;
const ignoreStartOnly = source === 'second-instance' && args.start && !hasNonStartAction; const ignoreStartOnly =
source === 'second-instance' &&
args.start &&
!hasNonStartAction &&
deps.isOverlayRuntimeInitialized();
if (ignoreStartOnly) { if (ignoreStartOnly) {
deps.log('Ignoring --start because SubMiner is already running.'); deps.log('Ignoring --start because SubMiner is already running.');
return; return;
@@ -283,9 +287,11 @@ export function handleCliCommand(
const shouldStart = const shouldStart =
args.start || args.start ||
(source === 'initial' && args.toggle ||
(args.toggle || args.toggleVisibleOverlay || args.toggleInvisibleOverlay)); args.toggleVisibleOverlay ||
args.toggleInvisibleOverlay;
const needsOverlayRuntime = commandNeedsOverlayRuntime(args); const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
if (args.socketPath !== undefined) { if (args.socketPath !== undefined) {
deps.setMpvSocketPath(args.socketPath); deps.setMpvSocketPath(args.socketPath);
@@ -306,7 +312,7 @@ export function handleCliCommand(
return; return;
} }
if (needsOverlayRuntime && !deps.isOverlayRuntimeInitialized()) { if (shouldInitializeOverlayRuntime && !deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime(); deps.initializeOverlayRuntime();
} }

View File

@@ -52,6 +52,7 @@ test('config hot reload runtime debounces rapid watch events', () => {
onHotReloadApplied: () => {}, onHotReloadApplied: () => {},
onRestartRequired: () => {}, onRestartRequired: () => {},
onInvalidConfig: () => {}, onInvalidConfig: () => {},
onValidationWarnings: () => {},
}; };
const runtime = createConfigHotReloadRuntime(deps); const runtime = createConfigHotReloadRuntime(deps);
@@ -103,9 +104,59 @@ test('config hot reload runtime reports invalid config and skips apply', () => {
onInvalidConfig: (message) => { onInvalidConfig: (message) => {
invalidMessages.push(message); invalidMessages.push(message);
}, },
onValidationWarnings: () => {
throw new Error('Validation warnings should not trigger for invalid config.');
},
}); });
runtime.start(); runtime.start();
assert.equal(watchedChangeCallback, null); assert.equal(watchedChangeCallback, null);
assert.equal(invalidMessages.length, 1); assert.equal(invalidMessages.length, 1);
}); });
test('config hot reload runtime reports validation warnings from reload', () => {
let watchedChangeCallback: (() => void) | null = null;
const warningCalls: Array<{ path: string; count: number }> = [];
const runtime = createConfigHotReloadRuntime({
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
reloadConfigStrict: () => ({
ok: true,
config: deepCloneConfig(DEFAULT_CONFIG),
warnings: [
{
path: 'ankiConnect.openRouter',
message: 'Deprecated key; use ankiConnect.ai instead.',
value: { enabled: true },
fallback: {},
},
],
path: '/tmp/config.jsonc',
}),
watchConfigPath: (_path, onChange) => {
watchedChangeCallback = onChange;
return { close: () => {} };
},
setTimeout: (callback) => {
callback();
return 1 as unknown as NodeJS.Timeout;
},
clearTimeout: () => {},
debounceMs: 0,
onHotReloadApplied: () => {},
onRestartRequired: () => {},
onInvalidConfig: () => {},
onValidationWarnings: (path, warnings) => {
warningCalls.push({ path, count: warnings.length });
},
});
runtime.start();
assert.equal(warningCalls.length, 0);
if (!watchedChangeCallback) {
throw new Error('Expected watch callback to be registered.');
}
const trigger = watchedChangeCallback as () => void;
trigger();
assert.deepEqual(warningCalls, [{ path: '/tmp/config.jsonc', count: 1 }]);
});

View File

@@ -1,4 +1,5 @@
import { type ReloadConfigStrictResult } from '../../config'; import { type ReloadConfigStrictResult } from '../../config';
import type { ConfigValidationWarning } from '../../types';
import type { ResolvedConfig } from '../../types'; import type { ResolvedConfig } from '../../types';
export interface ConfigHotReloadDiff { export interface ConfigHotReloadDiff {
@@ -16,6 +17,7 @@ export interface ConfigHotReloadRuntimeDeps {
onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void; onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void;
onRestartRequired: (fields: string[]) => void; onRestartRequired: (fields: string[]) => void;
onInvalidConfig: (message: string) => void; onInvalidConfig: (message: string) => void;
onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) => void;
} }
export interface ConfigHotReloadRuntime { export interface ConfigHotReloadRuntime {
@@ -107,6 +109,10 @@ export function createConfigHotReloadRuntime(
watchPath(result.path); watchPath(result.path);
} }
if (result.warnings.length > 0) {
deps.onValidationWarnings(result.path, result.warnings);
}
const diff = classifyDiff(prev, result.config); const diff = classifyDiff(prev, result.config);
if (diff.hotReloadFields.length > 0) { if (diff.hotReloadFields.length > 0) {
deps.onHotReloadApplied(diff, result.config); deps.onHotReloadApplied(diff, result.config);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildConfigWarningNotificationBody,
buildConfigWarningSummary,
failStartupFromConfig,
formatConfigValue,
} from './config-validation';
test('formatConfigValue handles undefined and JSON values', () => {
assert.equal(formatConfigValue(undefined), 'undefined');
assert.equal(formatConfigValue({ x: 1 }), '{"x":1}');
assert.equal(formatConfigValue(['a', 2]), '["a",2]');
});
test('buildConfigWarningSummary includes warnings with formatted values', () => {
const summary = buildConfigWarningSummary('/tmp/config.jsonc', [
{
path: 'ankiConnect.pollingRate',
message: 'must be >= 50',
value: 20,
fallback: 250,
},
]);
assert.match(summary, /Validation found 1 issue\(s\)\. File: \/tmp\/config\.jsonc/);
assert.match(summary, /ankiConnect\.pollingRate: must be >= 50 actual=20 fallback=250/);
});
test('buildConfigWarningNotificationBody includes concise warning details', () => {
const body = buildConfigWarningNotificationBody('/tmp/config.jsonc', [
{
path: 'ankiConnect.openRouter',
message: 'Deprecated key; use ankiConnect.ai instead.',
value: { enabled: true },
fallback: {},
},
{
path: 'ankiConnect.isLapis.sentenceCardSentenceField',
message: 'Deprecated key; sentence-card sentence field is fixed to Sentence.',
value: 'Sentence',
fallback: 'Sentence',
},
]);
assert.match(body, /2 config validation issue\(s\) detected\./);
assert.match(body, /File: \/tmp\/config\.jsonc/);
assert.match(body, /1\. ankiConnect\.openRouter: Deprecated key; use ankiConnect\.ai instead\./);
assert.match(
body,
/2\. ankiConnect\.isLapis\.sentenceCardSentenceField: Deprecated key; sentence-card sentence field is fixed to Sentence\./,
);
});
test('failStartupFromConfig invokes handlers and throws', () => {
const calls: string[] = [];
const previousExitCode = process.exitCode;
process.exitCode = 0;
assert.throws(
() =>
failStartupFromConfig('Config Error', 'bad value', {
logError: (details) => {
calls.push(`log:${details}`);
},
showErrorBox: (title, details) => {
calls.push(`dialog:${title}:${details}`);
},
quit: () => {
calls.push('quit');
},
}),
/bad value/,
);
assert.equal(process.exitCode, 1);
assert.deepEqual(calls, ['log:bad value', 'dialog:Config Error:bad value', 'quit']);
process.exitCode = previousExitCode;
});

View File

@@ -0,0 +1,74 @@
import type { ConfigValidationWarning } from '../types';
export type StartupFailureHandlers = {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
quit: () => void;
};
export function formatConfigValue(value: unknown): string {
if (value === undefined) {
return 'undefined';
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
export function buildConfigWarningSummary(
configPath: string,
warnings: ConfigValidationWarning[],
): string {
const lines = [
`[config] Validation found ${warnings.length} issue(s). File: ${configPath}`,
...warnings.map(
(warning, index) =>
`[config] ${index + 1}. ${warning.path}: ${warning.message} actual=${formatConfigValue(warning.value)} fallback=${formatConfigValue(warning.fallback)}`,
),
];
return lines.join('\n');
}
export function buildConfigWarningNotificationBody(
configPath: string,
warnings: ConfigValidationWarning[],
): string {
const maxLines = 3;
const maxPathLength = 48;
const trimPath = (value: string): string =>
value.length > maxPathLength ? `...${value.slice(-(maxPathLength - 3))}` : value;
const clippedPath = trimPath(configPath);
const lines = warnings.slice(0, maxLines).map((warning, index) => {
const message = `${warning.path}: ${warning.message}`;
return `${index + 1}. ${message}`;
});
const overflow = warnings.length - lines.length;
if (overflow > 0) {
lines.push(`+${overflow} more issue(s)`);
}
return [
`${warnings.length} config validation issue(s) detected.`,
'Defaults were applied where possible.',
`File: ${clippedPath}`,
...lines,
].join('\n');
}
export function failStartupFromConfig(
title: string,
details: string,
handlers: StartupFailureHandlers,
): never {
handlers.logError(details);
handlers.showErrorBox(title, details);
process.exitCode = 1;
handlers.quit();
throw new Error(details);
}

View File

@@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createConsumeAnilistSetupTokenFromUrlHandler,
createHandleAnilistSetupProtocolUrlHandler,
createNotifyAnilistSetupHandler,
createRegisterSubminerProtocolClientHandler,
} from './anilist-setup-protocol';
test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => {
const calls: string[] = [];
const notify = createNotifyAnilistSetupHandler({
hasMpvClient: () => true,
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: () => calls.push('desktop'),
logInfo: () => calls.push('log'),
});
notify('AniList login success');
assert.deepEqual(calls, ['osd:AniList login success']);
});
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {
const consume = createConsumeAnilistSetupTokenFromUrlHandler({
consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'),
saveToken: () => {},
setCachedToken: () => {},
setResolvedState: () => {},
setSetupPageOpened: () => {},
onSuccess: () => {},
closeWindow: () => {},
});
assert.equal(consume('subminer://anilist-setup?access_token=ok'), true);
assert.equal(consume('subminer://anilist-setup'), false);
});
test('createHandleAnilistSetupProtocolUrlHandler validates scheme and logs missing token', () => {
const warnings: string[] = [];
const handleProtocolUrl = createHandleAnilistSetupProtocolUrlHandler({
consumeAnilistSetupTokenFromUrl: () => false,
logWarn: (message) => warnings.push(message),
});
assert.equal(handleProtocolUrl('https://example.com'), false);
assert.equal(handleProtocolUrl('subminer://anilist-setup'), true);
assert.deepEqual(warnings, ['AniList setup protocol URL missing access token']);
});
test('createRegisterSubminerProtocolClientHandler registers default app entry', () => {
const calls: string[] = [];
const register = createRegisterSubminerProtocolClientHandler({
isDefaultApp: () => true,
getArgv: () => ['electron', './entry.js'],
execPath: '/usr/local/bin/electron',
resolvePath: (value) => `/resolved/${value}`,
setAsDefaultProtocolClient: (_scheme, _path, args) => {
calls.push(`register:${String(args?.[0])}`);
return true;
},
logWarn: (message) => calls.push(`warn:${message}`),
});
register();
assert.deepEqual(calls, ['register:/resolved/./entry.js']);
});

View File

@@ -0,0 +1,91 @@
export type ConsumeAnilistSetupTokenDeps = {
consumeAnilistSetupCallbackUrl: (input: {
rawUrl: string;
saveToken: (token: string) => void;
setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void;
onSuccess: () => void;
closeWindow: () => void;
}) => boolean;
saveToken: (token: string) => void;
setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void;
onSuccess: () => void;
closeWindow: () => void;
};
export function createConsumeAnilistSetupTokenFromUrlHandler(deps: ConsumeAnilistSetupTokenDeps) {
return (rawUrl: string): boolean =>
deps.consumeAnilistSetupCallbackUrl({
rawUrl,
saveToken: deps.saveToken,
setCachedToken: deps.setCachedToken,
setResolvedState: deps.setResolvedState,
setSetupPageOpened: deps.setSetupPageOpened,
onSuccess: deps.onSuccess,
closeWindow: deps.closeWindow,
});
}
export function createNotifyAnilistSetupHandler(deps: {
hasMpvClient: () => boolean;
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
logInfo: (message: string) => void;
}) {
return (message: string): void => {
if (deps.hasMpvClient()) {
deps.showMpvOsd(message);
return;
}
deps.showDesktopNotification('SubMiner AniList', { body: message });
deps.logInfo(`[AniList setup] ${message}`);
};
}
export function createHandleAnilistSetupProtocolUrlHandler(deps: {
consumeAnilistSetupTokenFromUrl: (rawUrl: string) => boolean;
logWarn: (message: string, details: unknown) => void;
}) {
return (rawUrl: string): boolean => {
if (!rawUrl.startsWith('subminer://anilist-setup')) {
return false;
}
if (deps.consumeAnilistSetupTokenFromUrl(rawUrl)) {
return true;
}
deps.logWarn('AniList setup protocol URL missing access token', { rawUrl });
return true;
};
}
export function createRegisterSubminerProtocolClientHandler(deps: {
isDefaultApp: () => boolean;
getArgv: () => string[];
execPath: string;
resolvePath: (value: string) => string;
setAsDefaultProtocolClient: (
scheme: string,
path?: string,
args?: string[],
) => boolean;
logWarn: (message: string, details?: unknown) => void;
}) {
return (): void => {
try {
const defaultAppEntry = deps.isDefaultApp() ? deps.getArgv()[1] : undefined;
const success = defaultAppEntry
? deps.setAsDefaultProtocolClient('subminer', deps.execPath, [
deps.resolvePath(defaultAppEntry),
])
: deps.setAsDefaultProtocolClient('subminer');
if (!success) {
deps.logWarn('Failed to register default protocol handler for subminer:// URLs');
}
} catch (error) {
deps.logWarn('Failed to register subminer:// protocol handler', error);
}
};
}

View File

@@ -0,0 +1,148 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildAnilistSetupFallbackHtml,
buildAnilistManualTokenEntryHtml,
buildAnilistSetupUrl,
consumeAnilistSetupCallbackUrl,
extractAnilistAccessTokenFromUrl,
findAnilistSetupDeepLinkArgvUrl,
} from './anilist-setup';
test('buildAnilistSetupUrl includes required query params', () => {
const url = buildAnilistSetupUrl({
authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize',
clientId: '36084',
responseType: 'token',
redirectUri: 'https://anilist.subminer.moe/',
});
assert.match(url, /client_id=36084/);
assert.match(url, /response_type=token/);
assert.match(url, /redirect_uri=https%3A%2F%2Fanilist\.subminer\.moe%2F/);
});
test('buildAnilistSetupUrl omits redirect_uri when unset', () => {
const url = buildAnilistSetupUrl({
authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize',
clientId: '36084',
responseType: 'token',
});
assert.match(url, /client_id=36084/);
assert.match(url, /response_type=token/);
assert.equal(url.includes('redirect_uri='), false);
});
test('buildAnilistSetupFallbackHtml escapes reason content', () => {
const html = buildAnilistSetupFallbackHtml({
reason: '<script>alert(1)</script>',
authorizeUrl: 'https://anilist.example/auth',
developerSettingsUrl: 'https://anilist.example/dev',
});
assert.equal(html.includes('<script>alert(1)</script>'), false);
assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/);
});
test('buildAnilistManualTokenEntryHtml includes access-token submit route only', () => {
const html = buildAnilistManualTokenEntryHtml({
authorizeUrl: 'https://anilist.example/auth',
developerSettingsUrl: 'https://anilist.example/dev',
});
assert.match(html, /subminer:\/\/anilist-setup\?access_token=/);
assert.equal(html.includes('callback_url='), false);
assert.equal(html.includes('subminer://anilist-setup?code='), false);
});
test('extractAnilistAccessTokenFromUrl returns access token from hash fragment', () => {
const token = extractAnilistAccessTokenFromUrl(
'https://anilist.subminer.moe/#access_token=token-from-hash&token_type=Bearer',
);
assert.equal(token, 'token-from-hash');
});
test('extractAnilistAccessTokenFromUrl returns access token from query', () => {
const token = extractAnilistAccessTokenFromUrl(
'https://anilist.subminer.moe/?access_token=token-from-query&token_type=Bearer',
);
assert.equal(token, 'token-from-query');
});
test('findAnilistSetupDeepLinkArgvUrl finds subminer deep link from argv', () => {
const rawUrl = findAnilistSetupDeepLinkArgvUrl([
'/Applications/SubMiner.app/Contents/MacOS/SubMiner',
'--start',
'subminer://anilist-setup?access_token=argv-token',
]);
assert.equal(rawUrl, 'subminer://anilist-setup?access_token=argv-token');
});
test('findAnilistSetupDeepLinkArgvUrl returns null when missing', () => {
const rawUrl = findAnilistSetupDeepLinkArgvUrl([
'/Applications/SubMiner.app/Contents/MacOS/SubMiner',
'--start',
]);
assert.equal(rawUrl, null);
});
test('consumeAnilistSetupCallbackUrl persists token and closes window for callback URL', () => {
const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token',
saveToken: (value: string) => events.push(`save:${value}`),
setCachedToken: (value: string) => events.push(`cache:${value}`),
setResolvedState: (timestampMs: number) =>
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
onSuccess: () => events.push('success'),
closeWindow: () => events.push('close'),
});
assert.equal(handled, true);
assert.deepEqual(events, [
'save:saved-token',
'cache:saved-token',
'state:ok',
'opened:false',
'success',
'close',
]);
});
test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL', () => {
const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
saveToken: (value: string) => events.push(`save:${value}`),
setCachedToken: (value: string) => events.push(`cache:${value}`),
setResolvedState: (timestampMs: number) =>
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
onSuccess: () => events.push('success'),
closeWindow: () => events.push('close'),
});
assert.equal(handled, true);
assert.deepEqual(events, [
'save:saved-token',
'cache:saved-token',
'state:ok',
'opened:false',
'success',
'close',
]);
});
test('consumeAnilistSetupCallbackUrl ignores non-callback URLs', () => {
const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'https://anilist.co/settings/developer',
saveToken: () => events.push('save'),
setCachedToken: () => events.push('cache'),
setResolvedState: () => events.push('state'),
setSetupPageOpened: () => events.push('opened'),
onSuccess: () => events.push('success'),
closeWindow: () => events.push('close'),
});
assert.equal(handled, false);
assert.deepEqual(events, []);
});

View File

@@ -0,0 +1,177 @@
import type { BrowserWindow } from 'electron';
import type { ResolvedConfig } from '../../types';
export type BuildAnilistSetupUrlDeps = {
authorizeUrl: string;
clientId: string;
responseType: string;
redirectUri?: string;
};
export type ConsumeAnilistSetupCallbackUrlDeps = {
rawUrl: string;
saveToken: (token: string) => void;
setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void;
onSuccess: () => void;
closeWindow: () => void;
};
export function isAnilistTrackingEnabled(resolved: ResolvedConfig): boolean {
return resolved.anilist.enabled;
}
export function buildAnilistSetupUrl(params: BuildAnilistSetupUrlDeps): string {
const authorizeUrl = new URL(params.authorizeUrl);
authorizeUrl.searchParams.set('client_id', params.clientId);
authorizeUrl.searchParams.set('response_type', params.responseType);
if (params.redirectUri && params.redirectUri.trim().length > 0) {
authorizeUrl.searchParams.set('redirect_uri', params.redirectUri);
}
return authorizeUrl.toString();
}
export function extractAnilistAccessTokenFromUrl(rawUrl: string): string | null {
try {
const parsed = new URL(rawUrl);
const fromQuery = parsed.searchParams.get('access_token')?.trim();
if (fromQuery && fromQuery.length > 0) {
return fromQuery;
}
const hash = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash;
if (hash.length === 0) {
return null;
}
const hashParams = new URLSearchParams(hash);
const fromHash = hashParams.get('access_token')?.trim();
if (fromHash && fromHash.length > 0) {
return fromHash;
}
return null;
} catch {
return null;
}
}
export function findAnilistSetupDeepLinkArgvUrl(argv: readonly string[]): string | null {
for (const value of argv) {
if (value.startsWith('subminer://anilist-setup')) {
return value;
}
}
return null;
}
export function consumeAnilistSetupCallbackUrl(
deps: ConsumeAnilistSetupCallbackUrlDeps,
): boolean {
const token = extractAnilistAccessTokenFromUrl(deps.rawUrl);
if (!token) {
return false;
}
const resolvedAt = Date.now();
deps.saveToken(token);
deps.setCachedToken(token);
deps.setResolvedState(resolvedAt);
deps.setSetupPageOpened(false);
deps.onSuccess();
deps.closeWindow();
return true;
}
export function openAnilistSetupInBrowser(params: {
authorizeUrl: string;
openExternal: (url: string) => Promise<void>;
logError: (message: string, error: unknown) => void;
}): void {
void params.openExternal(params.authorizeUrl).catch((error) => {
params.logError('Failed to open AniList authorize URL in browser', error);
});
}
export function buildAnilistSetupFallbackHtml(params: {
reason: string;
authorizeUrl: string;
developerSettingsUrl: string;
}): string {
const safeReason = params.reason.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const safeAuth = params.authorizeUrl.replace(/"/g, '&quot;');
const safeDev = params.developerSettingsUrl.replace(/"/g, '&quot;');
return `<!doctype html>
<html><head><meta charset="utf-8"><title>AniList Setup</title></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.5;">
<h1>AniList setup</h1>
<p>Automatic page load failed (${safeReason}).</p>
<p><a href="${safeAuth}">Open AniList authorize page</a></p>
<p><a href="${safeDev}">Open AniList developer settings</a></p>
</body></html>`;
}
export function buildAnilistManualTokenEntryHtml(params: {
authorizeUrl: string;
developerSettingsUrl: string;
}): string {
const safeAuth = params.authorizeUrl.replace(/"/g, '&quot;');
const safeDev = params.developerSettingsUrl.replace(/"/g, '&quot;');
return `<!doctype html>
<html><head><meta charset="utf-8"><title>AniList Setup</title></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.5;">
<h1>AniList setup</h1>
<p>Authorize in browser, then paste the access token below.</p>
<p><a href="${safeAuth}" target="_blank" rel="noreferrer">Open AniList authorize page</a></p>
<p><a href="${safeDev}" target="_blank" rel="noreferrer">Open AniList developer settings</a></p>
<form id="token-form">
<label for="token">Access token</label><br />
<input id="token" style="width: 100%; max-width: 760px; margin: 8px 0; padding: 8px;" autocomplete="off" />
<br />
<button type="submit" style="padding: 8px 12px;">Continue</button>
</form>
<script>
const form = document.getElementById('token-form');
const token = document.getElementById('token');
form?.addEventListener('submit', (event) => {
event.preventDefault();
const rawToken = String(token?.value || '').trim();
if (rawToken) {
window.location.href = 'subminer://anilist-setup?access_token=' + encodeURIComponent(rawToken);
}
});
</script>
</body></html>`;
}
export function loadAnilistSetupFallback(params: {
setupWindow: BrowserWindow;
reason: string;
authorizeUrl: string;
developerSettingsUrl: string;
logWarn: (message: string, data: unknown) => void;
}): void {
const html = buildAnilistSetupFallbackHtml({
reason: params.reason,
authorizeUrl: params.authorizeUrl,
developerSettingsUrl: params.developerSettingsUrl,
});
void params.setupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
params.logWarn('Loaded AniList setup fallback page', { reason: params.reason });
}
export function loadAnilistManualTokenEntry(params: {
setupWindow: BrowserWindow;
authorizeUrl: string;
developerSettingsUrl: string;
logWarn: (message: string, data: unknown) => void;
}): void {
const html = buildAnilistManualTokenEntryHtml({
authorizeUrl: params.authorizeUrl,
developerSettingsUrl: params.developerSettingsUrl,
});
void params.setupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
params.logWarn('Loaded AniList manual token entry page', {
authorizeUrl: params.authorizeUrl,
});
}

View File

@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createAnilistStateRuntime } from './anilist-state';
import type { AnilistRetryQueueState, AnilistSecretResolutionState } from '../state';
function createRuntime() {
let clientState: AnilistSecretResolutionState = {
status: 'resolved',
source: 'stored',
message: 'ok' as string | null,
resolvedAt: 1000 as number | null,
errorAt: null as number | null,
};
let queueState: AnilistRetryQueueState = {
pending: 1,
ready: 2,
deadLetter: 3,
lastAttemptAt: 2000 as number | null,
lastError: 'none' as string | null,
};
let clearedStoredToken = false;
let clearedCachedToken = false;
const runtime = createAnilistStateRuntime({
getClientSecretState: () => clientState,
setClientSecretState: (next) => {
clientState = next;
},
getRetryQueueState: () => queueState,
setRetryQueueState: (next) => {
queueState = next;
},
getUpdateQueueSnapshot: () => ({
pending: 7,
ready: 8,
deadLetter: 9,
lastAttemptAt: 3000,
lastError: 'boom' as string | null,
}),
clearStoredToken: () => {
clearedStoredToken = true;
},
clearCachedAccessToken: () => {
clearedCachedToken = true;
},
});
return {
runtime,
getClientState: () => clientState,
getQueueState: () => queueState,
getClearedStoredToken: () => clearedStoredToken,
getClearedCachedToken: () => clearedCachedToken,
};
}
test('setClientSecretState merges partial updates', () => {
const harness = createRuntime();
harness.runtime.setClientSecretState({
status: 'error',
source: 'none',
errorAt: 4000,
});
assert.deepEqual(harness.getClientState(), {
status: 'error',
source: 'none',
message: 'ok',
resolvedAt: 1000,
errorAt: 4000,
});
});
test('refresh/get queue snapshot uses update queue snapshot', () => {
const harness = createRuntime();
const snapshot = harness.runtime.getQueueStatusSnapshot();
assert.deepEqual(snapshot, {
pending: 7,
ready: 8,
deadLetter: 9,
lastAttemptAt: 3000,
lastError: 'boom',
});
assert.deepEqual(harness.getQueueState(), snapshot);
});
test('clearTokenState resets token state and clears caches', () => {
const harness = createRuntime();
harness.runtime.clearTokenState();
assert.equal(harness.getClearedStoredToken(), true);
assert.equal(harness.getClearedCachedToken(), true);
assert.deepEqual(harness.getClientState(), {
status: 'not_checked',
source: 'none',
message: 'stored token cleared',
resolvedAt: null,
errorAt: null,
});
});

View File

@@ -0,0 +1,97 @@
import type { AnilistRetryQueueState, AnilistSecretResolutionState } from '../state';
type AnilistQueueSnapshot = Pick<AnilistRetryQueueState, 'pending' | 'ready' | 'deadLetter'>;
type AnilistStatusSnapshot = {
tokenStatus: AnilistSecretResolutionState['status'];
tokenSource: AnilistSecretResolutionState['source'];
tokenMessage: string | null;
tokenResolvedAt: number | null;
tokenErrorAt: number | null;
queuePending: number;
queueReady: number;
queueDeadLetter: number;
queueLastAttemptAt: number | null;
queueLastError: string | null;
};
export type AnilistStateRuntimeDeps = {
getClientSecretState: () => AnilistSecretResolutionState;
setClientSecretState: (next: AnilistSecretResolutionState) => void;
getRetryQueueState: () => AnilistRetryQueueState;
setRetryQueueState: (next: AnilistRetryQueueState) => void;
getUpdateQueueSnapshot: () => AnilistQueueSnapshot;
clearStoredToken: () => void;
clearCachedAccessToken: () => void;
};
export function createAnilistStateRuntime(deps: AnilistStateRuntimeDeps): {
setClientSecretState: (partial: Partial<AnilistSecretResolutionState>) => void;
refreshRetryQueueState: () => void;
getStatusSnapshot: () => AnilistStatusSnapshot;
getQueueStatusSnapshot: () => AnilistRetryQueueState;
clearTokenState: () => void;
} {
const setClientSecretState = (partial: Partial<AnilistSecretResolutionState>): void => {
deps.setClientSecretState({
...deps.getClientSecretState(),
...partial,
});
};
const refreshRetryQueueState = (): void => {
deps.setRetryQueueState({
...deps.getRetryQueueState(),
...deps.getUpdateQueueSnapshot(),
});
};
const getStatusSnapshot = (): AnilistStatusSnapshot => {
const client = deps.getClientSecretState();
const queue = deps.getRetryQueueState();
return {
tokenStatus: client.status,
tokenSource: client.source,
tokenMessage: client.message,
tokenResolvedAt: client.resolvedAt,
tokenErrorAt: client.errorAt,
queuePending: queue.pending,
queueReady: queue.ready,
queueDeadLetter: queue.deadLetter,
queueLastAttemptAt: queue.lastAttemptAt,
queueLastError: queue.lastError,
};
};
const getQueueStatusSnapshot = (): AnilistRetryQueueState => {
refreshRetryQueueState();
const queue = deps.getRetryQueueState();
return {
pending: queue.pending,
ready: queue.ready,
deadLetter: queue.deadLetter,
lastAttemptAt: queue.lastAttemptAt,
lastError: queue.lastError,
};
};
const clearTokenState = (): void => {
deps.clearStoredToken();
deps.clearCachedAccessToken();
setClientSecretState({
status: 'not_checked',
source: 'none',
message: 'stored token cleared',
resolvedAt: null,
errorAt: null,
});
};
return {
setClientSecretState,
refreshRetryQueueState,
getStatusSnapshot,
getQueueStatusSnapshot,
clearTokenState,
};
}

View File

@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { appendClipboardVideoToQueueRuntime } from './clipboard-queue';
test('appendClipboardVideoToQueueRuntime returns disconnected when mpv unavailable', () => {
const result = appendClipboardVideoToQueueRuntime({
getMpvClient: () => null,
readClipboardText: () => '',
showMpvOsd: () => {},
sendMpvCommand: () => {},
});
assert.deepEqual(result, { ok: false, message: 'MPV is not connected.' });
});
test('appendClipboardVideoToQueueRuntime rejects unsupported clipboard path', () => {
const osdMessages: string[] = [];
const result = appendClipboardVideoToQueueRuntime({
getMpvClient: () => ({ connected: true }),
readClipboardText: () => 'not a media path',
showMpvOsd: (text) => osdMessages.push(text),
sendMpvCommand: () => {},
});
assert.equal(result.ok, false);
assert.equal(osdMessages[0], 'Clipboard does not contain a supported video path.');
});
test('appendClipboardVideoToQueueRuntime queues readable media file', () => {
const tempPath = path.join(process.cwd(), 'dist', 'clipboard-queue-test-video.mkv');
fs.writeFileSync(tempPath, 'stub');
const commands: Array<(string | number)[]> = [];
const osdMessages: string[] = [];
const result = appendClipboardVideoToQueueRuntime({
getMpvClient: () => ({ connected: true }),
readClipboardText: () => tempPath,
showMpvOsd: (text) => osdMessages.push(text),
sendMpvCommand: (command) => commands.push(command),
});
assert.equal(result.ok, true);
assert.deepEqual(commands[0], ['loadfile', tempPath, 'append']);
assert.equal(osdMessages[0], `Queued from clipboard: ${path.basename(tempPath)}`);
fs.unlinkSync(tempPath);
});

View File

@@ -0,0 +1,40 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseClipboardVideoPath } from '../../core/services';
type MpvClientLike = {
connected: boolean;
};
export type AppendClipboardVideoToQueueRuntimeDeps = {
getMpvClient: () => MpvClientLike | null;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
sendMpvCommand: (command: (string | number)[]) => void;
};
export function appendClipboardVideoToQueueRuntime(
deps: AppendClipboardVideoToQueueRuntimeDeps,
): { ok: boolean; message: string } {
const mpvClient = deps.getMpvClient();
if (!mpvClient || !mpvClient.connected) {
return { ok: false, message: 'MPV is not connected.' };
}
const clipboardText = deps.readClipboardText();
const parsedPath = parseClipboardVideoPath(clipboardText);
if (!parsedPath) {
deps.showMpvOsd('Clipboard does not contain a supported video path.');
return { ok: false, message: 'Clipboard does not contain a supported video path.' };
}
const resolvedPath = path.resolve(parsedPath);
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
deps.showMpvOsd('Clipboard path is not a readable file.');
return { ok: false, message: 'Clipboard path is not a readable file.' };
}
deps.sendMpvCommand(['loadfile', resolvedPath, 'append']);
deps.showMpvOsd(`Queued from clipboard: ${path.basename(resolvedPath)}`);
return { ok: true, message: `Queued ${resolvedPath}` };
}

View File

@@ -0,0 +1,64 @@
import type { RuntimeOptionsManager } from '../../runtime-options';
import type { JimakuApiResponse, JimakuLanguagePreference, ResolvedConfig } from '../../types';
import {
getInitialInvisibleOverlayVisibility as getInitialInvisibleOverlayVisibilityCore,
getJimakuLanguagePreference as getJimakuLanguagePreferenceCore,
getJimakuMaxEntryResults as getJimakuMaxEntryResultsCore,
isAutoUpdateEnabledRuntime as isAutoUpdateEnabledRuntimeCore,
jimakuFetchJson as jimakuFetchJsonCore,
resolveJimakuApiKey as resolveJimakuApiKeyCore,
shouldAutoInitializeOverlayRuntimeFromConfig as shouldAutoInitializeOverlayRuntimeFromConfigCore,
shouldBindVisibleOverlayToMpvSubVisibility as shouldBindVisibleOverlayToMpvSubVisibilityCore,
} from '../../core/services';
export type ConfigDerivedRuntimeDeps = {
getResolvedConfig: () => ResolvedConfig;
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
platform: NodeJS.Platform;
defaultJimakuLanguagePreference: JimakuLanguagePreference;
defaultJimakuMaxEntryResults: number;
defaultJimakuApiBaseUrl: string;
};
export function createConfigDerivedRuntime(deps: ConfigDerivedRuntimeDeps): {
getInitialInvisibleOverlayVisibility: () => boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isAutoUpdateEnabledRuntime: () => boolean;
getJimakuLanguagePreference: () => JimakuLanguagePreference;
getJimakuMaxEntryResults: () => number;
resolveJimakuApiKey: () => Promise<string | null>;
jimakuFetchJson: <T>(
endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>,
) => Promise<JimakuApiResponse<T>>;
} {
return {
getInitialInvisibleOverlayVisibility: () =>
getInitialInvisibleOverlayVisibilityCore(deps.getResolvedConfig(), deps.platform),
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
shouldAutoInitializeOverlayRuntimeFromConfigCore(deps.getResolvedConfig()),
shouldBindVisibleOverlayToMpvSubVisibility: () =>
shouldBindVisibleOverlayToMpvSubVisibilityCore(deps.getResolvedConfig()),
isAutoUpdateEnabledRuntime: () =>
isAutoUpdateEnabledRuntimeCore(deps.getResolvedConfig(), deps.getRuntimeOptionsManager()),
getJimakuLanguagePreference: () =>
getJimakuLanguagePreferenceCore(
() => deps.getResolvedConfig(),
deps.defaultJimakuLanguagePreference,
),
getJimakuMaxEntryResults: () =>
getJimakuMaxEntryResultsCore(() => deps.getResolvedConfig(), deps.defaultJimakuMaxEntryResults),
resolveJimakuApiKey: () => resolveJimakuApiKeyCore(() => deps.getResolvedConfig()),
jimakuFetchJson: <T>(
endpoint: string,
query: Record<string, string | number | boolean | null | undefined> = {},
): Promise<JimakuApiResponse<T>> =>
jimakuFetchJsonCore<T>(endpoint, query, {
getResolvedConfig: () => deps.getResolvedConfig(),
defaultBaseUrl: deps.defaultJimakuApiBaseUrl,
defaultMaxEntryResults: deps.defaultJimakuMaxEntryResults,
defaultLanguagePreference: deps.defaultJimakuLanguagePreference,
}),
};
}

View File

@@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import {
buildRestartRequiredConfigMessage,
createConfigHotReloadAppliedHandler,
createConfigHotReloadMessageHandler,
} from './config-hot-reload-handlers';
test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
const calls: string[] = [];
const ankiPatches: Array<{ enabled: boolean }> = [];
const applyHotReload = createConfigHotReloadAppliedHandler({
setKeybindings: () => calls.push('set:keybindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`),
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${typeof payload === 'string' ? payload : 'object'}`),
applyAnkiRuntimeConfigPatch: (patch) => {
ankiPatches.push({ enabled: patch.ai.enabled });
},
});
applyHotReload(
{
hotReloadFields: ['shortcuts', 'secondarySub.defaultMode', 'ankiConnect.ai'],
restartRequiredFields: [],
},
config,
);
assert.ok(calls.includes('set:keybindings'));
assert.ok(calls.includes('refresh:shortcuts'));
assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`));
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
assert.ok(calls.includes('broadcast:config:hot-reload:object'));
assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]);
});
test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
const calls: string[] = [];
const applyHotReload = createConfigHotReloadAppliedHandler({
setKeybindings: () => calls.push('set:keybindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
setSecondarySubMode: () => calls.push('set:secondary'),
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
applyAnkiRuntimeConfigPatch: () => calls.push('anki:patch'),
});
applyHotReload(
{
hotReloadFields: [],
restartRequiredFields: [],
},
config,
);
assert.deepEqual(calls, ['set:keybindings']);
});
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {
const calls: string[] = [];
const handleMessage = createConfigHotReloadMessageHandler({
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
});
handleMessage('Config reload failed');
assert.deepEqual(calls, ['osd:Config reload failed', 'notify:SubMiner:Config reload failed']);
});
test('buildRestartRequiredConfigMessage formats changed fields', () => {
assert.equal(
buildRestartRequiredConfigMessage(['websocket', 'subtitleStyle']),
'Config updated; restart required for: websocket, subtitleStyle',
);
});

View File

@@ -0,0 +1,73 @@
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
import { resolveKeybindings } from '../../core/utils';
import { DEFAULT_KEYBINDINGS } from '../../config';
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
type ConfigHotReloadAppliedDeps = {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => void;
};
type ConfigHotReloadMessageDeps = {
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
};
export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
if (!config.subtitleStyle) {
return null;
}
return {
...config.subtitleStyle,
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
knownWordColor: config.ankiConnect.nPlusOne.knownWord,
enableJlpt: config.subtitleStyle.enableJlpt,
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
};
}
export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
return {
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
subtitleStyle: resolveSubtitleStyleForRenderer(config),
secondarySubMode: config.secondarySub.defaultMode,
};
}
export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadAppliedDeps) {
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
const payload = buildConfigHotReloadPayload(config);
deps.setKeybindings(payload.keybindings);
if (diff.hotReloadFields.includes('shortcuts')) {
deps.refreshGlobalAndOverlayShortcuts();
}
if (diff.hotReloadFields.includes('secondarySub.defaultMode')) {
deps.setSecondarySubMode(payload.secondarySubMode);
deps.broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode);
}
if (diff.hotReloadFields.includes('ankiConnect.ai')) {
deps.applyAnkiRuntimeConfigPatch({ ai: config.ankiConnect.ai });
}
if (diff.hotReloadFields.length > 0) {
deps.broadcastToOverlayWindows('config:hot-reload', payload);
}
};
}
export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) {
return (message: string): void => {
deps.showMpvOsd(message);
deps.showDesktopNotification('SubMiner', { body: message });
};
}
export function buildRestartRequiredConfigMessage(fields: string[]): string {
return `Config updated; restart required for: ${fields.join(', ')}`;
}

View File

@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createImmersionMediaRuntime } from './immersion-media';
test('getConfiguredDbPath uses trimmed configured path with fallback', () => {
const runtime = createImmersionMediaRuntime({
getResolvedConfig: () => ({ immersionTracking: { dbPath: ' /tmp/custom.db ' } }),
defaultImmersionDbPath: '/tmp/default.db',
getTracker: () => null,
getMpvClient: () => null,
getCurrentMediaPath: () => null,
getCurrentMediaTitle: () => null,
logDebug: () => {},
logInfo: () => {},
});
assert.equal(runtime.getConfiguredDbPath(), '/tmp/custom.db');
const fallbackRuntime = createImmersionMediaRuntime({
getResolvedConfig: () => ({ immersionTracking: { dbPath: ' ' } }),
defaultImmersionDbPath: '/tmp/default.db',
getTracker: () => null,
getMpvClient: () => null,
getCurrentMediaPath: () => null,
getCurrentMediaTitle: () => null,
logDebug: () => {},
logInfo: () => {},
});
assert.equal(fallbackRuntime.getConfiguredDbPath(), '/tmp/default.db');
});
test('syncFromCurrentMediaState uses current media path directly', () => {
const calls: Array<{ path: string; title: string | null }> = [];
const runtime = createImmersionMediaRuntime({
getResolvedConfig: () => ({}),
defaultImmersionDbPath: '/tmp/default.db',
getTracker: () => ({
handleMediaChange: (path, title) => calls.push({ path, title }),
}),
getMpvClient: () => ({ connected: true, currentVideoPath: '/tmp/video.mkv' }),
getCurrentMediaPath: () => ' /tmp/current.mkv ',
getCurrentMediaTitle: () => ' Current Title ',
logDebug: () => {},
logInfo: () => {},
});
runtime.syncFromCurrentMediaState();
assert.deepEqual(calls, [{ path: '/tmp/current.mkv', title: 'Current Title' }]);
});
test('seedFromCurrentMedia resolves media path from mpv properties', async () => {
const calls: Array<{ path: string; title: string | null }> = [];
const runtime = createImmersionMediaRuntime({
getResolvedConfig: () => ({}),
defaultImmersionDbPath: '/tmp/default.db',
getTracker: () => ({
handleMediaChange: (path, title) => calls.push({ path, title }),
}),
getMpvClient: () => ({
connected: true,
requestProperty: async (name: string) => {
if (name === 'path') return '/tmp/from-property.mkv';
if (name === 'media-title') return 'Property Title';
return null;
},
}),
getCurrentMediaPath: () => null,
getCurrentMediaTitle: () => null,
sleep: async () => {},
seedAttempts: 2,
logDebug: () => {},
logInfo: () => {},
});
await runtime.seedFromCurrentMedia();
assert.deepEqual(calls, [{ path: '/tmp/from-property.mkv', title: 'Property Title' }]);
});

View File

@@ -0,0 +1,174 @@
type ResolvedConfigLike = {
immersionTracking?: {
dbPath?: string | null;
};
};
type ImmersionTrackerLike = {
handleMediaChange: (path: string, title: string | null) => void;
};
type MpvClientLike = {
currentVideoPath?: string | null;
connected?: boolean;
requestProperty?: (name: string) => Promise<unknown>;
};
type ImmersionMediaState = {
path: string | null;
title: string | null;
};
export type ImmersionMediaRuntimeDeps = {
getResolvedConfig: () => ResolvedConfigLike;
defaultImmersionDbPath: string;
getTracker: () => ImmersionTrackerLike | null;
getMpvClient: () => MpvClientLike | null;
getCurrentMediaPath: () => string | null | undefined;
getCurrentMediaTitle: () => string | null | undefined;
sleep?: (ms: number) => Promise<void>;
seedWaitMs?: number;
seedAttempts?: number;
logDebug: (message: string) => void;
logInfo: (message: string) => void;
};
function trimToNull(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
async function readMpvPropertyAsString(
mpvClient: MpvClientLike | null | undefined,
propertyName: string,
): Promise<string | null> {
const requestProperty = mpvClient?.requestProperty;
if (!requestProperty) {
return null;
}
try {
const value = await requestProperty(propertyName);
return typeof value === 'string' ? trimToNull(value) : null;
} catch {
return null;
}
}
export function createImmersionMediaRuntime(deps: ImmersionMediaRuntimeDeps): {
getConfiguredDbPath: () => string;
seedFromCurrentMedia: () => Promise<void>;
syncFromCurrentMediaState: () => void;
} {
const sleep = deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const waitMs = deps.seedWaitMs ?? 250;
const attempts = deps.seedAttempts ?? 120;
let isSeedInProgress = false;
const getConfiguredDbPath = (): string => {
const configuredDbPath = trimToNull(deps.getResolvedConfig().immersionTracking?.dbPath);
return configuredDbPath ?? deps.defaultImmersionDbPath;
};
const getCurrentMpvMediaStateForTracker = async (): Promise<ImmersionMediaState> => {
const statePath = trimToNull(deps.getCurrentMediaPath());
const stateTitle = trimToNull(deps.getCurrentMediaTitle());
if (statePath) {
return {
path: statePath,
title: stateTitle,
};
}
const mpvClient = deps.getMpvClient();
const trackedPath = trimToNull(mpvClient?.currentVideoPath);
if (trackedPath) {
return {
path: trackedPath,
title: stateTitle,
};
}
const [pathFromProperty, filenameFromProperty, titleFromProperty] = await Promise.all([
readMpvPropertyAsString(mpvClient, 'path'),
readMpvPropertyAsString(mpvClient, 'filename'),
readMpvPropertyAsString(mpvClient, 'media-title'),
]);
return {
path: pathFromProperty || filenameFromProperty || null,
title: stateTitle || titleFromProperty || null,
};
};
const seedFromCurrentMedia = async (): Promise<void> => {
const tracker = deps.getTracker();
if (!tracker) {
deps.logDebug('Immersion tracker seeding skipped: tracker not initialized.');
return;
}
if (isSeedInProgress) {
deps.logDebug('Immersion tracker seeding already in progress; skipping duplicate call.');
return;
}
deps.logDebug('Starting immersion tracker media-state seed loop.');
isSeedInProgress = true;
try {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const mediaState = await getCurrentMpvMediaStateForTracker();
if (mediaState.path) {
deps.logInfo(
`Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${mediaState.path}`,
);
tracker.handleMediaChange(mediaState.path, mediaState.title);
return;
}
const mpvClient = deps.getMpvClient();
if (!mpvClient || !mpvClient.connected) {
await sleep(waitMs);
continue;
}
if (attempt < attempts - 1) {
await sleep(waitMs);
}
}
deps.logInfo(
'Immersion tracker seed failed: media path still unavailable after startup warmup',
);
} finally {
isSeedInProgress = false;
}
};
const syncFromCurrentMediaState = (): void => {
const tracker = deps.getTracker();
if (!tracker) {
deps.logDebug('Immersion tracker sync skipped: tracker not initialized yet.');
return;
}
const pathFromState =
trimToNull(deps.getCurrentMediaPath()) || trimToNull(deps.getMpvClient()?.currentVideoPath);
if (pathFromState) {
deps.logDebug('Immersion tracker sync using path from current media state.');
tracker.handleMediaChange(pathFromState, trimToNull(deps.getCurrentMediaTitle()));
return;
}
if (!isSeedInProgress) {
deps.logDebug('Immersion tracker sync did not find media path; starting seed loop.');
void seedFromCurrentMedia();
} else {
deps.logDebug('Immersion tracker sync found seed loop already running.');
}
};
return {
getConfiguredDbPath,
seedFromCurrentMedia,
syncFromCurrentMediaState,
};
}

View File

@@ -0,0 +1,137 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createImmersionTrackerStartupHandler } from './immersion-startup';
function makeConfig() {
return {
immersionTracking: {
enabled: true,
batchSize: 40,
flushIntervalMs: 1500,
queueCap: 500,
payloadCapBytes: 16000,
maintenanceIntervalMs: 3600000,
retention: {
eventsDays: 14,
telemetryDays: 30,
dailyRollupsDays: 180,
monthlyRollupsDays: 730,
vacuumIntervalDays: 7,
},
},
};
}
test('createImmersionTrackerStartupHandler skips when disabled', () => {
const calls: string[] = [];
let tracker: unknown = 'unchanged';
const handler = createImmersionTrackerStartupHandler({
getResolvedConfig: () => ({
immersionTracking: {
...makeConfig().immersionTracking,
enabled: false,
},
}),
getConfiguredDbPath: () => '/tmp/subminer.db',
createTrackerService: () => {
calls.push('createTrackerService');
return {};
},
setTracker: (nextTracker) => {
tracker = nextTracker;
},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => calls.push('seedTracker'),
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
});
handler();
assert.ok(calls.includes('info:Immersion tracking disabled in config'));
assert.equal(calls.includes('createTrackerService'), false);
assert.equal(calls.includes('seedTracker'), false);
assert.equal(tracker, 'unchanged');
});
test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv', () => {
const calls: string[] = [];
const trackerInstance = { kind: 'tracker' };
let assignedTracker: unknown = null;
let receivedDbPath = '';
let receivedPolicy: unknown;
let connectCalls = 0;
const handler = createImmersionTrackerStartupHandler({
getResolvedConfig: () => makeConfig(),
getConfiguredDbPath: () => '/tmp/subminer.db',
createTrackerService: (params) => {
receivedDbPath = params.dbPath;
receivedPolicy = params.policy;
return trackerInstance;
},
setTracker: (nextTracker) => {
assignedTracker = nextTracker;
},
getMpvClient: () => ({
connected: false,
connect: () => {
connectCalls += 1;
},
}),
seedTrackerFromCurrentMedia: () => calls.push('seedTracker'),
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
});
handler();
assert.equal(receivedDbPath, '/tmp/subminer.db');
assert.deepEqual(receivedPolicy, {
batchSize: 40,
flushIntervalMs: 1500,
queueCap: 500,
payloadCapBytes: 16000,
maintenanceIntervalMs: 3600000,
retention: {
eventsDays: 14,
telemetryDays: 30,
dailyRollupsDays: 180,
monthlyRollupsDays: 730,
vacuumIntervalDays: 7,
},
});
assert.equal(assignedTracker, trackerInstance);
assert.equal(connectCalls, 1);
assert.ok(calls.includes('seedTracker'));
assert.ok(calls.includes('info:Auto-connecting MPV client for immersion tracking'));
});
test('createImmersionTrackerStartupHandler disables tracker on failure', () => {
const calls: string[] = [];
let assignedTracker: unknown = 'initial';
const handler = createImmersionTrackerStartupHandler({
getResolvedConfig: () => makeConfig(),
getConfiguredDbPath: () => '/tmp/subminer.db',
createTrackerService: () => {
throw new Error('db unavailable');
},
setTracker: (nextTracker) => {
assignedTracker = nextTracker;
},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => calls.push('seedTracker'),
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message, details) => calls.push(`warn:${message}:${(details as Error).message}`),
});
handler();
assert.equal(assignedTracker, null);
assert.equal(calls.includes('seedTracker'), false);
assert.ok(
calls.includes('warn:Immersion tracker startup failed; disabling tracking.:db unavailable'),
);
});

View File

@@ -0,0 +1,99 @@
type ImmersionRetentionPolicy = {
eventsDays: number;
telemetryDays: number;
dailyRollupsDays: number;
monthlyRollupsDays: number;
vacuumIntervalDays: number;
};
type ImmersionTrackingPolicy = {
enabled?: boolean;
batchSize: number;
flushIntervalMs: number;
queueCap: number;
payloadCapBytes: number;
maintenanceIntervalMs: number;
retention: ImmersionRetentionPolicy;
};
type ImmersionTrackingConfig = {
immersionTracking?: ImmersionTrackingPolicy;
};
type ImmersionTrackerPolicy = Omit<ImmersionTrackingPolicy, 'enabled'>;
type ImmersionTrackerServiceParams = {
dbPath: string;
policy: ImmersionTrackerPolicy;
};
type MpvClientLike = {
connected: boolean;
connect: () => void;
};
export type ImmersionTrackerStartupDeps = {
getResolvedConfig: () => ImmersionTrackingConfig;
getConfiguredDbPath: () => string;
createTrackerService: (params: ImmersionTrackerServiceParams) => unknown;
setTracker: (tracker: unknown | null) => void;
getMpvClient: () => MpvClientLike | null;
seedTrackerFromCurrentMedia: () => void;
logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string, details: unknown) => void;
};
export function createImmersionTrackerStartupHandler(
deps: ImmersionTrackerStartupDeps,
): () => void {
return () => {
const config = deps.getResolvedConfig();
if (config.immersionTracking?.enabled === false) {
deps.logInfo('Immersion tracking disabled in config');
return;
}
try {
deps.logDebug('Immersion tracker startup requested: creating tracker service.');
const dbPath = deps.getConfiguredDbPath();
deps.logInfo(`Creating immersion tracker with dbPath=${dbPath}`);
const policy = config.immersionTracking;
if (!policy) {
throw new Error('Immersion tracking policy missing');
}
deps.setTracker(
deps.createTrackerService({
dbPath,
policy: {
batchSize: policy.batchSize,
flushIntervalMs: policy.flushIntervalMs,
queueCap: policy.queueCap,
payloadCapBytes: policy.payloadCapBytes,
maintenanceIntervalMs: policy.maintenanceIntervalMs,
retention: {
eventsDays: policy.retention.eventsDays,
telemetryDays: policy.retention.telemetryDays,
dailyRollupsDays: policy.retention.dailyRollupsDays,
monthlyRollupsDays: policy.retention.monthlyRollupsDays,
vacuumIntervalDays: policy.retention.vacuumIntervalDays,
},
},
}),
);
deps.logDebug('Immersion tracker initialized successfully.');
const mpvClient = deps.getMpvClient();
if (mpvClient && !mpvClient.connected) {
deps.logInfo('Auto-connecting MPV client for immersion tracking');
mpvClient.connect();
}
deps.seedTrackerFromCurrentMedia();
} catch (error) {
deps.logWarn('Immersion tracker startup failed; disabling tracking.', error);
deps.setTracker(null);
}
};
}

View File

@@ -0,0 +1,141 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createHandleJellyfinRemoteGeneralCommand,
createHandleJellyfinRemotePlay,
createHandleJellyfinRemotePlaystate,
getConfiguredJellyfinSession,
type ActiveJellyfinRemotePlaybackState,
} from './jellyfin-remote-commands';
test('getConfiguredJellyfinSession returns null for incomplete config', () => {
assert.equal(
getConfiguredJellyfinSession({
serverUrl: '',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
null,
);
});
test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', async () => {
const calls: Array<{ itemId: string; audio?: number; subtitle?: number; start?: number }> = [];
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({ enabled: true }),
playJellyfinItem: async (params) => {
calls.push({
itemId: params.itemId,
audio: params.audioStreamIndex,
subtitle: params.subtitleStreamIndex,
start: params.startTimeTicksOverride,
});
},
logWarn: () => {},
});
await handlePlay({
ItemIds: ['item-1'],
AudioStreamIndex: 3,
SubtitleStreamIndex: 7,
StartPositionTicks: 1000,
});
assert.deepEqual(calls, [{ itemId: 'item-1', audio: 3, subtitle: 7, start: 1000 }]);
});
test('createHandleJellyfinRemotePlay logs and skips payload without item id', async () => {
const warnings: string[] = [];
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({}),
playJellyfinItem: async () => {
throw new Error('should not be called');
},
logWarn: (message) => warnings.push(message),
});
await handlePlay({ ItemIds: [] });
assert.deepEqual(warnings, ['Ignoring Jellyfin remote Play event without ItemIds.']);
});
test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', async () => {
const mpvClient = {};
const commands: Array<(string | number)[]> = [];
const calls: string[] = [];
const handlePlaystate = createHandleJellyfinRemotePlaystate({
getMpvClient: () => mpvClient,
sendMpvCommand: (_client, command) => commands.push(command),
reportJellyfinRemoteProgress: async (force) => {
calls.push(`progress:${force}`);
},
reportJellyfinRemoteStopped: async () => {
calls.push('stopped');
},
jellyfinTicksToSeconds: (ticks) => ticks / 10,
});
await handlePlaystate({ Command: 'Pause' });
await handlePlaystate({ Command: 'Seek', SeekPositionTicks: 50 });
await handlePlaystate({ Command: 'Stop' });
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['seek', 5, 'absolute+exact'],
['stop'],
]);
assert.deepEqual(calls, ['progress:true', 'progress:true', 'stopped']);
});
test('createHandleJellyfinRemoteGeneralCommand mutates active playback indices', async () => {
const mpvClient = {};
const commands: Array<(string | number)[]> = [];
const playback: ActiveJellyfinRemotePlaybackState = {
itemId: 'item-1',
playMethod: 'DirectPlay',
audioStreamIndex: null,
subtitleStreamIndex: null,
};
const calls: string[] = [];
const handleGeneral = createHandleJellyfinRemoteGeneralCommand({
getMpvClient: () => mpvClient,
sendMpvCommand: (_client, command) => commands.push(command),
getActivePlayback: () => playback,
reportJellyfinRemoteProgress: async (force) => {
calls.push(`progress:${force}`);
},
logDebug: (message) => {
calls.push(`debug:${message}`);
},
});
await handleGeneral({ Name: 'SetAudioStreamIndex', Arguments: { Index: 2 } });
await handleGeneral({ Name: 'SetSubtitleStreamIndex', Arguments: { Index: -1 } });
await handleGeneral({ Name: 'UnsupportedCommand', Arguments: {} });
assert.deepEqual(commands, [
['set_property', 'aid', 2],
['set_property', 'sid', 'no'],
]);
assert.equal(playback.audioStreamIndex, 2);
assert.equal(playback.subtitleStreamIndex, null);
assert.ok(calls.includes('progress:true'));
assert.ok(
calls.some((entry) => entry.includes('Ignoring unsupported Jellyfin GeneralCommand: UnsupportedCommand')),
);
});

View File

@@ -0,0 +1,189 @@
export type ActiveJellyfinRemotePlaybackState = {
itemId: string;
mediaSourceId?: string;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
playMethod: 'DirectPlay' | 'Transcode';
};
type JellyfinSession = {
serverUrl: string;
accessToken: string;
userId: string;
username: string;
};
type JellyfinClientInfo = {
clientName: string;
clientVersion: string;
deviceId: string;
};
type JellyfinConfigLike = {
serverUrl: string;
accessToken: string;
userId: string;
username: string;
};
function asInteger(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isInteger(value)) return undefined;
return value;
}
export function getConfiguredJellyfinSession(config: JellyfinConfigLike): JellyfinSession | null {
if (!config.serverUrl || !config.accessToken || !config.userId) {
return null;
}
return {
serverUrl: config.serverUrl,
accessToken: config.accessToken,
userId: config.userId,
username: config.username,
};
}
export type JellyfinRemotePlayHandlerDeps = {
getConfiguredSession: () => JellyfinSession | null;
getClientInfo: () => JellyfinClientInfo;
getJellyfinConfig: () => unknown;
playJellyfinItem: (params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
jellyfinConfig: unknown;
itemId: string;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
startTimeTicksOverride?: number;
setQuitOnDisconnectArm?: boolean;
}) => Promise<void>;
logWarn: (message: string) => void;
};
export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDeps) {
return async (payload: unknown): Promise<void> => {
const session = deps.getConfiguredSession();
if (!session) return;
const clientInfo = deps.getClientInfo();
const jellyfinConfig = deps.getJellyfinConfig();
const data = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const itemIds = Array.isArray(data.ItemIds)
? data.ItemIds.filter((entry): entry is string => typeof entry === 'string')
: [];
const itemId = itemIds[0];
if (!itemId) {
deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.');
return;
}
await deps.playJellyfinItem({
session,
clientInfo,
jellyfinConfig,
itemId,
audioStreamIndex: asInteger(data.AudioStreamIndex),
subtitleStreamIndex: asInteger(data.SubtitleStreamIndex),
startTimeTicksOverride: asInteger(data.StartPositionTicks),
setQuitOnDisconnectArm: false,
});
};
}
type MpvClientLike = object;
export type JellyfinRemotePlaystateHandlerDeps = {
getMpvClient: () => MpvClientLike | null;
sendMpvCommand: (client: MpvClientLike, command: (string | number)[]) => void;
reportJellyfinRemoteProgress: (force: boolean) => Promise<void>;
reportJellyfinRemoteStopped: () => Promise<void>;
jellyfinTicksToSeconds: (ticks: number) => number;
};
export function createHandleJellyfinRemotePlaystate(deps: JellyfinRemotePlaystateHandlerDeps) {
return async (payload: unknown): Promise<void> => {
const data = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const command = String(data.Command || '');
const client = deps.getMpvClient();
if (!client) return;
if (command === 'Pause') {
deps.sendMpvCommand(client, ['set_property', 'pause', 'yes']);
await deps.reportJellyfinRemoteProgress(true);
return;
}
if (command === 'Unpause') {
deps.sendMpvCommand(client, ['set_property', 'pause', 'no']);
await deps.reportJellyfinRemoteProgress(true);
return;
}
if (command === 'PlayPause') {
deps.sendMpvCommand(client, ['cycle', 'pause']);
await deps.reportJellyfinRemoteProgress(true);
return;
}
if (command === 'Stop') {
deps.sendMpvCommand(client, ['stop']);
await deps.reportJellyfinRemoteStopped();
return;
}
if (command === 'Seek') {
const seekTicks = asInteger(data.SeekPositionTicks);
if (seekTicks !== undefined) {
deps.sendMpvCommand(client, [
'seek',
deps.jellyfinTicksToSeconds(seekTicks),
'absolute+exact',
]);
await deps.reportJellyfinRemoteProgress(true);
}
}
};
}
export type JellyfinRemoteGeneralCommandHandlerDeps = {
getMpvClient: () => MpvClientLike | null;
sendMpvCommand: (client: MpvClientLike, command: (string | number)[]) => void;
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
reportJellyfinRemoteProgress: (force: boolean) => Promise<void>;
logDebug: (message: string) => void;
};
export function createHandleJellyfinRemoteGeneralCommand(
deps: JellyfinRemoteGeneralCommandHandlerDeps,
) {
return async (payload: unknown): Promise<void> => {
const data = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const command = String(data.Name || '');
const args =
data.Arguments && typeof data.Arguments === 'object'
? (data.Arguments as Record<string, unknown>)
: {};
const client = deps.getMpvClient();
if (!client) return;
if (command === 'SetAudioStreamIndex') {
const index = asInteger(args.Index);
if (index !== undefined) {
deps.sendMpvCommand(client, ['set_property', 'aid', index]);
const playback = deps.getActivePlayback();
if (playback) {
playback.audioStreamIndex = index;
}
await deps.reportJellyfinRemoteProgress(true);
}
return;
}
if (command === 'SetSubtitleStreamIndex') {
const index = asInteger(args.Index);
if (index !== undefined) {
deps.sendMpvCommand(client, ['set_property', 'sid', index < 0 ? 'no' : index]);
const playback = deps.getActivePlayback();
if (playback) {
playback.subtitleStreamIndex = index < 0 ? null : index;
}
await deps.reportJellyfinRemoteProgress(true);
}
return;
}
deps.logDebug(`Ignoring unsupported Jellyfin GeneralCommand: ${command}`);
};
}

View File

@@ -0,0 +1,102 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createEnsureMpvConnectedForJellyfinPlaybackHandler,
createLaunchMpvIdleForJellyfinPlaybackHandler,
createWaitForMpvConnectedHandler,
} from './jellyfin-remote-connection';
test('createWaitForMpvConnectedHandler connects and waits for readiness', async () => {
let connected = false;
let nowMs = 0;
const waitForConnected = createWaitForMpvConnectedHandler({
getMpvClient: () => ({
connected,
connect: () => {
connected = true;
},
}),
now: () => nowMs,
sleep: async () => {
nowMs += 100;
},
});
const ready = await waitForConnected(500);
assert.equal(ready, true);
});
test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', () => {
const spawnedArgs: string[][] = [];
const logs: string[] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getSocketPath: () => '/tmp/subminer.sock',
platform: 'darwin',
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
defaultMpvLogPath: '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
spawnedArgs.push(args);
return {
on: () => {},
unref: () => {},
};
},
logWarn: (message) => logs.push(message),
logInfo: (message) => logs.push(message),
});
launch();
assert.equal(spawnedArgs.length, 1);
assert.ok(spawnedArgs[0].includes('--idle=yes'));
assert.ok(spawnedArgs[0].some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock')));
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));
});
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
let autoLaunchInFlight: Promise<boolean> | null = null;
let launchCalls = 0;
let waitCalls = 0;
let mpvClient: { connected: boolean; connect: () => void } | null = null;
let resolveAutoLaunchPromise: (value: boolean) => void = () => {};
const autoLaunchPromise = new Promise<boolean>((resolve) => {
resolveAutoLaunchPromise = resolve;
});
const ensureConnected = createEnsureMpvConnectedForJellyfinPlaybackHandler({
getMpvClient: () => mpvClient,
setMpvClient: (client) => {
mpvClient = client;
},
createMpvClient: () => ({
connected: false,
connect: () => {},
}),
waitForMpvConnected: async (timeoutMs) => {
waitCalls += 1;
if (timeoutMs === 3000) return false;
return await autoLaunchPromise;
},
launchMpvIdleForJellyfinPlayback: () => {
launchCalls += 1;
},
getAutoLaunchInFlight: () => autoLaunchInFlight,
setAutoLaunchInFlight: (promise) => {
autoLaunchInFlight = promise;
},
connectTimeoutMs: 3000,
autoLaunchTimeoutMs: 20000,
});
const firstPromise = ensureConnected();
const secondPromise = ensureConnected();
resolveAutoLaunchPromise(true);
const first = await firstPromise;
const second = await secondPromise;
assert.equal(first, true);
assert.equal(second, true);
assert.equal(launchCalls, 1);
assert.equal(waitCalls >= 2, true);
});

View File

@@ -0,0 +1,108 @@
type MpvClientLike = {
connected: boolean;
connect: () => void;
};
type SpawnedProcessLike = {
on: (event: 'error', listener: (error: unknown) => void) => void;
unref: () => void;
};
export type WaitForMpvConnectedDeps = {
getMpvClient: () => MpvClientLike | null;
now: () => number;
sleep: (delayMs: number) => Promise<void>;
};
export function createWaitForMpvConnectedHandler(deps: WaitForMpvConnectedDeps) {
return async (timeoutMs = 7000): Promise<boolean> => {
const client = deps.getMpvClient();
if (!client) return false;
if (client.connected) return true;
try {
client.connect();
} catch {}
const startedAt = deps.now();
while (deps.now() - startedAt < timeoutMs) {
if (deps.getMpvClient()?.connected) return true;
await deps.sleep(100);
}
return Boolean(deps.getMpvClient()?.connected);
};
}
export type LaunchMpvForJellyfinDeps = {
getSocketPath: () => string;
platform: NodeJS.Platform;
execPath: string;
defaultMpvLogPath: string;
defaultMpvArgs: readonly string[];
removeSocketPath: (socketPath: string) => void;
spawnMpv: (args: string[]) => SpawnedProcessLike;
logWarn: (message: string, error: unknown) => void;
logInfo: (message: string) => void;
};
export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvForJellyfinDeps) {
return (): void => {
const socketPath = deps.getSocketPath();
if (deps.platform !== 'win32') {
try {
deps.removeSocketPath(socketPath);
} catch {
// ignore stale socket cleanup errors
}
}
const scriptOpts = `--script-opts=subminer-binary_path=${deps.execPath},subminer-socket_path=${socketPath}`;
const mpvArgs = [
...deps.defaultMpvArgs,
'--idle=yes',
scriptOpts,
`--log-file=${deps.defaultMpvLogPath}`,
`--input-ipc-server=${socketPath}`,
];
const proc = deps.spawnMpv(mpvArgs);
proc.on('error', (error) => {
deps.logWarn('Failed to launch mpv for Jellyfin remote playback', error);
});
proc.unref();
deps.logInfo(`Launched mpv for Jellyfin playback on socket: ${socketPath}`);
};
}
export type EnsureMpvConnectedDeps = {
getMpvClient: () => MpvClientLike | null;
setMpvClient: (client: MpvClientLike | null) => void;
createMpvClient: () => MpvClientLike;
waitForMpvConnected: (timeoutMs: number) => Promise<boolean>;
launchMpvIdleForJellyfinPlayback: () => void;
getAutoLaunchInFlight: () => Promise<boolean> | null;
setAutoLaunchInFlight: (promise: Promise<boolean> | null) => void;
connectTimeoutMs: number;
autoLaunchTimeoutMs: number;
};
export function createEnsureMpvConnectedForJellyfinPlaybackHandler(deps: EnsureMpvConnectedDeps) {
return async (): Promise<boolean> => {
if (!deps.getMpvClient()) {
deps.setMpvClient(deps.createMpvClient());
}
const connected = await deps.waitForMpvConnected(deps.connectTimeoutMs);
if (connected) return true;
if (!deps.getAutoLaunchInFlight()) {
const inFlight = (async () => {
deps.launchMpvIdleForJellyfinPlayback();
return deps.waitForMpvConnected(deps.autoLaunchTimeoutMs);
})().finally(() => {
deps.setAutoLaunchInFlight(null);
});
deps.setAutoLaunchInFlight(inFlight);
}
return deps.getAutoLaunchInFlight() as Promise<boolean>;
};
}

View File

@@ -0,0 +1,121 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler,
secondsToJellyfinTicks,
} from './jellyfin-remote-playback';
test('secondsToJellyfinTicks converts seconds and clamps invalid values', () => {
assert.equal(secondsToJellyfinTicks(1.25, 10_000_000), 12_500_000);
assert.equal(secondsToJellyfinTicks(-3, 10_000_000), 0);
assert.equal(secondsToJellyfinTicks(Number.NaN, 10_000_000), 0);
});
test('createReportJellyfinRemoteProgressHandler reports playback progress', async () => {
let lastProgressAtMs = 0;
const reportPayloads: Array<{ itemId: string; positionTicks: number; isPaused: boolean }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => ({
itemId: 'item-1',
mediaSourceId: undefined,
playMethod: 'DirectPlay',
audioStreamIndex: 1,
subtitleStreamIndex: 2,
}),
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => true,
reportProgress: async (payload) => {
reportPayloads.push({
itemId: payload.itemId,
positionTicks: payload.positionTicks,
isPaused: payload.isPaused,
});
},
reportStopped: async () => {},
}),
getMpvClient: () => ({
requestProperty: async (name: string) => (name === 'time-pos' ? 2.5 : true),
}),
getNow: () => 5000,
getLastProgressAtMs: () => lastProgressAtMs,
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportProgress(true);
assert.deepEqual(reportPayloads, [
{
itemId: 'item-1',
positionTicks: 25_000_000,
isPaused: true,
},
]);
assert.equal(lastProgressAtMs, 5000);
});
test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => {
let called = false;
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
}),
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => true,
reportProgress: async () => {
called = true;
},
reportStopped: async () => {},
}),
getMpvClient: () => ({
requestProperty: async () => 1,
}),
getNow: () => 4000,
getLastProgressAtMs: () => 3500,
setLastProgressAtMs: () => {},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportProgress(false);
assert.equal(called, false);
});
test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback', async () => {
let cleared = false;
let stoppedItemId: string | null = null;
const reportStopped = createReportJellyfinRemoteStoppedHandler({
getActivePlayback: () => ({
itemId: 'item-2',
mediaSourceId: undefined,
playMethod: 'Transcode',
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
clearActivePlayback: () => {
cleared = true;
},
getSession: () => ({
isConnected: () => true,
reportProgress: async () => {},
reportStopped: async (payload) => {
stoppedItemId = payload.itemId;
},
}),
logDebug: () => {},
});
await reportStopped();
assert.equal(stoppedItemId, 'item-2');
assert.equal(cleared, true);
});

View File

@@ -0,0 +1,109 @@
import type { ActiveJellyfinRemotePlaybackState } from './jellyfin-remote-commands';
type JellyfinRemoteSessionLike = {
isConnected: () => boolean;
reportProgress: (payload: {
itemId: string;
mediaSourceId?: string;
positionTicks: number;
isPaused: boolean;
playMethod: 'DirectPlay' | 'Transcode';
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
eventName: 'timeupdate';
}) => Promise<unknown>;
reportStopped: (payload: {
itemId: string;
mediaSourceId?: string;
playMethod: 'DirectPlay' | 'Transcode';
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
eventName: 'stop';
}) => Promise<unknown>;
};
type MpvClientLike = {
requestProperty: (name: string) => Promise<unknown>;
};
export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number): number {
if (!Number.isFinite(seconds)) return 0;
return Math.max(0, Math.floor(seconds * ticksPerSecond));
}
export type JellyfinRemoteProgressReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void;
getSession: () => JellyfinRemoteSessionLike | null;
getMpvClient: () => MpvClientLike | null;
getNow: () => number;
getLastProgressAtMs: () => number;
setLastProgressAtMs: (value: number) => void;
progressIntervalMs: number;
ticksPerSecond: number;
logDebug: (message: string, error: unknown) => void;
};
export function createReportJellyfinRemoteProgressHandler(deps: JellyfinRemoteProgressReporterDeps) {
return async (force = false): Promise<void> => {
const playback = deps.getActivePlayback();
if (!playback) return;
const session = deps.getSession();
if (!session || !session.isConnected()) return;
const now = deps.getNow();
if (!force && now - deps.getLastProgressAtMs() < deps.progressIntervalMs) {
return;
}
try {
const mpvClient = deps.getMpvClient();
const position = await mpvClient?.requestProperty('time-pos');
const paused = await mpvClient?.requestProperty('pause');
await session.reportProgress({
itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId,
positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond),
isPaused: paused === true,
playMethod: playback.playMethod,
audioStreamIndex: playback.audioStreamIndex,
subtitleStreamIndex: playback.subtitleStreamIndex,
eventName: 'timeupdate',
});
deps.setLastProgressAtMs(now);
} catch (error) {
deps.logDebug('Failed to report Jellyfin remote progress', error);
}
};
}
export type JellyfinRemoteStoppedReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void;
getSession: () => JellyfinRemoteSessionLike | null;
logDebug: (message: string, error: unknown) => void;
};
export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) {
return async (): Promise<void> => {
const playback = deps.getActivePlayback();
if (!playback) return;
const session = deps.getSession();
if (!session || !session.isConnected()) {
deps.clearActivePlayback();
return;
}
try {
await session.reportStopped({
itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId,
playMethod: playback.playMethod,
audioStreamIndex: playback.audioStreamIndex,
subtitleStreamIndex: playback.subtitleStreamIndex,
eventName: 'stop',
});
} catch (error) {
deps.logDebug('Failed to report Jellyfin remote stop', error);
} finally {
deps.clearActivePlayback();
}
};
}

View File

@@ -0,0 +1,119 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createCriticalConfigErrorHandler,
createReloadConfigHandler,
} from './startup-config';
test('createReloadConfigHandler runs success flow with warnings', async () => {
const calls: string[] = [];
const refreshCalls: { force: boolean }[] = [];
const reloadConfig = createReloadConfigHandler({
reloadConfigStrict: () => ({
ok: true,
path: '/tmp/config.jsonc',
warnings: [
{
path: 'ankiConnect.pollingRate',
message: 'must be >= 50',
value: 10,
fallback: 250,
},
],
}),
logInfo: (message) => calls.push(`info:${message}`),
logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('hotReload:start'),
refreshAnilistClientSecretState: async (options) => {
refreshCalls.push(options);
},
failHandlers: {
logError: (details) => calls.push(`error:${details}`),
showErrorBox: (title, details) => calls.push(`dialog:${title}:${details}`),
quit: () => calls.push('quit'),
},
});
reloadConfig();
await Promise.resolve();
assert.ok(calls.some((entry) => entry.startsWith('info:Using config file: /tmp/config.jsonc')));
assert.ok(calls.some((entry) => entry.startsWith('warn:[config] Validation found 1 issue(s)')));
assert.ok(
calls.some((entry) =>
entry.includes('notify:SubMiner:1 config validation issue(s) detected.'),
),
);
assert.ok(calls.some((entry) => entry.includes('1. ankiConnect.pollingRate: must be >= 50')));
assert.ok(calls.includes('hotReload:start'));
assert.deepEqual(refreshCalls, [{ force: true }]);
});
test('createReloadConfigHandler fails startup for parse errors', () => {
const calls: string[] = [];
const previousExitCode = process.exitCode;
process.exitCode = 0;
const reloadConfig = createReloadConfigHandler({
reloadConfigStrict: () => ({
ok: false,
path: '/tmp/config.jsonc',
error: 'unexpected token',
}),
logInfo: (message) => calls.push(`info:${message}`),
logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('hotReload:start'),
refreshAnilistClientSecretState: async () => {
calls.push('refresh');
},
failHandlers: {
logError: (details) => calls.push(`error:${details}`),
showErrorBox: (title, details) => calls.push(`dialog:${title}:${details}`),
quit: () => calls.push('quit'),
},
});
assert.throws(() => reloadConfig(), /Failed to parse config file at:/);
assert.equal(process.exitCode, 1);
assert.ok(calls.some((entry) => entry.startsWith('error:Failed to parse config file at:')));
assert.ok(
calls.some((entry) =>
entry.startsWith('dialog:SubMiner config parse error:Failed to parse config file at:'),
),
);
assert.ok(calls.includes('quit'));
assert.equal(calls.includes('hotReload:start'), false);
process.exitCode = previousExitCode;
});
test('createCriticalConfigErrorHandler formats and fails', () => {
const calls: string[] = [];
const previousExitCode = process.exitCode;
process.exitCode = 0;
const handleCriticalErrors = createCriticalConfigErrorHandler({
getConfigPath: () => '/tmp/config.jsonc',
failHandlers: {
logError: (details) => calls.push(`error:${details}`),
showErrorBox: (title, details) => calls.push(`dialog:${title}:${details}`),
quit: () => calls.push('quit'),
},
});
assert.throws(
() => handleCriticalErrors(['foo invalid', 'bar invalid']),
/Critical config validation failed/,
);
assert.equal(process.exitCode, 1);
assert.ok(calls.some((entry) => entry.includes('/tmp/config.jsonc')));
assert.ok(calls.some((entry) => entry.includes('1. foo invalid')));
assert.ok(calls.some((entry) => entry.includes('2. bar invalid')));
assert.ok(calls.includes('quit'));
process.exitCode = previousExitCode;
});

View File

@@ -0,0 +1,83 @@
import type { ConfigValidationWarning } from '../../types';
import {
buildConfigWarningNotificationBody,
buildConfigWarningSummary,
failStartupFromConfig,
} from '../config-validation';
type ReloadConfigFailure = {
ok: false;
path: string;
error: string;
};
type ReloadConfigSuccess = {
ok: true;
path: string;
warnings: ConfigValidationWarning[];
};
type ReloadConfigStrictResult = ReloadConfigFailure | ReloadConfigSuccess;
export type ReloadConfigRuntimeDeps = {
reloadConfigStrict: () => ReloadConfigStrictResult;
logInfo: (message: string) => void;
logWarning: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
startConfigHotReload: () => void;
refreshAnilistClientSecretState: (options: { force: boolean }) => Promise<unknown>;
failHandlers: {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
quit: () => void;
};
};
export type CriticalConfigErrorRuntimeDeps = {
getConfigPath: () => string;
failHandlers: {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
quit: () => void;
};
};
export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () => void {
return () => {
const result = deps.reloadConfigStrict();
if (!result.ok) {
failStartupFromConfig(
'SubMiner config parse error',
`Failed to parse config file at:\n${result.path}\n\nError: ${result.error}\n\nFix the config file and restart SubMiner.`,
deps.failHandlers,
);
}
deps.logInfo(`Using config file: ${result.path}`);
if (result.warnings.length > 0) {
deps.logWarning(buildConfigWarningSummary(result.path, result.warnings));
deps.showDesktopNotification('SubMiner', {
body: buildConfigWarningNotificationBody(result.path, result.warnings),
});
}
deps.startConfigHotReload();
void deps.refreshAnilistClientSecretState({ force: true });
};
}
export function createCriticalConfigErrorHandler(
deps: CriticalConfigErrorRuntimeDeps,
): (errors: string[]) => never {
return (errors: string[]) => {
const configPath = deps.getConfigPath();
const details = [
`Critical config validation failed. File: ${configPath}`,
'',
...errors.map((error, index) => `${index + 1}. ${error}`),
'',
'Fix the config file and restart SubMiner.',
].join('\n');
return failStartupFromConfig('SubMiner config validation error', details, deps.failHandlers);
};
}

View File

@@ -0,0 +1,37 @@
import type { MpvIpcClient } from '../../core/services';
import { runSubsyncManualFromIpcRuntime, triggerSubsyncFromConfigRuntime } from '../../core/services';
import type { SubsyncResult, SubsyncManualPayload, SubsyncManualRunRequest, ResolvedConfig } from '../../types';
import { getSubsyncConfig } from '../../subsync/utils';
import { createSubsyncRuntimeServiceInputFromState } from '../subsync-runtime';
export type MainSubsyncRuntimeDeps = {
getMpvClient: () => MpvIpcClient | null;
getResolvedConfig: () => ResolvedConfig;
getSubsyncInProgress: () => boolean;
setSubsyncInProgress: (inProgress: boolean) => void;
showMpvOsd: (text: string) => void;
openManualPicker: (payload: SubsyncManualPayload) => void;
};
export function createMainSubsyncRuntime(deps: MainSubsyncRuntimeDeps): {
triggerFromConfig: () => Promise<void>;
runManualFromIpc: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
} {
const getRuntimeServiceParams = () =>
createSubsyncRuntimeServiceInputFromState({
getMpvClient: () => deps.getMpvClient(),
getResolvedSubsyncConfig: () => getSubsyncConfig(deps.getResolvedConfig().subsync),
getSubsyncInProgress: () => deps.getSubsyncInProgress(),
setSubsyncInProgress: (inProgress: boolean) => deps.setSubsyncInProgress(inProgress),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
openManualPicker: (payload: SubsyncManualPayload) => deps.openManualPicker(payload),
});
return {
triggerFromConfig: async (): Promise<void> => {
await triggerSubsyncFromConfigRuntime(getRuntimeServiceParams());
},
runManualFromIpc: async (request: SubsyncManualRunRequest): Promise<SubsyncResult> =>
runSubsyncManualFromIpcRuntime(request, getRuntimeServiceParams()),
};
}