mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
refactor(main): modularize runtime and harden anilist setup flow
This commit is contained in:
43
AGENTS.md
43
AGENTS.md
@@ -27,46 +27,3 @@ You MUST read the overview resource to understand the complete workflow. The inf
|
||||
</CRITICAL_INSTRUCTION>
|
||||
|
||||
<!-- 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)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
project_name: 'SubMiner'
|
||||
default_status: 'To Do'
|
||||
statuses: ['To Do', 'In Progress', 'Done']
|
||||
project_name: "SubMiner"
|
||||
default_status: "To Do"
|
||||
statuses: ["To Do", "In Progress", "Done"]
|
||||
labels: []
|
||||
milestones: []
|
||||
date_format: yyyy-mm-dd
|
||||
max_column_width: 20
|
||||
default_editor: 'nvim'
|
||||
default_editor: "nvim"
|
||||
auto_open_browser: false
|
||||
default_port: 6420
|
||||
remote_operations: true
|
||||
@@ -13,4 +13,4 @@ auto_commit: false
|
||||
bypass_git_hooks: false
|
||||
check_active_branches: true
|
||||
active_branch_days: 30
|
||||
task_prefix: 'task'
|
||||
task_prefix: "task"
|
||||
|
||||
@@ -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 -->
|
||||
@@ -4,19 +4,19 @@ title: Add error boundary and recovery in renderer overlay
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-14 01:01'
|
||||
updated_date: '2026-02-19 21:50'
|
||||
updated_date: '2026-02-19 23:18'
|
||||
labels:
|
||||
- renderer
|
||||
- reliability
|
||||
- error-handling
|
||||
dependencies: []
|
||||
priority: medium
|
||||
ordinal: 60000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- 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.
|
||||
|
||||
## Motivation
|
||||
@@ -39,9 +39,7 @@ If a renderer modal throws (e.g., jimaku API timeout, DOM manipulation error, ma
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
|
||||
- [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] #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
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
- 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.
|
||||
- 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 test:core:dist`
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
@@ -5,13 +5,14 @@ status: Done
|
||||
assignee:
|
||||
- codex-main
|
||||
created_date: '2026-02-14 02:02'
|
||||
updated_date: '2026-02-19 08:21'
|
||||
updated_date: '2026-02-19 23:18'
|
||||
labels:
|
||||
- config
|
||||
- developer-experience
|
||||
- error-handling
|
||||
dependencies: []
|
||||
priority: medium
|
||||
ordinal: 66000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -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`
|
||||
@@ -4,7 +4,7 @@ title: Run Electron app as background tray service with IPC startup
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-18 08:48'
|
||||
updated_date: '2026-02-19 21:50'
|
||||
updated_date: '2026-02-19 23:18'
|
||||
labels:
|
||||
- electron
|
||||
- tray
|
||||
@@ -12,6 +12,7 @@ labels:
|
||||
- desktop-entry
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 61000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -4,7 +4,7 @@ title: Make wrapper stop auto-sending --start by default
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-18 09:47'
|
||||
updated_date: '2026-02-18 10:02'
|
||||
updated_date: '2026-02-19 23:18'
|
||||
labels:
|
||||
- launcher
|
||||
- wrapper
|
||||
@@ -12,6 +12,7 @@ labels:
|
||||
- background-mode
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 68000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -4,12 +4,13 @@ title: Allow trailing commas in JSONC config parsing
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-18 10:13'
|
||||
updated_date: '2026-02-18 10:13'
|
||||
updated_date: '2026-02-19 23:18'
|
||||
labels:
|
||||
- config
|
||||
- jsonc
|
||||
dependencies: []
|
||||
priority: medium
|
||||
ordinal: 67000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,13 +5,14 @@ status: Done
|
||||
assignee:
|
||||
- codex-main
|
||||
created_date: '2026-02-18 11:35'
|
||||
updated_date: '2026-02-19 08:27'
|
||||
updated_date: '2026-02-19 23:18'
|
||||
labels:
|
||||
- config
|
||||
- validation
|
||||
- safety
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 65000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,13 +5,14 @@ status: Done
|
||||
assignee:
|
||||
- codex-main
|
||||
created_date: '2026-02-18 11:35'
|
||||
updated_date: '2026-02-19 09:05'
|
||||
updated_date: '2026-02-19 23:18'
|
||||
labels:
|
||||
- config
|
||||
- launcher
|
||||
- consistency
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 63000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,13 +5,14 @@ status: Done
|
||||
assignee:
|
||||
- codex-main
|
||||
created_date: '2026-02-19 08:38'
|
||||
updated_date: '2026-02-19 08:40'
|
||||
updated_date: '2026-02-19 23:18'
|
||||
labels:
|
||||
- config
|
||||
- anki
|
||||
- cleanup
|
||||
dependencies: []
|
||||
priority: medium
|
||||
ordinal: 64000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -5,26 +5,27 @@
|
||||
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Overlay Auto-Start
|
||||
// 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
|
||||
// Control whether visible overlay toggles also toggle MPV subtitle visibility.
|
||||
// When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.
|
||||
// ==========================================
|
||||
"bind_visible_overlay_to_mpv_sub_visibility": true,
|
||||
"bind_visible_overlay_to_mpv_sub_visibility": true, // Link visible overlay toggles to MPV subtitle visibility (primary and secondary). Values: true | false
|
||||
|
||||
// ==========================================
|
||||
// Texthooker Server
|
||||
// Control whether browser opens automatically for texthooker.
|
||||
// ==========================================
|
||||
"texthooker": {
|
||||
"openBrowser": true,
|
||||
},
|
||||
"openBrowser": true // Open browser setting. Values: true | false
|
||||
}, // Control whether browser opens automatically for texthooker.
|
||||
|
||||
// ==========================================
|
||||
// WebSocket Server
|
||||
@@ -32,9 +33,9 @@
|
||||
// Auto mode disables built-in server if mpv_websocket is detected.
|
||||
// ==========================================
|
||||
"websocket": {
|
||||
"enabled": "auto",
|
||||
"port": 6677,
|
||||
},
|
||||
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
||||
"port": 6677 // Built-in subtitle websocket server port.
|
||||
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
||||
|
||||
// ==========================================
|
||||
// Logging
|
||||
@@ -42,8 +43,8 @@
|
||||
// Set to debug for full runtime diagnostics.
|
||||
// ==========================================
|
||||
"logging": {
|
||||
"level": "info",
|
||||
},
|
||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||
}, // Controls logging verbosity.
|
||||
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
@@ -52,93 +53,93 @@
|
||||
// Most other AnkiConnect settings still require restart.
|
||||
// ==========================================
|
||||
"ankiConnect": {
|
||||
"enabled": false,
|
||||
"url": "http://127.0.0.1:8765",
|
||||
"pollingRate": 3000,
|
||||
"tags": ["SubMiner"],
|
||||
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
||||
"url": "http://127.0.0.1:8765", // Url setting.
|
||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||
"tags": [
|
||||
"SubMiner"
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"fields": {
|
||||
"audio": "ExpressionAudio",
|
||||
"image": "Picture",
|
||||
"sentence": "Sentence",
|
||||
"miscInfo": "MiscInfo",
|
||||
"translation": "SelectionText",
|
||||
},
|
||||
"audio": "ExpressionAudio", // Audio setting.
|
||||
"image": "Picture", // Image setting.
|
||||
"sentence": "Sentence", // Sentence setting.
|
||||
"miscInfo": "MiscInfo", // Misc info setting.
|
||||
"translation": "SelectionText" // Translation setting.
|
||||
}, // Fields setting.
|
||||
"ai": {
|
||||
"enabled": false,
|
||||
"alwaysUseAiTranslation": false,
|
||||
"apiKey": "",
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"baseUrl": "https://openrouter.ai/api",
|
||||
"targetLanguage": "English",
|
||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.",
|
||||
},
|
||||
"enabled": false, // Enabled setting. Values: true | false
|
||||
"alwaysUseAiTranslation": false, // Always use ai translation setting. Values: true | false
|
||||
"apiKey": "", // Api key setting.
|
||||
"model": "openai/gpt-4o-mini", // Model setting.
|
||||
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
||||
"targetLanguage": "English", // Target language setting.
|
||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting.
|
||||
}, // Ai setting.
|
||||
"media": {
|
||||
"generateAudio": true,
|
||||
"generateImage": true,
|
||||
"imageType": "static",
|
||||
"imageFormat": "jpg",
|
||||
"imageQuality": 92,
|
||||
"animatedFps": 10,
|
||||
"animatedMaxWidth": 640,
|
||||
"animatedCrf": 35,
|
||||
"audioPadding": 0.5,
|
||||
"fallbackDuration": 3,
|
||||
"maxMediaDuration": 30,
|
||||
},
|
||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
||||
"generateImage": true, // Generate image setting. Values: true | false
|
||||
"imageType": "static", // Image type setting.
|
||||
"imageFormat": "jpg", // Image format setting.
|
||||
"imageQuality": 92, // Image quality setting.
|
||||
"animatedFps": 10, // Animated fps setting.
|
||||
"animatedMaxWidth": 640, // Animated max width setting.
|
||||
"animatedCrf": 35, // Animated crf setting.
|
||||
"audioPadding": 0.5, // Audio padding setting.
|
||||
"fallbackDuration": 3, // Fallback duration setting.
|
||||
"maxMediaDuration": 30 // Max media duration setting.
|
||||
}, // Media setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true,
|
||||
"overwriteImage": true,
|
||||
"mediaInsertMode": "append",
|
||||
"highlightWord": true,
|
||||
"notificationType": "osd",
|
||||
"autoUpdateNewCards": true,
|
||||
},
|
||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
||||
"overwriteImage": true, // Overwrite image setting. Values: true | false
|
||||
"mediaInsertMode": "append", // Media insert mode setting.
|
||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
||||
"notificationType": "osd", // Notification type setting.
|
||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||
}, // Behavior setting.
|
||||
"nPlusOne": {
|
||||
"highlightEnabled": false,
|
||||
"refreshMinutes": 1440,
|
||||
"matchMode": "headword",
|
||||
"decks": [],
|
||||
"minSentenceWords": 3,
|
||||
"nPlusOne": "#c6a0f6",
|
||||
"knownWord": "#a6da95",
|
||||
},
|
||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"matchMode": "headword", // Known-word matching strategy for N+1 highlighting. Values: headword | surface
|
||||
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
|
||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
|
||||
"knownWord": "#a6da95" // Color used for legacy known-word highlights.
|
||||
}, // N plus one setting.
|
||||
"metadata": {
|
||||
"pattern": "[SubMiner] %f (%t)",
|
||||
},
|
||||
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
||||
}, // Metadata setting.
|
||||
"isLapis": {
|
||||
"enabled": false,
|
||||
"sentenceCardModel": "Japanese sentences",
|
||||
},
|
||||
"enabled": false, // Enabled setting. Values: true | false
|
||||
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
||||
}, // Is lapis setting.
|
||||
"isKiku": {
|
||||
"enabled": false,
|
||||
"fieldGrouping": "disabled",
|
||||
"deleteDuplicateInAuto": true,
|
||||
},
|
||||
},
|
||||
"enabled": false, // Enabled setting. Values: true | false
|
||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
||||
} // Is kiku setting.
|
||||
}, // Automatic Anki updates and media generation options.
|
||||
|
||||
// ==========================================
|
||||
// Keyboard Shortcuts
|
||||
// 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.
|
||||
// ==========================================
|
||||
"shortcuts": {
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
|
||||
"copySubtitle": "CommandOrControl+C",
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C",
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V",
|
||||
"triggerFieldGrouping": "CommandOrControl+G",
|
||||
"triggerSubsync": "Ctrl+Alt+S",
|
||||
"mineSentence": "CommandOrControl+S",
|
||||
"mineSentenceMultiple": "CommandOrControl+Shift+S",
|
||||
"multiCopyTimeoutMs": 3000,
|
||||
"toggleSecondarySub": "CommandOrControl+Shift+V",
|
||||
"markAudioCard": "CommandOrControl+Shift+A",
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O",
|
||||
"openJimaku": "Ctrl+Shift+J",
|
||||
},
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
|
||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
||||
"triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting.
|
||||
"triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting.
|
||||
"mineSentence": "CommandOrControl+S", // Mine sentence setting.
|
||||
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting.
|
||||
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
||||
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
// Invisible Overlay
|
||||
@@ -147,8 +148,8 @@
|
||||
// This edit-mode shortcut is fixed and is not currently configurable.
|
||||
// ==========================================
|
||||
"invisibleOverlay": {
|
||||
"startupVisibility": "platform-default",
|
||||
},
|
||||
"startupVisibility": "platform-default" // Startup visibility setting.
|
||||
}, // Startup behavior for the invisible interactive subtitle mining layer.
|
||||
|
||||
// ==========================================
|
||||
// Keybindings (MPV Commands)
|
||||
@@ -156,7 +157,7 @@
|
||||
// Set command to null to disable a default keybinding.
|
||||
// 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
|
||||
@@ -164,39 +165,45 @@
|
||||
// Hot-reload: subtitle style changes apply live without restarting SubMiner.
|
||||
// ==========================================
|
||||
"subtitleStyle": {
|
||||
"enableJlpt": false,
|
||||
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
|
||||
"fontSize": 35,
|
||||
"fontColor": "#cad3f5",
|
||||
"fontWeight": "normal",
|
||||
"fontStyle": "normal",
|
||||
"backgroundColor": "rgba(54, 58, 79, 0.5)",
|
||||
"nPlusOneColor": "#c6a0f6",
|
||||
"knownWordColor": "#a6da95",
|
||||
"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", // Font family setting.
|
||||
"fontSize": 35, // Font size setting.
|
||||
"fontColor": "#cad3f5", // Font color setting.
|
||||
"fontWeight": "normal", // Font weight setting.
|
||||
"fontStyle": "normal", // Font style setting.
|
||||
"backgroundColor": "rgba(54, 58, 79, 0.5)", // Background color setting.
|
||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
||||
"knownWordColor": "#a6da95", // Known word color setting.
|
||||
"jlptColors": {
|
||||
"N1": "#ed8796",
|
||||
"N2": "#f5a97f",
|
||||
"N3": "#f9e2af",
|
||||
"N4": "#a6e3a1",
|
||||
"N5": "#8aadf4",
|
||||
},
|
||||
"N1": "#ed8796", // N1 setting.
|
||||
"N2": "#f5a97f", // N2 setting.
|
||||
"N3": "#f9e2af", // N3 setting.
|
||||
"N4": "#a6e3a1", // N4 setting.
|
||||
"N5": "#8aadf4" // N5 setting.
|
||||
}, // Jlpt colors setting.
|
||||
"frequencyDictionary": {
|
||||
"enabled": false,
|
||||
"sourcePath": "",
|
||||
"topX": 1000,
|
||||
"mode": "single",
|
||||
"singleColor": "#f5a97f",
|
||||
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"],
|
||||
},
|
||||
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
||||
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used.
|
||||
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
|
||||
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
|
||||
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
||||
"bandedColors": [
|
||||
"#ed8796",
|
||||
"#f5a97f",
|
||||
"#f9e2af",
|
||||
"#a6e3a1",
|
||||
"#8aadf4"
|
||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||
}, // Frequency dictionary setting.
|
||||
"secondary": {
|
||||
"fontSize": 24,
|
||||
"fontColor": "#ffffff",
|
||||
"backgroundColor": "transparent",
|
||||
"fontWeight": "normal",
|
||||
"fontStyle": "normal",
|
||||
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
|
||||
},
|
||||
},
|
||||
"fontSize": 24, // Font size setting.
|
||||
"fontColor": "#ffffff", // Font color setting.
|
||||
"backgroundColor": "transparent", // Background color setting.
|
||||
"fontWeight": "normal", // Font weight setting.
|
||||
"fontStyle": "normal", // Font style setting.
|
||||
"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
|
||||
@@ -205,59 +212,62 @@
|
||||
// Hot-reload: defaultMode updates live while SubMiner is running.
|
||||
// ==========================================
|
||||
"secondarySub": {
|
||||
"secondarySubLanguages": [],
|
||||
"autoLoadSecondarySub": false,
|
||||
"defaultMode": "hover",
|
||||
},
|
||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
||||
"defaultMode": "hover" // Default mode setting.
|
||||
}, // Dual subtitle track options.
|
||||
|
||||
// ==========================================
|
||||
// Auto Subtitle Sync
|
||||
// Subsync engine and executable paths.
|
||||
// ==========================================
|
||||
"subsync": {
|
||||
"defaultMode": "auto",
|
||||
"alass_path": "",
|
||||
"ffsubsync_path": "",
|
||||
"ffmpeg_path": "",
|
||||
},
|
||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
||||
"alass_path": "", // Alass path setting.
|
||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||
"ffmpeg_path": "" // Ffmpeg path setting.
|
||||
}, // Subsync engine and executable paths.
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Position
|
||||
// Initial vertical subtitle position from the bottom.
|
||||
// ==========================================
|
||||
"subtitlePosition": {
|
||||
"yPercent": 10,
|
||||
},
|
||||
"yPercent": 10 // Y percent setting.
|
||||
}, // Initial vertical subtitle position from the bottom.
|
||||
|
||||
// ==========================================
|
||||
// Jimaku
|
||||
// Jimaku API configuration and defaults.
|
||||
// ==========================================
|
||||
"jimaku": {
|
||||
"apiBaseUrl": "https://jimaku.cc",
|
||||
"languagePreference": "ja",
|
||||
"maxEntryResults": 10,
|
||||
},
|
||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||
}, // Jimaku API configuration and defaults.
|
||||
|
||||
// ==========================================
|
||||
// YouTube Subtitle Generation
|
||||
// Defaults for subminer YouTube subtitle extraction/transcription mode.
|
||||
// ==========================================
|
||||
"youtubeSubgen": {
|
||||
"mode": "automatic",
|
||||
"whisperBin": "",
|
||||
"whisperModel": "",
|
||||
"primarySubLanguages": ["ja", "jpn"],
|
||||
},
|
||||
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
|
||||
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
|
||||
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
||||
"primarySubLanguages": [
|
||||
"ja",
|
||||
"jpn"
|
||||
] // Comma-separated primary subtitle language priority used by the launcher.
|
||||
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
||||
|
||||
// ==========================================
|
||||
// Anilist
|
||||
// Anilist API credentials and update behavior.
|
||||
// ==========================================
|
||||
"anilist": {
|
||||
"enabled": false,
|
||||
"accessToken": "",
|
||||
},
|
||||
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
||||
"accessToken": "" // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
||||
}, // Anilist API credentials and update behavior.
|
||||
|
||||
// ==========================================
|
||||
// Jellyfin
|
||||
@@ -265,25 +275,33 @@
|
||||
// Access token is stored in config and should be treated as a secret.
|
||||
// ==========================================
|
||||
"jellyfin": {
|
||||
"enabled": false,
|
||||
"serverUrl": "",
|
||||
"username": "",
|
||||
"accessToken": "",
|
||||
"userId": "",
|
||||
"deviceId": "subminer",
|
||||
"clientName": "SubMiner",
|
||||
"clientVersion": "0.1.0",
|
||||
"defaultLibraryId": "",
|
||||
"remoteControlEnabled": true,
|
||||
"remoteControlAutoConnect": true,
|
||||
"autoAnnounce": false,
|
||||
"remoteControlDeviceName": "SubMiner",
|
||||
"pullPictures": false,
|
||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons",
|
||||
"directPlayPreferred": true,
|
||||
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
|
||||
"transcodeVideoCodec": "h264",
|
||||
},
|
||||
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
|
||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||
"username": "", // Default Jellyfin username used during CLI login.
|
||||
"accessToken": "", // Access token setting.
|
||||
"userId": "", // User id setting.
|
||||
"deviceId": "subminer", // Device id setting.
|
||||
"clientName": "SubMiner", // Client name setting.
|
||||
"clientVersion": "0.1.0", // Client version setting.
|
||||
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
||||
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
|
||||
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
|
||||
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
||||
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||
"directPlayContainers": [
|
||||
"mkv",
|
||||
"mp4",
|
||||
"webm",
|
||||
"mov",
|
||||
"flac",
|
||||
"mp3",
|
||||
"aac"
|
||||
], // Container allowlist for direct play decisions.
|
||||
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
|
||||
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||
|
||||
// ==========================================
|
||||
// Immersion Tracking
|
||||
@@ -292,19 +310,19 @@
|
||||
// Policy tuning is available for queue, flush, and retention values.
|
||||
// ==========================================
|
||||
"immersionTracking": {
|
||||
"enabled": true,
|
||||
"dbPath": "",
|
||||
"batchSize": 25,
|
||||
"flushIntervalMs": 500,
|
||||
"queueCap": 1000,
|
||||
"payloadCapBytes": 256,
|
||||
"maintenanceIntervalMs": 86400000,
|
||||
"enabled": true, // Enable immersion tracking for mined subtitle metadata. Values: true | false
|
||||
"dbPath": "", // Optional SQLite database path for immersion tracking. Empty value uses the default app data path.
|
||||
"batchSize": 25, // Buffered telemetry/event writes per SQLite transaction.
|
||||
"flushIntervalMs": 500, // Max delay before queue flush in milliseconds.
|
||||
"queueCap": 1000, // In-memory write queue cap before overflow policy applies.
|
||||
"payloadCapBytes": 256, // Max JSON payload size per event before truncation.
|
||||
"maintenanceIntervalMs": 86400000, // Maintenance cadence (prune + rollup + vacuum checks).
|
||||
"retention": {
|
||||
"eventsDays": 7,
|
||||
"telemetryDays": 30,
|
||||
"dailyRollupsDays": 365,
|
||||
"monthlyRollupsDays": 1825,
|
||||
"vacuumIntervalDays": 7,
|
||||
},
|
||||
},
|
||||
"eventsDays": 7, // Raw event retention window in days.
|
||||
"telemetryDays": 30, // Telemetry retention window in days.
|
||||
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
||||
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
||||
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
|
||||
} // Retention setting.
|
||||
} // Enable/disable immersion tracking.
|
||||
}
|
||||
|
||||
@@ -71,9 +71,11 @@ export default {
|
||||
text: 'Reference',
|
||||
items: [
|
||||
{ text: 'Configuration', link: '/configuration' },
|
||||
{ text: 'Immersion Tracking', link: '/immersion-tracking' },
|
||||
{ text: 'Keyboard Shortcuts', link: '/shortcuts' },
|
||||
{ text: 'Anki Integration', link: '/anki-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: 'Troubleshooting', link: '/troubleshooting' },
|
||||
],
|
||||
|
||||
@@ -15,15 +15,17 @@ make docs-preview # Preview built site at http://localhost:4173
|
||||
### Getting Started
|
||||
|
||||
- [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
|
||||
|
||||
### Reference
|
||||
|
||||
- [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
|
||||
- [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
|
||||
- [Troubleshooting](/troubleshooting) — Common issues and solutions by category
|
||||
|
||||
|
||||
@@ -413,13 +413,13 @@ Set `openBrowser` to `false` to only print the URL without opening a browser.
|
||||
|
||||
### 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
|
||||
{
|
||||
"anilist": {
|
||||
"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 |
|
||||
| ------------- | --------------- | ----------------------------------------------------------------------------------- |
|
||||
| `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.
|
||||
|
||||
@@ -442,14 +442,13 @@ Current post-watch behavior:
|
||||
Setup flow details:
|
||||
|
||||
1. Set `anilist.enabled` to `true`.
|
||||
2. Leave `anilist.accessToken` empty and restart SubMiner to trigger setup.
|
||||
3. Approve access in AniList (browser window or system browser fallback).
|
||||
4. Copy the returned token and paste it into `anilist.accessToken`.
|
||||
5. Save config and restart SubMiner.
|
||||
2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
|
||||
3. Approve access in AniList.
|
||||
4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically.
|
||||
|
||||
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`.
|
||||
- When `guessit` cannot parse or is missing, SubMiner falls back automatically to internal filename parsing.
|
||||
|
||||
|
||||
21
docs/file-size-budgets.md
Normal file
21
docs/file-size-budgets.md
Normal 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.
|
||||
@@ -39,17 +39,17 @@ features:
|
||||
src: /assets/dual-layer.svg
|
||||
alt: Dual layer icon
|
||||
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:
|
||||
src: /assets/highlight.svg
|
||||
alt: Highlight icon
|
||||
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.
|
||||
- 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.
|
||||
src: /assets/tokenization.svg
|
||||
alt: Tokenization icon
|
||||
title: Immersion Tracking
|
||||
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:
|
||||
src: /assets/subtitle-download.svg
|
||||
alt: Subtitle download icon
|
||||
@@ -60,6 +60,11 @@ features:
|
||||
alt: Keyboard icon
|
||||
title: Keyboard-Driven
|
||||
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>
|
||||
@@ -104,14 +109,20 @@ features:
|
||||
|
||||
.workflow-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 960px) {
|
||||
.workflow-steps {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.workflow-steps {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -158,7 +169,7 @@ features:
|
||||
<div class="workflow-step">
|
||||
<div class="step-number">02</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 class="workflow-step">
|
||||
<div class="step-number">03</div>
|
||||
@@ -170,6 +181,11 @@ features:
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -5,26 +5,27 @@
|
||||
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Overlay Auto-Start
|
||||
// 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
|
||||
// Control whether visible overlay toggles also toggle MPV subtitle visibility.
|
||||
// When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.
|
||||
// ==========================================
|
||||
"bind_visible_overlay_to_mpv_sub_visibility": true,
|
||||
"bind_visible_overlay_to_mpv_sub_visibility": true, // Link visible overlay toggles to MPV subtitle visibility (primary and secondary). Values: true | false
|
||||
|
||||
// ==========================================
|
||||
// Texthooker Server
|
||||
// Control whether browser opens automatically for texthooker.
|
||||
// ==========================================
|
||||
"texthooker": {
|
||||
"openBrowser": true,
|
||||
},
|
||||
"openBrowser": true // Open browser setting. Values: true | false
|
||||
}, // Control whether browser opens automatically for texthooker.
|
||||
|
||||
// ==========================================
|
||||
// WebSocket Server
|
||||
@@ -32,9 +33,9 @@
|
||||
// Auto mode disables built-in server if mpv_websocket is detected.
|
||||
// ==========================================
|
||||
"websocket": {
|
||||
"enabled": "auto",
|
||||
"port": 6677,
|
||||
},
|
||||
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
||||
"port": 6677 // Built-in subtitle websocket server port.
|
||||
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
||||
|
||||
// ==========================================
|
||||
// Logging
|
||||
@@ -42,8 +43,8 @@
|
||||
// Set to debug for full runtime diagnostics.
|
||||
// ==========================================
|
||||
"logging": {
|
||||
"level": "info",
|
||||
},
|
||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||
}, // Controls logging verbosity.
|
||||
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
@@ -52,93 +53,93 @@
|
||||
// Most other AnkiConnect settings still require restart.
|
||||
// ==========================================
|
||||
"ankiConnect": {
|
||||
"enabled": false,
|
||||
"url": "http://127.0.0.1:8765",
|
||||
"pollingRate": 3000,
|
||||
"tags": ["SubMiner"],
|
||||
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
||||
"url": "http://127.0.0.1:8765", // Url setting.
|
||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||
"tags": [
|
||||
"SubMiner"
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"fields": {
|
||||
"audio": "ExpressionAudio",
|
||||
"image": "Picture",
|
||||
"sentence": "Sentence",
|
||||
"miscInfo": "MiscInfo",
|
||||
"translation": "SelectionText",
|
||||
},
|
||||
"audio": "ExpressionAudio", // Audio setting.
|
||||
"image": "Picture", // Image setting.
|
||||
"sentence": "Sentence", // Sentence setting.
|
||||
"miscInfo": "MiscInfo", // Misc info setting.
|
||||
"translation": "SelectionText" // Translation setting.
|
||||
}, // Fields setting.
|
||||
"ai": {
|
||||
"enabled": false,
|
||||
"alwaysUseAiTranslation": false,
|
||||
"apiKey": "",
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"baseUrl": "https://openrouter.ai/api",
|
||||
"targetLanguage": "English",
|
||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.",
|
||||
},
|
||||
"enabled": false, // Enabled setting. Values: true | false
|
||||
"alwaysUseAiTranslation": false, // Always use ai translation setting. Values: true | false
|
||||
"apiKey": "", // Api key setting.
|
||||
"model": "openai/gpt-4o-mini", // Model setting.
|
||||
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
||||
"targetLanguage": "English", // Target language setting.
|
||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting.
|
||||
}, // Ai setting.
|
||||
"media": {
|
||||
"generateAudio": true,
|
||||
"generateImage": true,
|
||||
"imageType": "static",
|
||||
"imageFormat": "jpg",
|
||||
"imageQuality": 92,
|
||||
"animatedFps": 10,
|
||||
"animatedMaxWidth": 640,
|
||||
"animatedCrf": 35,
|
||||
"audioPadding": 0.5,
|
||||
"fallbackDuration": 3,
|
||||
"maxMediaDuration": 30,
|
||||
},
|
||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
||||
"generateImage": true, // Generate image setting. Values: true | false
|
||||
"imageType": "static", // Image type setting.
|
||||
"imageFormat": "jpg", // Image format setting.
|
||||
"imageQuality": 92, // Image quality setting.
|
||||
"animatedFps": 10, // Animated fps setting.
|
||||
"animatedMaxWidth": 640, // Animated max width setting.
|
||||
"animatedCrf": 35, // Animated crf setting.
|
||||
"audioPadding": 0.5, // Audio padding setting.
|
||||
"fallbackDuration": 3, // Fallback duration setting.
|
||||
"maxMediaDuration": 30 // Max media duration setting.
|
||||
}, // Media setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true,
|
||||
"overwriteImage": true,
|
||||
"mediaInsertMode": "append",
|
||||
"highlightWord": true,
|
||||
"notificationType": "osd",
|
||||
"autoUpdateNewCards": true,
|
||||
},
|
||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
||||
"overwriteImage": true, // Overwrite image setting. Values: true | false
|
||||
"mediaInsertMode": "append", // Media insert mode setting.
|
||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
||||
"notificationType": "osd", // Notification type setting.
|
||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||
}, // Behavior setting.
|
||||
"nPlusOne": {
|
||||
"highlightEnabled": false,
|
||||
"refreshMinutes": 1440,
|
||||
"matchMode": "headword",
|
||||
"decks": [],
|
||||
"minSentenceWords": 3,
|
||||
"nPlusOne": "#c6a0f6",
|
||||
"knownWord": "#a6da95",
|
||||
},
|
||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"matchMode": "headword", // Known-word matching strategy for N+1 highlighting. Values: headword | surface
|
||||
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
|
||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
|
||||
"knownWord": "#a6da95" // Color used for legacy known-word highlights.
|
||||
}, // N plus one setting.
|
||||
"metadata": {
|
||||
"pattern": "[SubMiner] %f (%t)",
|
||||
},
|
||||
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
||||
}, // Metadata setting.
|
||||
"isLapis": {
|
||||
"enabled": false,
|
||||
"sentenceCardModel": "Japanese sentences",
|
||||
},
|
||||
"enabled": false, // Enabled setting. Values: true | false
|
||||
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
||||
}, // Is lapis setting.
|
||||
"isKiku": {
|
||||
"enabled": false,
|
||||
"fieldGrouping": "disabled",
|
||||
"deleteDuplicateInAuto": true,
|
||||
},
|
||||
},
|
||||
"enabled": false, // Enabled setting. Values: true | false
|
||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
||||
} // Is kiku setting.
|
||||
}, // Automatic Anki updates and media generation options.
|
||||
|
||||
// ==========================================
|
||||
// Keyboard Shortcuts
|
||||
// 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.
|
||||
// ==========================================
|
||||
"shortcuts": {
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
|
||||
"copySubtitle": "CommandOrControl+C",
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C",
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V",
|
||||
"triggerFieldGrouping": "CommandOrControl+G",
|
||||
"triggerSubsync": "Ctrl+Alt+S",
|
||||
"mineSentence": "CommandOrControl+S",
|
||||
"mineSentenceMultiple": "CommandOrControl+Shift+S",
|
||||
"multiCopyTimeoutMs": 3000,
|
||||
"toggleSecondarySub": "CommandOrControl+Shift+V",
|
||||
"markAudioCard": "CommandOrControl+Shift+A",
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O",
|
||||
"openJimaku": "Ctrl+Shift+J",
|
||||
},
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting.
|
||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
||||
"triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting.
|
||||
"triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting.
|
||||
"mineSentence": "CommandOrControl+S", // Mine sentence setting.
|
||||
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting.
|
||||
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
||||
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
// Invisible Overlay
|
||||
@@ -147,8 +148,8 @@
|
||||
// This edit-mode shortcut is fixed and is not currently configurable.
|
||||
// ==========================================
|
||||
"invisibleOverlay": {
|
||||
"startupVisibility": "platform-default",
|
||||
},
|
||||
"startupVisibility": "platform-default" // Startup visibility setting.
|
||||
}, // Startup behavior for the invisible interactive subtitle mining layer.
|
||||
|
||||
// ==========================================
|
||||
// Keybindings (MPV Commands)
|
||||
@@ -156,7 +157,7 @@
|
||||
// Set command to null to disable a default keybinding.
|
||||
// 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
|
||||
@@ -164,39 +165,45 @@
|
||||
// Hot-reload: subtitle style changes apply live without restarting SubMiner.
|
||||
// ==========================================
|
||||
"subtitleStyle": {
|
||||
"enableJlpt": false,
|
||||
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
|
||||
"fontSize": 35,
|
||||
"fontColor": "#cad3f5",
|
||||
"fontWeight": "normal",
|
||||
"fontStyle": "normal",
|
||||
"backgroundColor": "rgba(54, 58, 79, 0.5)",
|
||||
"nPlusOneColor": "#c6a0f6",
|
||||
"knownWordColor": "#a6da95",
|
||||
"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", // Font family setting.
|
||||
"fontSize": 35, // Font size setting.
|
||||
"fontColor": "#cad3f5", // Font color setting.
|
||||
"fontWeight": "normal", // Font weight setting.
|
||||
"fontStyle": "normal", // Font style setting.
|
||||
"backgroundColor": "rgba(54, 58, 79, 0.5)", // Background color setting.
|
||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
||||
"knownWordColor": "#a6da95", // Known word color setting.
|
||||
"jlptColors": {
|
||||
"N1": "#ed8796",
|
||||
"N2": "#f5a97f",
|
||||
"N3": "#f9e2af",
|
||||
"N4": "#a6e3a1",
|
||||
"N5": "#8aadf4",
|
||||
},
|
||||
"N1": "#ed8796", // N1 setting.
|
||||
"N2": "#f5a97f", // N2 setting.
|
||||
"N3": "#f9e2af", // N3 setting.
|
||||
"N4": "#a6e3a1", // N4 setting.
|
||||
"N5": "#8aadf4" // N5 setting.
|
||||
}, // Jlpt colors setting.
|
||||
"frequencyDictionary": {
|
||||
"enabled": false,
|
||||
"sourcePath": "",
|
||||
"topX": 1000,
|
||||
"mode": "single",
|
||||
"singleColor": "#f5a97f",
|
||||
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"],
|
||||
},
|
||||
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
||||
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used.
|
||||
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
|
||||
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
|
||||
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
||||
"bandedColors": [
|
||||
"#ed8796",
|
||||
"#f5a97f",
|
||||
"#f9e2af",
|
||||
"#a6e3a1",
|
||||
"#8aadf4"
|
||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||
}, // Frequency dictionary setting.
|
||||
"secondary": {
|
||||
"fontSize": 24,
|
||||
"fontColor": "#ffffff",
|
||||
"backgroundColor": "transparent",
|
||||
"fontWeight": "normal",
|
||||
"fontStyle": "normal",
|
||||
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
|
||||
},
|
||||
},
|
||||
"fontSize": 24, // Font size setting.
|
||||
"fontColor": "#ffffff", // Font color setting.
|
||||
"backgroundColor": "transparent", // Background color setting.
|
||||
"fontWeight": "normal", // Font weight setting.
|
||||
"fontStyle": "normal", // Font style setting.
|
||||
"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
|
||||
@@ -205,59 +212,62 @@
|
||||
// Hot-reload: defaultMode updates live while SubMiner is running.
|
||||
// ==========================================
|
||||
"secondarySub": {
|
||||
"secondarySubLanguages": [],
|
||||
"autoLoadSecondarySub": false,
|
||||
"defaultMode": "hover",
|
||||
},
|
||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
||||
"defaultMode": "hover" // Default mode setting.
|
||||
}, // Dual subtitle track options.
|
||||
|
||||
// ==========================================
|
||||
// Auto Subtitle Sync
|
||||
// Subsync engine and executable paths.
|
||||
// ==========================================
|
||||
"subsync": {
|
||||
"defaultMode": "auto",
|
||||
"alass_path": "",
|
||||
"ffsubsync_path": "",
|
||||
"ffmpeg_path": "",
|
||||
},
|
||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
||||
"alass_path": "", // Alass path setting.
|
||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||
"ffmpeg_path": "" // Ffmpeg path setting.
|
||||
}, // Subsync engine and executable paths.
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Position
|
||||
// Initial vertical subtitle position from the bottom.
|
||||
// ==========================================
|
||||
"subtitlePosition": {
|
||||
"yPercent": 10,
|
||||
},
|
||||
"yPercent": 10 // Y percent setting.
|
||||
}, // Initial vertical subtitle position from the bottom.
|
||||
|
||||
// ==========================================
|
||||
// Jimaku
|
||||
// Jimaku API configuration and defaults.
|
||||
// ==========================================
|
||||
"jimaku": {
|
||||
"apiBaseUrl": "https://jimaku.cc",
|
||||
"languagePreference": "ja",
|
||||
"maxEntryResults": 10,
|
||||
},
|
||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||
}, // Jimaku API configuration and defaults.
|
||||
|
||||
// ==========================================
|
||||
// YouTube Subtitle Generation
|
||||
// Defaults for subminer YouTube subtitle extraction/transcription mode.
|
||||
// ==========================================
|
||||
"youtubeSubgen": {
|
||||
"mode": "automatic",
|
||||
"whisperBin": "",
|
||||
"whisperModel": "",
|
||||
"primarySubLanguages": ["ja", "jpn"],
|
||||
},
|
||||
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
|
||||
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
|
||||
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
||||
"primarySubLanguages": [
|
||||
"ja",
|
||||
"jpn"
|
||||
] // Comma-separated primary subtitle language priority used by the launcher.
|
||||
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
||||
|
||||
// ==========================================
|
||||
// Anilist
|
||||
// Anilist API credentials and update behavior.
|
||||
// ==========================================
|
||||
"anilist": {
|
||||
"enabled": false,
|
||||
"accessToken": "",
|
||||
},
|
||||
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
||||
"accessToken": "" // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
||||
}, // Anilist API credentials and update behavior.
|
||||
|
||||
// ==========================================
|
||||
// Jellyfin
|
||||
@@ -265,25 +275,33 @@
|
||||
// Access token is stored in config and should be treated as a secret.
|
||||
// ==========================================
|
||||
"jellyfin": {
|
||||
"enabled": false,
|
||||
"serverUrl": "",
|
||||
"username": "",
|
||||
"accessToken": "",
|
||||
"userId": "",
|
||||
"deviceId": "subminer",
|
||||
"clientName": "SubMiner",
|
||||
"clientVersion": "0.1.0",
|
||||
"defaultLibraryId": "",
|
||||
"remoteControlEnabled": true,
|
||||
"remoteControlAutoConnect": true,
|
||||
"autoAnnounce": false,
|
||||
"remoteControlDeviceName": "SubMiner",
|
||||
"pullPictures": false,
|
||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons",
|
||||
"directPlayPreferred": true,
|
||||
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
|
||||
"transcodeVideoCodec": "h264",
|
||||
},
|
||||
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
|
||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||
"username": "", // Default Jellyfin username used during CLI login.
|
||||
"accessToken": "", // Access token setting.
|
||||
"userId": "", // User id setting.
|
||||
"deviceId": "subminer", // Device id setting.
|
||||
"clientName": "SubMiner", // Client name setting.
|
||||
"clientVersion": "0.1.0", // Client version setting.
|
||||
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
||||
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
|
||||
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
|
||||
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
||||
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||
"directPlayContainers": [
|
||||
"mkv",
|
||||
"mp4",
|
||||
"webm",
|
||||
"mov",
|
||||
"flac",
|
||||
"mp3",
|
||||
"aac"
|
||||
], // Container allowlist for direct play decisions.
|
||||
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
|
||||
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||
|
||||
// ==========================================
|
||||
// Immersion Tracking
|
||||
@@ -292,19 +310,19 @@
|
||||
// Policy tuning is available for queue, flush, and retention values.
|
||||
// ==========================================
|
||||
"immersionTracking": {
|
||||
"enabled": true,
|
||||
"dbPath": "",
|
||||
"batchSize": 25,
|
||||
"flushIntervalMs": 500,
|
||||
"queueCap": 1000,
|
||||
"payloadCapBytes": 256,
|
||||
"maintenanceIntervalMs": 86400000,
|
||||
"enabled": true, // Enable immersion tracking for mined subtitle metadata. Values: true | false
|
||||
"dbPath": "", // Optional SQLite database path for immersion tracking. Empty value uses the default app data path.
|
||||
"batchSize": 25, // Buffered telemetry/event writes per SQLite transaction.
|
||||
"flushIntervalMs": 500, // Max delay before queue flush in milliseconds.
|
||||
"queueCap": 1000, // In-memory write queue cap before overflow policy applies.
|
||||
"payloadCapBytes": 256, // Max JSON payload size per event before truncation.
|
||||
"maintenanceIntervalMs": 86400000, // Maintenance cadence (prune + rollup + vacuum checks).
|
||||
"retention": {
|
||||
"eventsDays": 7,
|
||||
"telemetryDays": 30,
|
||||
"dailyRollupsDays": 365,
|
||||
"monthlyRollupsDays": 1825,
|
||||
"vacuumIntervalDays": 7,
|
||||
},
|
||||
},
|
||||
"eventsDays": 7, // Raw event retention window in days.
|
||||
"telemetryDays": 30, // Telemetry retention window in days.
|
||||
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
||||
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
||||
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
|
||||
} // Retention setting.
|
||||
} // Enable/disable immersion tracking.
|
||||
}
|
||||
|
||||
132
docs/shortcuts.md
Normal file
132
docs/shortcuts.md
Normal 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 1–9 to select count) | `shortcuts.mineSentenceMultiple` |
|
||||
| `Ctrl/Cmd+C` | Copy current subtitle text | `shortcuts.copySubtitle` |
|
||||
| `Ctrl/Cmd+Shift+C` | Copy multiple lines (press 1–9 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.
|
||||
@@ -4,4 +4,7 @@ Read first. Keep concise.
|
||||
|
||||
| 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` |
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Agent: <agent_id>
|
||||
# Agent: `<agent_id>`
|
||||
|
||||
- alias: <short label>
|
||||
- mission: <one-line focus>
|
||||
- status: <planning|editing|testing|blocked|handoff|done>
|
||||
- branch: <name>
|
||||
- started_at: <UTC ISO>
|
||||
- heartbeat_minutes: <n>
|
||||
- alias: `<short label>`
|
||||
- mission: `<one-line focus>`
|
||||
- status: `<planning|editing|testing|blocked|handoff|done>`
|
||||
- branch: `<name>`
|
||||
- started_at: `<UTC ISO>`
|
||||
- heartbeat_minutes: `<n>`
|
||||
|
||||
## Current Work (newest first)
|
||||
- [YYYY-MM-DDTHH:MM:SSZ] intent: ...
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,14 +1,40 @@
|
||||
# Agent: codex-main
|
||||
|
||||
- alias: planner-exec
|
||||
- mission: Unify config path resolution across app + launcher
|
||||
- status: handoff
|
||||
- mission: Fix frequency/N+1 regression in plugin --start flow
|
||||
- status: in_progress
|
||||
- branch: main
|
||||
- started_at: 2026-02-19T08:06:28Z
|
||||
- heartbeat_minutes: 20
|
||||
|
||||
## 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] 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.
|
||||
@@ -62,6 +88,13 @@
|
||||
- `docs/mining-workflow.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`
|
||||
- `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
|
||||
|
||||
@@ -73,4 +106,17 @@
|
||||
|
||||
## 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`.
|
||||
|
||||
49
docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md
Normal file
49
docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md
Normal 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.
|
||||
@@ -4,3 +4,4 @@ Shared notes. Append-only.
|
||||
|
||||
- [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-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).
|
||||
|
||||
@@ -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.").
|
||||
- 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**
|
||||
|
||||
|
||||
@@ -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 idle # Launch detached idle mpv with SubMiner defaults
|
||||
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 --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 mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||
- `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`).
|
||||
|
||||
Use subcommands for Jellyfin/YouTube command families (`subminer jellyfin ...`, `subminer yt ...`).
|
||||
|
||||
21
launcher/config.test.ts
Normal file
21
launcher/config.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -260,7 +260,28 @@ function applyRootOptions(program: Command): void {
|
||||
.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 {
|
||||
return getTopLevelCommand(argv) !== null;
|
||||
}
|
||||
|
||||
function getTopLevelCommand(argv: string[]): { name: string; index: number } | null {
|
||||
const commandNames = new Set([
|
||||
'jellyfin',
|
||||
'jf',
|
||||
@@ -270,6 +291,8 @@ function hasTopLevelCommand(argv: string[]): boolean {
|
||||
'config',
|
||||
'mpv',
|
||||
'texthooker',
|
||||
'app',
|
||||
'bin',
|
||||
'help',
|
||||
]);
|
||||
const optionsWithValue = new Set([
|
||||
@@ -283,16 +306,16 @@ function hasTopLevelCommand(argv: string[]): boolean {
|
||||
]);
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i] || '';
|
||||
if (token === '--') return false;
|
||||
if (token === '--') return null;
|
||||
if (token.startsWith('-')) {
|
||||
if (optionsWithValue.has(token)) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return commandNames.has(token);
|
||||
return commandNames.has(token) ? { name: token, index: i } : null;
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseArgs(
|
||||
@@ -300,6 +323,7 @@ export function parseArgs(
|
||||
scriptName: string,
|
||||
launcherConfig: LauncherYoutubeSubgenConfig,
|
||||
): Args {
|
||||
const topLevelCommand = getTopLevelCommand(argv);
|
||||
const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || '').toLowerCase();
|
||||
const defaultMode: YoutubeSubgenMode =
|
||||
envMode === 'preprocess' || envMode === 'off' || envMode === 'automatic'
|
||||
@@ -350,6 +374,8 @@ export function parseArgs(
|
||||
mpvIdle: false,
|
||||
mpvSocket: false,
|
||||
mpvStatus: false,
|
||||
appPassthrough: false,
|
||||
appArgs: [],
|
||||
jellyfinServer: '',
|
||||
jellyfinUsername: '',
|
||||
jellyfinPassword: '',
|
||||
@@ -374,6 +400,11 @@ export function parseArgs(
|
||||
parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference;
|
||||
if (launcherConfig.jimakuMaxEntryResults !== undefined)
|
||||
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: {
|
||||
action?: string;
|
||||
@@ -399,6 +430,7 @@ export function parseArgs(
|
||||
} | null = null;
|
||||
let configInvocation: { 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 texthookerLogLevel: string | null = null;
|
||||
|
||||
@@ -522,6 +554,21 @@ export function parseArgs(
|
||||
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;
|
||||
try {
|
||||
selectedProgram.parse(['node', scriptName, ...argv]);
|
||||
@@ -644,5 +691,10 @@ export function parseArgs(
|
||||
else fail(`Unknown mpv action: ${mpvInvocation.action}`);
|
||||
}
|
||||
|
||||
if (appInvocation !== null) {
|
||||
parsed.appPassthrough = true;
|
||||
parsed.appArgs = appInvocation.appArgs;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -218,6 +218,10 @@ async function main(): Promise<void> {
|
||||
}
|
||||
state.appPath = appPath;
|
||||
|
||||
if (args.appPassthrough) {
|
||||
runAppCommandWithInherit(appPath, args.appArgs);
|
||||
}
|
||||
|
||||
if (args.mpvIdle) {
|
||||
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
|
||||
const ready = await waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
|
||||
24
launcher/parse-args.test.ts
Normal file
24
launcher/parse-args.test.ts
Normal 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']);
|
||||
});
|
||||
@@ -91,6 +91,8 @@ export interface Args {
|
||||
mpvIdle: boolean;
|
||||
mpvSocket: boolean;
|
||||
mpvStatus: boolean;
|
||||
appPassthrough: boolean;
|
||||
appArgs: string[];
|
||||
jellyfinServer: string;
|
||||
jellyfinUsername: string;
|
||||
jellyfinPassword: string;
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort",
|
||||
"format": "prettier --write .",
|
||||
"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: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\"",
|
||||
|
||||
100
scripts/check-file-budgets.ts
Normal file
100
scripts/check-file-budgets.ts
Normal 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();
|
||||
@@ -889,4 +889,16 @@ test('template generator includes known keys', () => {
|
||||
assert.match(output, /"knownWord": "#a6da95"/);
|
||||
assert.match(output, /"minSentenceWords": 3/);
|
||||
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/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -503,7 +503,8 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
|
||||
path: 'anilist.accessToken',
|
||||
kind: 'string',
|
||||
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',
|
||||
|
||||
@@ -1,7 +1,46 @@
|
||||
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 nextPad = ' '.repeat(indent + 2);
|
||||
|
||||
@@ -11,7 +50,7 @@ function renderValue(value: unknown, indent = 0): string {
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
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(']');
|
||||
}
|
||||
|
||||
@@ -20,10 +59,18 @@ function renderValue(value: unknown, indent = 0): string {
|
||||
([, child]) => child !== undefined,
|
||||
);
|
||||
if (entries.length === 0) return '{}';
|
||||
const lines = entries.map(
|
||||
([key, child]) => `${nextPad}${JSON.stringify(key)}: ${renderValue(child, indent + 2)}`,
|
||||
);
|
||||
return `\n${lines.join(',\n')}\n${pad}`.replace(/^/, '{').concat('}');
|
||||
const lines = entries.map(([key, child], index) => {
|
||||
const isLast = index === entries.length - 1;
|
||||
const trailingComma = isLast ? '' : ',';
|
||||
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';
|
||||
@@ -41,7 +88,17 @@ function renderSection(
|
||||
lines.push(` // ${comment}`);
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -197,8 +197,10 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
return { deps, calls, osd };
|
||||
}
|
||||
|
||||
test('handleCliCommand ignores --start for second-instance without actions', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
test('handleCliCommand ignores --start for second-instance when overlay runtime is already initialized', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
});
|
||||
const args = makeArgs({ start: true });
|
||||
|
||||
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', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
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);
|
||||
|
||||
assert.ok(calls.includes('initializeOverlayRuntime'));
|
||||
assert.ok(calls.includes('setMpvSocketPath:/tmp/custom.sock'));
|
||||
assert.ok(calls.includes('setMpvClientSocketPath:/tmp/custom.sock'));
|
||||
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', () => {
|
||||
const cases: Array<{
|
||||
args: Partial<CliArgs>;
|
||||
|
||||
@@ -275,7 +275,11 @@ export function handleCliCommand(
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.texthooker ||
|
||||
args.help;
|
||||
const ignoreStartOnly = source === 'second-instance' && args.start && !hasNonStartAction;
|
||||
const ignoreStartOnly =
|
||||
source === 'second-instance' &&
|
||||
args.start &&
|
||||
!hasNonStartAction &&
|
||||
deps.isOverlayRuntimeInitialized();
|
||||
if (ignoreStartOnly) {
|
||||
deps.log('Ignoring --start because SubMiner is already running.');
|
||||
return;
|
||||
@@ -283,9 +287,11 @@ export function handleCliCommand(
|
||||
|
||||
const shouldStart =
|
||||
args.start ||
|
||||
(source === 'initial' &&
|
||||
(args.toggle || args.toggleVisibleOverlay || args.toggleInvisibleOverlay));
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.toggleInvisibleOverlay;
|
||||
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
|
||||
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
|
||||
|
||||
if (args.socketPath !== undefined) {
|
||||
deps.setMpvSocketPath(args.socketPath);
|
||||
@@ -306,7 +312,7 @@ export function handleCliCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsOverlayRuntime && !deps.isOverlayRuntimeInitialized()) {
|
||||
if (shouldInitializeOverlayRuntime && !deps.isOverlayRuntimeInitialized()) {
|
||||
deps.initializeOverlayRuntime();
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ test('config hot reload runtime debounces rapid watch events', () => {
|
||||
onHotReloadApplied: () => {},
|
||||
onRestartRequired: () => {},
|
||||
onInvalidConfig: () => {},
|
||||
onValidationWarnings: () => {},
|
||||
};
|
||||
|
||||
const runtime = createConfigHotReloadRuntime(deps);
|
||||
@@ -103,9 +104,59 @@ test('config hot reload runtime reports invalid config and skips apply', () => {
|
||||
onInvalidConfig: (message) => {
|
||||
invalidMessages.push(message);
|
||||
},
|
||||
onValidationWarnings: () => {
|
||||
throw new Error('Validation warnings should not trigger for invalid config.');
|
||||
},
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
assert.equal(watchedChangeCallback, null);
|
||||
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 }]);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ReloadConfigStrictResult } from '../../config';
|
||||
import type { ConfigValidationWarning } from '../../types';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
|
||||
export interface ConfigHotReloadDiff {
|
||||
@@ -16,6 +17,7 @@ export interface ConfigHotReloadRuntimeDeps {
|
||||
onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void;
|
||||
onRestartRequired: (fields: string[]) => void;
|
||||
onInvalidConfig: (message: string) => void;
|
||||
onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) => void;
|
||||
}
|
||||
|
||||
export interface ConfigHotReloadRuntime {
|
||||
@@ -107,6 +109,10 @@ export function createConfigHotReloadRuntime(
|
||||
watchPath(result.path);
|
||||
}
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
deps.onValidationWarnings(result.path, result.warnings);
|
||||
}
|
||||
|
||||
const diff = classifyDiff(prev, result.config);
|
||||
if (diff.hotReloadFields.length > 0) {
|
||||
deps.onHotReloadApplied(diff, result.config);
|
||||
|
||||
1210
src/main.ts
1210
src/main.ts
File diff suppressed because it is too large
Load Diff
80
src/main/config-validation.test.ts
Normal file
80
src/main/config-validation.test.ts
Normal 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;
|
||||
});
|
||||
74
src/main/config-validation.ts
Normal file
74
src/main/config-validation.ts
Normal 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);
|
||||
}
|
||||
64
src/main/runtime/anilist-setup-protocol.test.ts
Normal file
64
src/main/runtime/anilist-setup-protocol.test.ts
Normal 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']);
|
||||
});
|
||||
91
src/main/runtime/anilist-setup-protocol.ts
Normal file
91
src/main/runtime/anilist-setup-protocol.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
148
src/main/runtime/anilist-setup.test.ts
Normal file
148
src/main/runtime/anilist-setup.test.ts
Normal 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, /<script>alert\(1\)<\/script>/);
|
||||
});
|
||||
|
||||
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, []);
|
||||
});
|
||||
177
src/main/runtime/anilist-setup.ts
Normal file
177
src/main/runtime/anilist-setup.ts
Normal 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, '<').replace(/>/g, '>');
|
||||
const safeAuth = params.authorizeUrl.replace(/"/g, '"');
|
||||
const safeDev = params.developerSettingsUrl.replace(/"/g, '"');
|
||||
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, '"');
|
||||
const safeDev = params.developerSettingsUrl.replace(/"/g, '"');
|
||||
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,
|
||||
});
|
||||
}
|
||||
101
src/main/runtime/anilist-state.test.ts
Normal file
101
src/main/runtime/anilist-state.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
97
src/main/runtime/anilist-state.ts
Normal file
97
src/main/runtime/anilist-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
47
src/main/runtime/clipboard-queue.test.ts
Normal file
47
src/main/runtime/clipboard-queue.test.ts
Normal 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);
|
||||
});
|
||||
40
src/main/runtime/clipboard-queue.ts
Normal file
40
src/main/runtime/clipboard-queue.ts
Normal 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}` };
|
||||
}
|
||||
64
src/main/runtime/config-derived.ts
Normal file
64
src/main/runtime/config-derived.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
81
src/main/runtime/config-hot-reload-handlers.test.ts
Normal file
81
src/main/runtime/config-hot-reload-handlers.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
73
src/main/runtime/config-hot-reload-handlers.ts
Normal file
73
src/main/runtime/config-hot-reload-handlers.ts
Normal 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(', ')}`;
|
||||
}
|
||||
76
src/main/runtime/immersion-media.test.ts
Normal file
76
src/main/runtime/immersion-media.test.ts
Normal 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' }]);
|
||||
});
|
||||
174
src/main/runtime/immersion-media.ts
Normal file
174
src/main/runtime/immersion-media.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
137
src/main/runtime/immersion-startup.test.ts
Normal file
137
src/main/runtime/immersion-startup.test.ts
Normal 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'),
|
||||
);
|
||||
});
|
||||
99
src/main/runtime/immersion-startup.ts
Normal file
99
src/main/runtime/immersion-startup.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
141
src/main/runtime/jellyfin-remote-commands.test.ts
Normal file
141
src/main/runtime/jellyfin-remote-commands.test.ts
Normal 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')),
|
||||
);
|
||||
});
|
||||
189
src/main/runtime/jellyfin-remote-commands.ts
Normal file
189
src/main/runtime/jellyfin-remote-commands.ts
Normal 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}`);
|
||||
};
|
||||
}
|
||||
102
src/main/runtime/jellyfin-remote-connection.test.ts
Normal file
102
src/main/runtime/jellyfin-remote-connection.test.ts
Normal 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);
|
||||
});
|
||||
108
src/main/runtime/jellyfin-remote-connection.ts
Normal file
108
src/main/runtime/jellyfin-remote-connection.ts
Normal 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>;
|
||||
};
|
||||
}
|
||||
121
src/main/runtime/jellyfin-remote-playback.test.ts
Normal file
121
src/main/runtime/jellyfin-remote-playback.test.ts
Normal 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);
|
||||
});
|
||||
109
src/main/runtime/jellyfin-remote-playback.ts
Normal file
109
src/main/runtime/jellyfin-remote-playback.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
119
src/main/runtime/startup-config.test.ts
Normal file
119
src/main/runtime/startup-config.test.ts
Normal 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;
|
||||
});
|
||||
83
src/main/runtime/startup-config.ts
Normal file
83
src/main/runtime/startup-config.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
37
src/main/runtime/subsync-runtime.ts
Normal file
37
src/main/runtime/subsync-runtime.ts
Normal 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()),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user