From 162be118e1c8cd1440876894cf39cf5293e9ee49 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 19 Feb 2026 16:04:59 -0800 Subject: [PATCH] refactor(main): modularize runtime and harden anilist setup flow --- AGENTS.md | 43 - backlog/config.yml | 10 +- ...callback-token-handling-in-setup-window.md | 43 + ...undary-and-recovery-in-renderer-overlay.md | 8 +- ...dly-config-validation-errors-on-startup.md | 3 +- ...t-loading-and-clipboard-append-shortcut.md | 34 - ...ackground-tray-service-with-IPC-startup.md | 3 +- ...pper-stop-auto-sending-start-by-default.md | 3 +- ...trailing-commas-in-JSONC-config-parsing.md | 3 +- ...ion-validation-and-deprecation-handling.md | 3 +- ...ath-resolution-across-main-and-launcher.md | 3 +- ...-card-field-config-to-fixed-field-names.md | 3 +- ...les-for-maintainability-and-readability.md | 56 + ...ion-issue-details-in-user-notifications.md | 38 + ...ation-frequency-N1-initialize-correctly.md | 37 + config.example.jsonc | 358 ++--- docs/.vitepress/config.ts | 4 +- docs/README.md | 6 +- docs/configuration.md | 15 +- docs/file-size-budgets.md | 21 + docs/index.md | 32 +- docs/public/config.example.jsonc | 358 ++--- docs/shortcuts.md | 132 ++ docs/subagents/INDEX.md | 5 +- docs/subagents/agents/TEMPLATE.md | 14 +- ...codex-anilist-deeplink-20260219T233926Z.md | 38 + ...config-validation-20260219T172015Z-iiyf.md | 39 + docs/subagents/agents/codex-main.md | 52 +- .../codex-task85-20260219T233711Z-46hc.md | 49 + docs/subagents/collaboration.md | 1 + docs/troubleshooting.md | 2 +- docs/usage.md | 2 + launcher/config.test.ts | 21 + launcher/config.ts | 58 +- launcher/main.ts | 4 + launcher/parse-args.test.ts | 24 + launcher/types.ts | 2 + package.json | 2 + scripts/check-file-budgets.ts | 100 ++ src/config/config.test.ts | 12 + src/config/definitions.ts | 3 +- src/config/template.ts | 73 +- src/core/services/cli-command.test.ts | 34 +- src/core/services/cli-command.ts | 14 +- src/core/services/config-hot-reload.test.ts | 51 + src/core/services/config-hot-reload.ts | 6 + src/main.ts | 1210 ++++++----------- src/main/config-validation.test.ts | 80 ++ src/main/config-validation.ts | 74 + .../runtime/anilist-setup-protocol.test.ts | 64 + src/main/runtime/anilist-setup-protocol.ts | 91 ++ src/main/runtime/anilist-setup.test.ts | 148 ++ src/main/runtime/anilist-setup.ts | 177 +++ src/main/runtime/anilist-state.test.ts | 101 ++ src/main/runtime/anilist-state.ts | 97 ++ src/main/runtime/clipboard-queue.test.ts | 47 + src/main/runtime/clipboard-queue.ts | 40 + src/main/runtime/config-derived.ts | 64 + .../config-hot-reload-handlers.test.ts | 81 ++ .../runtime/config-hot-reload-handlers.ts | 73 + src/main/runtime/immersion-media.test.ts | 76 ++ src/main/runtime/immersion-media.ts | 174 +++ src/main/runtime/immersion-startup.test.ts | 137 ++ src/main/runtime/immersion-startup.ts | 99 ++ .../runtime/jellyfin-remote-commands.test.ts | 141 ++ src/main/runtime/jellyfin-remote-commands.ts | 189 +++ .../jellyfin-remote-connection.test.ts | 102 ++ .../runtime/jellyfin-remote-connection.ts | 108 ++ .../runtime/jellyfin-remote-playback.test.ts | 121 ++ src/main/runtime/jellyfin-remote-playback.ts | 109 ++ src/main/runtime/startup-config.test.ts | 119 ++ src/main/runtime/startup-config.ts | 83 ++ src/main/runtime/subsync-runtime.ts | 37 + 73 files changed, 4413 insertions(+), 1251 deletions(-) create mode 100644 backlog/tasks/task-29.3 - Fix-AniList-OAuth-callback-token-handling-in-setup-window.md delete mode 100644 backlog/tasks/task-65 - Add-overlay-drag-drop-playlist-loading-and-clipboard-append-shortcut.md create mode 100644 backlog/tasks/task-85 - Refactor-large-files-for-maintainability-and-readability.md create mode 100644 backlog/tasks/task-86 - Include-config-validation-issue-details-in-user-notifications.md create mode 100644 backlog/tasks/task-87 - Fix-plugin-start-flow-so-tokenization-frequency-N1-initialize-correctly.md create mode 100644 docs/file-size-budgets.md create mode 100644 docs/shortcuts.md create mode 100644 docs/subagents/agents/codex-anilist-deeplink-20260219T233926Z.md create mode 100644 docs/subagents/agents/codex-config-validation-20260219T172015Z-iiyf.md create mode 100644 docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md create mode 100644 launcher/config.test.ts create mode 100644 launcher/parse-args.test.ts create mode 100644 scripts/check-file-budgets.ts create mode 100644 src/main/config-validation.test.ts create mode 100644 src/main/config-validation.ts create mode 100644 src/main/runtime/anilist-setup-protocol.test.ts create mode 100644 src/main/runtime/anilist-setup-protocol.ts create mode 100644 src/main/runtime/anilist-setup.test.ts create mode 100644 src/main/runtime/anilist-setup.ts create mode 100644 src/main/runtime/anilist-state.test.ts create mode 100644 src/main/runtime/anilist-state.ts create mode 100644 src/main/runtime/clipboard-queue.test.ts create mode 100644 src/main/runtime/clipboard-queue.ts create mode 100644 src/main/runtime/config-derived.ts create mode 100644 src/main/runtime/config-hot-reload-handlers.test.ts create mode 100644 src/main/runtime/config-hot-reload-handlers.ts create mode 100644 src/main/runtime/immersion-media.test.ts create mode 100644 src/main/runtime/immersion-media.ts create mode 100644 src/main/runtime/immersion-startup.test.ts create mode 100644 src/main/runtime/immersion-startup.ts create mode 100644 src/main/runtime/jellyfin-remote-commands.test.ts create mode 100644 src/main/runtime/jellyfin-remote-commands.ts create mode 100644 src/main/runtime/jellyfin-remote-connection.test.ts create mode 100644 src/main/runtime/jellyfin-remote-connection.ts create mode 100644 src/main/runtime/jellyfin-remote-playback.test.ts create mode 100644 src/main/runtime/jellyfin-remote-playback.ts create mode 100644 src/main/runtime/startup-config.test.ts create mode 100644 src/main/runtime/startup-config.ts create mode 100644 src/main/runtime/subsync-runtime.ts diff --git a/AGENTS.md b/AGENTS.md index a15d50f..1b3f6b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,46 +27,3 @@ You MUST read the overview resource to understand the complete workflow. The inf - -## 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/.md` (one file per agent) -- `docs/subagents/archive//` (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/.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) diff --git a/backlog/config.yml b/backlog/config.yml index ffb9ab0..de5fb9e 100644 --- a/backlog/config.yml +++ b/backlog/config.yml @@ -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" diff --git a/backlog/tasks/task-29.3 - Fix-AniList-OAuth-callback-token-handling-in-setup-window.md b/backlog/tasks/task-29.3 - Fix-AniList-OAuth-callback-token-handling-in-setup-window.md new file mode 100644 index 0000000..1bbc3f5 --- /dev/null +++ b/backlog/tasks/task-29.3 - Fix-AniList-OAuth-callback-token-handling-in-setup-window.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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 + + +## Definition of Done + +- [x] #1 Build passes +- [x] #2 Targeted AniList runtime tests pass + + +## Implementation Notes + + +- 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. + diff --git a/backlog/tasks/task-37 - Add-error-boundary-and-recovery-in-renderer-overlay.md b/backlog/tasks/task-37 - Add-error-boundary-and-recovery-in-renderer-overlay.md index a9cf20d..1d6934c 100644 --- a/backlog/tasks/task-37 - Add-error-boundary-and-recovery-in-renderer-overlay.md +++ b/backlog/tasks/task-37 - Add-error-boundary-and-recovery-in-renderer-overlay.md @@ -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 - 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 ## Acceptance Criteria - - - [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 + - 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` + diff --git a/backlog/tasks/task-38 - Add-user-friendly-config-validation-errors-on-startup.md b/backlog/tasks/task-38 - Add-user-friendly-config-validation-errors-on-startup.md index 4f28f92..33d4af8 100644 --- a/backlog/tasks/task-38 - Add-user-friendly-config-validation-errors-on-startup.md +++ b/backlog/tasks/task-38 - Add-user-friendly-config-validation-errors-on-startup.md @@ -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 diff --git a/backlog/tasks/task-65 - Add-overlay-drag-drop-playlist-loading-and-clipboard-append-shortcut.md b/backlog/tasks/task-65 - Add-overlay-drag-drop-playlist-loading-and-clipboard-append-shortcut.md deleted file mode 100644 index ff10fd5..0000000 --- a/backlog/tasks/task-65 - Add-overlay-drag-drop-playlist-loading-and-clipboard-append-shortcut.md +++ /dev/null @@ -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` diff --git a/backlog/tasks/task-65 - Run-Electron-app-as-background-tray-service-with-IPC-startup.md b/backlog/tasks/task-65 - Run-Electron-app-as-background-tray-service-with-IPC-startup.md index 9dc8577..36b3241 100644 --- a/backlog/tasks/task-65 - Run-Electron-app-as-background-tray-service-with-IPC-startup.md +++ b/backlog/tasks/task-65 - Run-Electron-app-as-background-tray-service-with-IPC-startup.md @@ -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 diff --git a/backlog/tasks/task-67 - Make-wrapper-stop-auto-sending-start-by-default.md b/backlog/tasks/task-67 - Make-wrapper-stop-auto-sending-start-by-default.md index 86256ae..460eff6 100644 --- a/backlog/tasks/task-67 - Make-wrapper-stop-auto-sending-start-by-default.md +++ b/backlog/tasks/task-67 - Make-wrapper-stop-auto-sending-start-by-default.md @@ -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 diff --git a/backlog/tasks/task-68 - Allow-trailing-commas-in-JSONC-config-parsing.md b/backlog/tasks/task-68 - Allow-trailing-commas-in-JSONC-config-parsing.md index ff8b9e7..7a470e7 100644 --- a/backlog/tasks/task-68 - Allow-trailing-commas-in-JSONC-config-parsing.md +++ b/backlog/tasks/task-68 - Allow-trailing-commas-in-JSONC-config-parsing.md @@ -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 diff --git a/backlog/tasks/task-69 - Harden-legacy-config-migration-validation-and-deprecation-handling.md b/backlog/tasks/task-69 - Harden-legacy-config-migration-validation-and-deprecation-handling.md index a48a492..2c812ca 100644 --- a/backlog/tasks/task-69 - Harden-legacy-config-migration-validation-and-deprecation-handling.md +++ b/backlog/tasks/task-69 - Harden-legacy-config-migration-validation-and-deprecation-handling.md @@ -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 diff --git a/backlog/tasks/task-70 - Unify-config-path-resolution-across-main-and-launcher.md b/backlog/tasks/task-70 - Unify-config-path-resolution-across-main-and-launcher.md index 13163fb..a1bc93b 100644 --- a/backlog/tasks/task-70 - Unify-config-path-resolution-across-main-and-launcher.md +++ b/backlog/tasks/task-70 - Unify-config-path-resolution-across-main-and-launcher.md @@ -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 diff --git a/backlog/tasks/task-83 - Simplify-isLapis-sentence-card-field-config-to-fixed-field-names.md b/backlog/tasks/task-83 - Simplify-isLapis-sentence-card-field-config-to-fixed-field-names.md index 28c8888..b0ddb18 100644 --- a/backlog/tasks/task-83 - Simplify-isLapis-sentence-card-field-config-to-fixed-field-names.md +++ b/backlog/tasks/task-83 - Simplify-isLapis-sentence-card-field-config-to-fixed-field-names.md @@ -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 diff --git a/backlog/tasks/task-85 - Refactor-large-files-for-maintainability-and-readability.md b/backlog/tasks/task-85 - Refactor-large-files-for-maintainability-and-readability.md new file mode 100644 index 0000000..d10990d --- /dev/null +++ b/backlog/tasks/task-85 - Refactor-large-files-for-maintainability-and-readability.md @@ -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 + + +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. + + +## Suggestions + + +- 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. + + +## Action Steps + + +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. + + +## Acceptance Criteria + +- [ ] #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 + + +## Definition of Done + +- [ ] #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 + diff --git a/backlog/tasks/task-86 - Include-config-validation-issue-details-in-user-notifications.md b/backlog/tasks/task-86 - Include-config-validation-issue-details-in-user-notifications.md new file mode 100644 index 0000000..1e8f1cd --- /dev/null +++ b/backlog/tasks/task-86 - Include-config-validation-issue-details-in-user-notifications.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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. + + +## Implementation Notes + + +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. + + +## Final Summary + + +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. + diff --git a/backlog/tasks/task-87 - Fix-plugin-start-flow-so-tokenization-frequency-N1-initialize-correctly.md b/backlog/tasks/task-87 - Fix-plugin-start-flow-so-tokenization-frequency-N1-initialize-correctly.md new file mode 100644 index 0000000..d7a2747 --- /dev/null +++ b/backlog/tasks/task-87 - Fix-plugin-start-flow-so-tokenization-frequency-N1-initialize-correctly.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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. + + +## Implementation Notes + + +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`. + + +## Final Summary + + +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. + diff --git a/config.example.jsonc b/config.example.jsonc index abcdeaf..2c6034c 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -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. } diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 0757cbc..f3e5040 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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' }, ], diff --git a/docs/README.md b/docs/README.md index d0fd3eb..34845a0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index e93dae3..d3d4293 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/docs/file-size-budgets.md b/docs/file-size-budgets.md new file mode 100644 index 0000000..b8e5d0b --- /dev/null +++ b/docs/file-size-budgets.md @@ -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. diff --git a/docs/index.md b/docs/index.md index 8efeeba..46001a0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. --- - - -

AniList Setup

-
-

Embedded AniList page did not render: ${reason}

-

We attempted to open the authorize URL in your default browser automatically.

-

Use one of these links to continue setup:

-

${authorizeUrl}

-

${ANILIST_DEVELOPER_SETTINGS_URL}

-

After login/authorization, copy the token into anilist.accessToken.

-
- -`; - void setupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(fallbackHtml)}`); -} +const registerSubminerProtocolClient = createRegisterSubminerProtocolClientHandler({ + isDefaultApp: () => Boolean(process.defaultApp), + getArgv: () => process.argv, + execPath: process.execPath, + resolvePath: (value) => path.resolve(value), + setAsDefaultProtocolClient: (scheme, appPath, args) => + appPath ? app.setAsDefaultProtocolClient(scheme, appPath, args) : app.setAsDefaultProtocolClient(scheme), + logWarn: (message, details) => logger.warn(message, details), +}); function openAnilistSetupWindow(): void { if (appState.anilistSetupWindow) { @@ -1677,6 +1311,32 @@ function openAnilistSetupWindow(): void { contextIsolation: true, }, }); + const authorizeUrl = buildAnilistSetupUrl({ + authorizeUrl: ANILIST_SETUP_CLIENT_ID_URL, + clientId: ANILIST_DEFAULT_CLIENT_ID, + responseType: ANILIST_SETUP_RESPONSE_TYPE, + }); + const consumeCallbackUrl = (rawUrl: string): boolean => consumeAnilistSetupTokenFromUrl(rawUrl); + + const handleManualAnilistSetupSubmission = (rawUrl: string): boolean => { + if (!rawUrl.startsWith('subminer://anilist-setup')) { + return false; + } + try { + const parsed = new URL(rawUrl); + const accessToken = parsed.searchParams.get('access_token')?.trim() ?? ''; + if (accessToken.length > 0) { + return consumeCallbackUrl( + `${ANILIST_REDIRECT_URI}#access_token=${encodeURIComponent(accessToken)}`, + ); + } + logger.warn('AniList setup submission missing access token'); + return true; + } catch { + logger.warn('AniList setup submission had invalid callback input'); + return true; + } + }; setupWindow.webContents.setWindowOpenHandler(({ url }) => { if (!isAllowedAnilistExternalUrl(url)) { @@ -1687,12 +1347,37 @@ function openAnilistSetupWindow(): void { return { action: 'deny' }; }); setupWindow.webContents.on('will-navigate', (event, url) => { + if (handleManualAnilistSetupSubmission(url)) { + event.preventDefault(); + return; + } + if (consumeCallbackUrl(url)) { + event.preventDefault(); + return; + } + if (url.startsWith(ANILIST_REDIRECT_URI)) { + event.preventDefault(); + return; + } + if (url.startsWith(`${ANILIST_REDIRECT_URI}#`)) { + event.preventDefault(); + return; + } if (isAllowedAnilistSetupNavigationUrl(url)) { return; } event.preventDefault(); logger.warn('Blocked unsafe AniList setup navigation URL', { url }); }); + setupWindow.webContents.on('will-redirect', (event, url) => { + if (!consumeCallbackUrl(url)) { + return; + } + event.preventDefault(); + }); + setupWindow.webContents.on('did-navigate', (_event, url) => { + consumeCallbackUrl(url); + }); setupWindow.webContents.on( 'did-fail-load', @@ -1702,9 +1387,18 @@ function openAnilistSetupWindow(): void { errorDescription, validatedURL, }); - openAnilistSetupInBrowser(); + openAnilistSetupInBrowser({ + authorizeUrl, + openExternal: (url) => shell.openExternal(url), + logError: (message, error) => logger.error(message, error), + }); if (!setupWindow.isDestroyed()) { - loadAnilistSetupFallback(setupWindow, `${errorDescription} (${errorCode})`); + loadAnilistManualTokenEntry({ + setupWindow, + authorizeUrl, + developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, + logWarn: (message, data) => logger.warn(message, data), + }); } }, ); @@ -1713,19 +1407,27 @@ function openAnilistSetupWindow(): void { const loadedUrl = setupWindow.webContents.getURL(); if (!loadedUrl || loadedUrl === 'about:blank') { logger.warn('AniList setup loaded a blank page; using fallback'); - openAnilistSetupInBrowser(); + openAnilistSetupInBrowser({ + authorizeUrl, + openExternal: (url) => shell.openExternal(url), + logError: (message, error) => logger.error(message, error), + }); if (!setupWindow.isDestroyed()) { - loadAnilistSetupFallback(setupWindow, 'blank page'); + loadAnilistManualTokenEntry({ + setupWindow, + authorizeUrl, + developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, + logWarn: (message, data) => logger.warn(message, data), + }); } } }); - void setupWindow.loadURL(buildAnilistSetupUrl()).catch((error) => { - logger.error('AniList setup loadURL rejected', error); - openAnilistSetupInBrowser(); - if (!setupWindow.isDestroyed()) { - loadAnilistSetupFallback(setupWindow, error instanceof Error ? error.message : String(error)); - } + loadAnilistManualTokenEntry({ + setupWindow, + authorizeUrl, + developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, + logWarn: (message, data) => logger.warn(message, data), }); setupWindow.on('closed', () => { @@ -1858,7 +1560,7 @@ async function refreshAnilistClientSecretState(options?: { const now = Date.now(); if (!isAnilistTrackingEnabled(resolved)) { anilistCachedAccessToken = null; - setAnilistClientSecretState({ + anilistStateRuntime.setClientSecretState({ status: 'not_checked', source: 'none', message: 'anilist tracking disabled', @@ -1874,7 +1576,7 @@ async function refreshAnilistClientSecretState(options?: { anilistTokenStore.saveToken(rawAccessToken); } anilistCachedAccessToken = rawAccessToken; - setAnilistClientSecretState({ + anilistStateRuntime.setClientSecretState({ status: 'resolved', source: 'literal', message: 'using configured anilist.accessToken', @@ -1892,7 +1594,7 @@ async function refreshAnilistClientSecretState(options?: { const storedToken = anilistTokenStore.loadToken()?.trim() ?? ''; if (storedToken.length > 0) { anilistCachedAccessToken = storedToken; - setAnilistClientSecretState({ + anilistStateRuntime.setClientSecretState({ status: 'resolved', source: 'stored', message: 'using stored anilist access token', @@ -1904,7 +1606,7 @@ async function refreshAnilistClientSecretState(options?: { } anilistCachedAccessToken = null; - setAnilistClientSecretState({ + anilistStateRuntime.setClientSecretState({ status: 'error', source: 'none', message: 'cannot authenticate without anilist.accessToken', @@ -2009,7 +1711,7 @@ async function processNextAnilistRetryUpdate(): Promise<{ message: string; }> { const queued = anilistUpdateQueue.nextReady(); - refreshAnilistRetryQueueState(); + anilistStateRuntime.refreshRetryQueueState(); if (!queued) { return { ok: true, message: 'AniList queue has no ready items.' }; } @@ -2026,14 +1728,14 @@ async function processNextAnilistRetryUpdate(): Promise<{ anilistUpdateQueue.markSuccess(queued.key); rememberAnilistAttemptedUpdateKey(queued.key); appState.anilistRetryQueueState.lastError = null; - refreshAnilistRetryQueueState(); + anilistStateRuntime.refreshRetryQueueState(); logger.info(`[AniList queue] ${result.message}`); return { ok: true, message: result.message }; } anilistUpdateQueue.markFailure(queued.key, result.message); appState.anilistRetryQueueState.lastError = result.message; - refreshAnilistRetryQueueState(); + anilistStateRuntime.refreshRetryQueueState(); return { ok: false, message: result.message }; } @@ -2086,7 +1788,7 @@ async function maybeRunAnilistPostWatchUpdate(): Promise { if (!accessToken) { anilistUpdateQueue.enqueue(attemptKey, guess.title, guess.episode); anilistUpdateQueue.markFailure(attemptKey, 'cannot authenticate without anilist.accessToken'); - refreshAnilistRetryQueueState(); + anilistStateRuntime.refreshRetryQueueState(); showMpvOsd('AniList: access token not configured'); return; } @@ -2094,7 +1796,7 @@ async function maybeRunAnilistPostWatchUpdate(): Promise { if (result.status === 'updated') { rememberAnilistAttemptedUpdateKey(attemptKey); anilistUpdateQueue.markSuccess(attemptKey); - refreshAnilistRetryQueueState(); + anilistStateRuntime.refreshRetryQueueState(); showMpvOsd(result.message); logger.info(result.message); return; @@ -2102,13 +1804,13 @@ async function maybeRunAnilistPostWatchUpdate(): Promise { if (result.status === 'skipped') { rememberAnilistAttemptedUpdateKey(attemptKey); anilistUpdateQueue.markSuccess(attemptKey); - refreshAnilistRetryQueueState(); + anilistStateRuntime.refreshRetryQueueState(); logger.info(result.message); return; } anilistUpdateQueue.enqueue(attemptKey, guess.title, guess.episode); anilistUpdateQueue.markFailure(attemptKey, result.message); - refreshAnilistRetryQueueState(); + anilistStateRuntime.refreshRetryQueueState(); showMpvOsd(`AniList: ${result.message}`); logger.warn(result.message); } finally { @@ -2140,6 +1842,25 @@ function saveSubtitlePosition(position: SubtitlePosition): void { }); } +registerSubminerProtocolClient(); + +app.on('open-url', (event, rawUrl) => { + event.preventDefault(); + if (!handleAnilistSetupProtocolUrl(rawUrl)) { + logger.warn('Unhandled app protocol URL', { rawUrl }); + } +}); + +app.on('second-instance', (_event, argv) => { + const rawUrl = findAnilistSetupDeepLinkArgvUrl(argv); + if (!rawUrl) { + return; + } + if (!handleAnilistSetupProtocolUrl(rawUrl)) { + logger.warn('Unhandled second-instance protocol URL', { rawUrl }); + } +}); + const startupState = runStartupBootstrapRuntime( createStartupBootstrapRuntimeDeps({ argv: process.argv, @@ -2193,26 +1914,19 @@ const startupState = runStartupBootstrapRuntime( createMpvClient: () => { appState.mpvClient = createMpvClientRuntimeService(); }, - reloadConfig: () => { - const result = configService.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.`, - ); - } - - appLogger.logInfo(`Using config file: ${result.path}`); - if (result.warnings.length > 0) { - appLogger.logWarning(buildConfigWarningSummary(result.path, result.warnings)); - showDesktopNotification('SubMiner', { - body: `${result.warnings.length} config validation issue(s) detected. Defaults were applied where possible. File: ${result.path}`, - }); - } - - configHotReloadRuntime.start(); - void refreshAnilistClientSecretState({ force: true }); - }, + reloadConfig: createReloadConfigHandler({ + reloadConfigStrict: () => configService.reloadConfigStrict(), + logInfo: (message) => appLogger.logInfo(message), + logWarning: (message) => appLogger.logWarning(message), + showDesktopNotification: (title, options) => showDesktopNotification(title, options), + startConfigHotReload: () => configHotReloadRuntime.start(), + refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretState(options), + failHandlers: { + logError: (details) => logger.error(details), + showErrorBox: (title, details) => dialog.showErrorBox(title, details), + quit: () => app.quit(), + }, + }), getResolvedConfig: () => getResolvedConfig(), getConfigWarnings: () => configService.getWarnings(), logConfigWarning: (warning) => appLogger.logConfigWarning(warning), @@ -2250,45 +1964,21 @@ const startupState = runStartupBootstrapRuntime( const tracker = new SubtitleTimingTracker(); appState.subtitleTimingTracker = tracker; }, - createImmersionTracker: () => { - const config = getResolvedConfig(); - if (config.immersionTracking?.enabled === false) { - logger.info('Immersion tracking disabled in config'); - return; - } - try { - logger.debug('Immersion tracker startup requested: creating tracker service.'); - const dbPath = getConfiguredImmersionDbPath(); - logger.info(`Creating immersion tracker with dbPath=${dbPath}`); - const policy = config.immersionTracking; - appState.immersionTracker = new ImmersionTrackerService({ - 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, - }, - }, - }); - logger.debug('Immersion tracker initialized successfully.'); - if (appState.mpvClient && !appState.mpvClient.connected) { - logger.info('Auto-connecting MPV client for immersion tracking'); - appState.mpvClient.connect(); - } - seedImmersionTrackerFromCurrentMedia(); - } catch (error) { - logger.warn('Immersion tracker startup failed; disabling tracking.', error); - appState.immersionTracker = null; - } - }, + createImmersionTracker: createImmersionTrackerStartupHandler({ + getResolvedConfig: () => getResolvedConfig(), + getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(), + createTrackerService: (params) => new ImmersionTrackerService(params), + setTracker: (tracker) => { + appState.immersionTracker = tracker as ImmersionTrackerService | null; + }, + getMpvClient: () => appState.mpvClient, + seedTrackerFromCurrentMedia: () => { + void immersionMediaRuntime.seedFromCurrentMedia(); + }, + logInfo: (message) => logger.info(message), + logDebug: (message) => logger.debug(message), + logWarn: (message, details) => logger.warn(message, details), + }), loadYomitanExtension: async () => { await loadYomitanExtension(); }, @@ -2303,20 +1993,19 @@ const startupState = runStartupBootstrapRuntime( }, texthookerOnlyMode: appState.texthookerOnlyMode, shouldAutoInitializeOverlayRuntimeFromConfig: () => - appState.backgroundMode ? false : shouldAutoInitializeOverlayRuntimeFromConfig(), + appState.backgroundMode + ? false + : configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(), initializeOverlayRuntime: () => initializeOverlayRuntime(), handleInitialArgs: () => handleInitialArgs(), - onCriticalConfigErrors: (errors: string[]) => { - const configPath = configService.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'); - failStartupFromConfig('SubMiner config validation error', details); - }, + onCriticalConfigErrors: createCriticalConfigErrorHandler({ + getConfigPath: () => configService.getConfigPath(), + failHandlers: { + logError: (message) => logger.error(message), + showErrorBox: (title, message) => dialog.showErrorBox(title, message), + quit: () => app.quit(), + }, + }), logDebug: (message: string) => { logger.debug(message); }, @@ -2379,9 +2068,19 @@ const startupState = runStartupBootstrapRuntime( applyStartupState(appState, startupState); void refreshAnilistClientSecretState({ force: true }); -refreshAnilistRetryQueueState(); +anilistStateRuntime.refreshRetryQueueState(); function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'): void { + if ( + appState.texthookerOnlyMode && + !args.texthooker && + (args.start || commandNeedsOverlayRuntime(args)) + ) { + appState.texthookerOnlyMode = false; + logger.info('Disabling texthooker-only mode after overlay/start command.'); + startBackgroundWarmups(); + } + handleCliCommandRuntimeServiceWithContext(args, source, { getSocketPath: () => appState.mpvSocketPath, setSocketPath: (socketPath: string) => { @@ -2416,11 +2115,11 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'): triggerFieldGrouping: () => triggerFieldGrouping(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), markLastCardAsAudioCard: () => markLastCardAsAudioCard(), - getAnilistStatus: () => getAnilistStatusSnapshot(), - clearAnilistToken: () => clearAnilistTokenState(), + getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), + clearAnilistToken: () => anilistStateRuntime.clearTokenState(), openAnilistSetup: () => openAnilistSetupWindow(), openJellyfinSetup: () => openJellyfinSetupWindow(), - getAnilistQueueStatus: () => getAnilistQueueStatusSnapshot(), + getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), openYomitanSettings: () => openYomitanSettings(), @@ -2508,14 +2207,14 @@ function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void { void maybeProbeAnilistDuration(mediaKey); void ensureAnilistMediaGuess(mediaKey); } - syncImmersionTrackerFromCurrentMediaState(); + immersionMediaRuntime.syncFromCurrentMediaState(); }); mpvClient.on('media-title-change', ({ title }) => { mediaRuntime.updateCurrentMediaTitle(title); anilistCurrentMediaGuess = null; anilistCurrentMediaGuessPromise = null; appState.immersionTracker?.handleMediaTitleUpdate(title); - syncImmersionTrackerFromCurrentMediaState(); + immersionMediaRuntime.syncFromCurrentMediaState(); }); mpvClient.on('time-pos-change', ({ time }) => { appState.immersionTracker?.recordPlaybackPosition(time); @@ -2538,7 +2237,8 @@ function createMpvClientRuntimeService(): MpvIpcClient { getResolvedConfig: () => getResolvedConfig(), autoStartOverlay: appState.autoStartOverlay, setOverlayVisible: (visible: boolean) => setOverlayVisible(visible), - shouldBindVisibleOverlayToMpvSubVisibility: () => shouldBindVisibleOverlayToMpvSubVisibility(), + shouldBindVisibleOverlayToMpvSubVisibility: () => + configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(), isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getReconnectTimer: () => appState.reconnectTimer, setReconnectTimer: (timer: ReturnType | null) => { @@ -2858,7 +2558,8 @@ function initializeOverlayRuntime(): void { } const result = initializeOverlayRuntimeCore({ backendOverride: appState.backendOverride, - getInitialInvisibleOverlayVisibility: () => getInitialInvisibleOverlayVisibility(), + getInitialInvisibleOverlayVisibility: () => + configDerivedRuntime.getInitialInvisibleOverlayVisibility(), createMainWindow: () => { createMainWindow(); }, @@ -2988,25 +2689,8 @@ const numericShortcutRuntime = createNumericShortcutRuntime({ const multiCopySession = numericShortcutRuntime.createSession(); const mineSentenceSession = numericShortcutRuntime.createSession(); -function getSubsyncRuntimeServiceParams() { - return createSubsyncRuntimeServiceInputFromState({ - getMpvClient: () => appState.mpvClient, - getResolvedSubsyncConfig: () => getSubsyncConfig(getResolvedConfig().subsync), - getSubsyncInProgress: () => appState.subsyncInProgress, - setSubsyncInProgress: (inProgress: boolean) => { - appState.subsyncInProgress = inProgress; - }, - showMpvOsd: (text: string) => showMpvOsd(text), - openManualPicker: (payload: SubsyncManualPayload) => { - sendToActiveOverlayWindow('subsync:open-manual', payload, { - restoreOnModalClose: 'subsync', - }); - }, - }); -} - async function triggerSubsyncFromConfig(): Promise { - await triggerSubsyncFromConfigRuntime(getSubsyncRuntimeServiceParams()); + await subsyncRuntime.triggerFromConfig(); } function cancelPendingMultiCopy(): void { @@ -3139,7 +2823,8 @@ function setVisibleOverlayVisible(visible: boolean): void { overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), syncInvisibleOverlayMousePassthrough: () => overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(), - shouldBindVisibleOverlayToMpvSubVisibility: () => shouldBindVisibleOverlayToMpvSubVisibility(), + shouldBindVisibleOverlayToMpvSubVisibility: () => + configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(), isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), setMpvSubVisibility: (mpvSubVisible) => { setMpvSubVisibilityRuntime(appState.mpvClient, mpvSubVisible); @@ -3200,31 +2885,18 @@ function handleMpvCommandFromIpc(command: (string | number)[]): void { } async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promise { - return runSubsyncManualFromIpcRuntime(request, getSubsyncRuntimeServiceParams()); + return subsyncRuntime.runManualFromIpc(request); } function appendClipboardVideoToQueue(): { ok: boolean; message: string } { - const mpvClient = appState.mpvClient; - if (!mpvClient || !mpvClient.connected) { - return { ok: false, message: 'MPV is not connected.' }; - } - - const clipboardText = clipboard.readText(); - const parsedPath = parseClipboardVideoPath(clipboardText); - if (!parsedPath) { - 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()) { - showMpvOsd('Clipboard path is not a readable file.'); - return { ok: false, message: 'Clipboard path is not a readable file.' }; - } - - sendMpvCommandRuntime(mpvClient, ['loadfile', resolvedPath, 'append']); - showMpvOsd(`Queued from clipboard: ${path.basename(resolvedPath)}`); - return { ok: true, message: `Queued ${resolvedPath}` }; + return appendClipboardVideoToQueueRuntime({ + getMpvClient: () => appState.mpvClient, + readClipboardText: () => clipboard.readText(), + showMpvOsd: (text) => showMpvOsd(text), + sendMpvCommand: (command) => { + sendMpvCommandRuntime(appState.mpvClient, command); + }, + }); } registerIpcRuntimeServices({ @@ -3273,10 +2945,10 @@ registerIpcRuntimeServices({ reportOverlayContentBounds: (payload: unknown) => { overlayContentMeasurementStore.report(payload); }, - getAnilistStatus: () => getAnilistStatusSnapshot(), - clearAnilistToken: () => clearAnilistTokenState(), + getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), + clearAnilistToken: () => anilistStateRuntime.clearTokenState(), openAnilistSetup: () => openAnilistSetupWindow(), - getAnilistQueueStatus: () => getAnilistQueueStatusSnapshot(), + getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(), }, @@ -3305,10 +2977,10 @@ registerIpcRuntimeServices({ jimakuFetchJson: ( endpoint: string, query?: Record, - ): Promise> => jimakuFetchJson(endpoint, query), - getJimakuMaxEntryResults: () => getJimakuMaxEntryResults(), - getJimakuLanguagePreference: () => getJimakuLanguagePreference(), - resolveJimakuApiKey: () => resolveJimakuApiKey(), + ): Promise> => configDerivedRuntime.jimakuFetchJson(endpoint, query), + getJimakuMaxEntryResults: () => configDerivedRuntime.getJimakuMaxEntryResults(), + getJimakuLanguagePreference: () => configDerivedRuntime.getJimakuLanguagePreference(), + resolveJimakuApiKey: () => configDerivedRuntime.resolveJimakuApiKey(), isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath), downloadToFile: (url: string, destPath: string, headers: Record) => downloadToFile(url, destPath, headers), diff --git a/src/main/config-validation.test.ts b/src/main/config-validation.test.ts new file mode 100644 index 0000000..139fb45 --- /dev/null +++ b/src/main/config-validation.test.ts @@ -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; +}); diff --git a/src/main/config-validation.ts b/src/main/config-validation.ts new file mode 100644 index 0000000..0e7914e --- /dev/null +++ b/src/main/config-validation.ts @@ -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); +} diff --git a/src/main/runtime/anilist-setup-protocol.test.ts b/src/main/runtime/anilist-setup-protocol.test.ts new file mode 100644 index 0000000..183b938 --- /dev/null +++ b/src/main/runtime/anilist-setup-protocol.test.ts @@ -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']); +}); diff --git a/src/main/runtime/anilist-setup-protocol.ts b/src/main/runtime/anilist-setup-protocol.ts new file mode 100644 index 0000000..6c119ce --- /dev/null +++ b/src/main/runtime/anilist-setup-protocol.ts @@ -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); + } + }; +} diff --git a/src/main/runtime/anilist-setup.test.ts b/src/main/runtime/anilist-setup.test.ts new file mode 100644 index 0000000..80e8247 --- /dev/null +++ b/src/main/runtime/anilist-setup.test.ts @@ -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: '', + authorizeUrl: 'https://anilist.example/auth', + developerSettingsUrl: 'https://anilist.example/dev', + }); + assert.equal(html.includes(''), 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, []); +}); diff --git a/src/main/runtime/anilist-setup.ts b/src/main/runtime/anilist-setup.ts new file mode 100644 index 0000000..afca57a --- /dev/null +++ b/src/main/runtime/anilist-setup.ts @@ -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; + 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, '>'); + const safeAuth = params.authorizeUrl.replace(/"/g, '"'); + const safeDev = params.developerSettingsUrl.replace(/"/g, '"'); + return ` +AniList Setup + +

AniList setup

+

Automatic page load failed (${safeReason}).

+

Open AniList authorize page

+

Open AniList developer settings

+`; +} + +export function buildAnilistManualTokenEntryHtml(params: { + authorizeUrl: string; + developerSettingsUrl: string; +}): string { + const safeAuth = params.authorizeUrl.replace(/"/g, '"'); + const safeDev = params.developerSettingsUrl.replace(/"/g, '"'); + return ` +AniList Setup + +

AniList setup

+

Authorize in browser, then paste the access token below.

+

Open AniList authorize page

+

Open AniList developer settings

+
+
+ +
+ +
+ +`; +} + +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, + }); +} diff --git a/src/main/runtime/anilist-state.test.ts b/src/main/runtime/anilist-state.test.ts new file mode 100644 index 0000000..27b3e35 --- /dev/null +++ b/src/main/runtime/anilist-state.test.ts @@ -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, + }); +}); diff --git a/src/main/runtime/anilist-state.ts b/src/main/runtime/anilist-state.ts new file mode 100644 index 0000000..1a90230 --- /dev/null +++ b/src/main/runtime/anilist-state.ts @@ -0,0 +1,97 @@ +import type { AnilistRetryQueueState, AnilistSecretResolutionState } from '../state'; + +type AnilistQueueSnapshot = Pick; + +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) => void; + refreshRetryQueueState: () => void; + getStatusSnapshot: () => AnilistStatusSnapshot; + getQueueStatusSnapshot: () => AnilistRetryQueueState; + clearTokenState: () => void; +} { + const setClientSecretState = (partial: Partial): 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, + }; +} diff --git a/src/main/runtime/clipboard-queue.test.ts b/src/main/runtime/clipboard-queue.test.ts new file mode 100644 index 0000000..08d23bc --- /dev/null +++ b/src/main/runtime/clipboard-queue.test.ts @@ -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); +}); diff --git a/src/main/runtime/clipboard-queue.ts b/src/main/runtime/clipboard-queue.ts new file mode 100644 index 0000000..b718af8 --- /dev/null +++ b/src/main/runtime/clipboard-queue.ts @@ -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}` }; +} diff --git a/src/main/runtime/config-derived.ts b/src/main/runtime/config-derived.ts new file mode 100644 index 0000000..3c90fc0 --- /dev/null +++ b/src/main/runtime/config-derived.ts @@ -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; + jimakuFetchJson: ( + endpoint: string, + query?: Record, + ) => Promise>; +} { + 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: ( + endpoint: string, + query: Record = {}, + ): Promise> => + jimakuFetchJsonCore(endpoint, query, { + getResolvedConfig: () => deps.getResolvedConfig(), + defaultBaseUrl: deps.defaultJimakuApiBaseUrl, + defaultMaxEntryResults: deps.defaultJimakuMaxEntryResults, + defaultLanguagePreference: deps.defaultJimakuLanguagePreference, + }), + }; +} diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts new file mode 100644 index 0000000..344b67e --- /dev/null +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -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', + ); +}); diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts new file mode 100644 index 0000000..3e41643 --- /dev/null +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -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(', ')}`; +} diff --git a/src/main/runtime/immersion-media.test.ts b/src/main/runtime/immersion-media.test.ts new file mode 100644 index 0000000..14f1fc5 --- /dev/null +++ b/src/main/runtime/immersion-media.test.ts @@ -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' }]); +}); diff --git a/src/main/runtime/immersion-media.ts b/src/main/runtime/immersion-media.ts new file mode 100644 index 0000000..35ddd27 --- /dev/null +++ b/src/main/runtime/immersion-media.ts @@ -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; +}; + +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; + 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 { + 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; + 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 => { + 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 => { + 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, + }; +} diff --git a/src/main/runtime/immersion-startup.test.ts b/src/main/runtime/immersion-startup.test.ts new file mode 100644 index 0000000..9e3b6ce --- /dev/null +++ b/src/main/runtime/immersion-startup.test.ts @@ -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'), + ); +}); diff --git a/src/main/runtime/immersion-startup.ts b/src/main/runtime/immersion-startup.ts new file mode 100644 index 0000000..cda2fc2 --- /dev/null +++ b/src/main/runtime/immersion-startup.ts @@ -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; + +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); + } + }; +} diff --git a/src/main/runtime/jellyfin-remote-commands.test.ts b/src/main/runtime/jellyfin-remote-commands.test.ts new file mode 100644 index 0000000..313545e --- /dev/null +++ b/src/main/runtime/jellyfin-remote-commands.test.ts @@ -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')), + ); +}); diff --git a/src/main/runtime/jellyfin-remote-commands.ts b/src/main/runtime/jellyfin-remote-commands.ts new file mode 100644 index 0000000..bc30fc1 --- /dev/null +++ b/src/main/runtime/jellyfin-remote-commands.ts @@ -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; + logWarn: (message: string) => void; +}; + +export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDeps) { + return async (payload: unknown): Promise => { + const session = deps.getConfiguredSession(); + if (!session) return; + const clientInfo = deps.getClientInfo(); + const jellyfinConfig = deps.getJellyfinConfig(); + const data = payload && typeof payload === 'object' ? (payload as Record) : {}; + 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; + reportJellyfinRemoteStopped: () => Promise; + jellyfinTicksToSeconds: (ticks: number) => number; +}; + +export function createHandleJellyfinRemotePlaystate(deps: JellyfinRemotePlaystateHandlerDeps) { + return async (payload: unknown): Promise => { + const data = payload && typeof payload === 'object' ? (payload as Record) : {}; + 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; + logDebug: (message: string) => void; +}; + +export function createHandleJellyfinRemoteGeneralCommand( + deps: JellyfinRemoteGeneralCommandHandlerDeps, +) { + return async (payload: unknown): Promise => { + const data = payload && typeof payload === 'object' ? (payload as Record) : {}; + const command = String(data.Name || ''); + const args = + data.Arguments && typeof data.Arguments === 'object' + ? (data.Arguments as Record) + : {}; + 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}`); + }; +} diff --git a/src/main/runtime/jellyfin-remote-connection.test.ts b/src/main/runtime/jellyfin-remote-connection.test.ts new file mode 100644 index 0000000..3fe4502 --- /dev/null +++ b/src/main/runtime/jellyfin-remote-connection.test.ts @@ -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 | 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((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); +}); diff --git a/src/main/runtime/jellyfin-remote-connection.ts b/src/main/runtime/jellyfin-remote-connection.ts new file mode 100644 index 0000000..8f34f1e --- /dev/null +++ b/src/main/runtime/jellyfin-remote-connection.ts @@ -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; +}; + +export function createWaitForMpvConnectedHandler(deps: WaitForMpvConnectedDeps) { + return async (timeoutMs = 7000): Promise => { + 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; + launchMpvIdleForJellyfinPlayback: () => void; + getAutoLaunchInFlight: () => Promise | null; + setAutoLaunchInFlight: (promise: Promise | null) => void; + connectTimeoutMs: number; + autoLaunchTimeoutMs: number; +}; + +export function createEnsureMpvConnectedForJellyfinPlaybackHandler(deps: EnsureMpvConnectedDeps) { + return async (): Promise => { + 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; + }; +} diff --git a/src/main/runtime/jellyfin-remote-playback.test.ts b/src/main/runtime/jellyfin-remote-playback.test.ts new file mode 100644 index 0000000..743018c --- /dev/null +++ b/src/main/runtime/jellyfin-remote-playback.test.ts @@ -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); +}); diff --git a/src/main/runtime/jellyfin-remote-playback.ts b/src/main/runtime/jellyfin-remote-playback.ts new file mode 100644 index 0000000..e39ffec --- /dev/null +++ b/src/main/runtime/jellyfin-remote-playback.ts @@ -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; + reportStopped: (payload: { + itemId: string; + mediaSourceId?: string; + playMethod: 'DirectPlay' | 'Transcode'; + audioStreamIndex?: number | null; + subtitleStreamIndex?: number | null; + eventName: 'stop'; + }) => Promise; +}; + +type MpvClientLike = { + requestProperty: (name: string) => Promise; +}; + +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 => { + 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 => { + 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(); + } + }; +} diff --git a/src/main/runtime/startup-config.test.ts b/src/main/runtime/startup-config.test.ts new file mode 100644 index 0000000..2645a77 --- /dev/null +++ b/src/main/runtime/startup-config.test.ts @@ -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; +}); diff --git a/src/main/runtime/startup-config.ts b/src/main/runtime/startup-config.ts new file mode 100644 index 0000000..2258cd0 --- /dev/null +++ b/src/main/runtime/startup-config.ts @@ -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; + 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); + }; +} diff --git a/src/main/runtime/subsync-runtime.ts b/src/main/runtime/subsync-runtime.ts new file mode 100644 index 0000000..b0dcdb4 --- /dev/null +++ b/src/main/runtime/subsync-runtime.ts @@ -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; + runManualFromIpc: (request: SubsyncManualRunRequest) => Promise; +} { + 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 => { + await triggerSubsyncFromConfigRuntime(getRuntimeServiceParams()); + }, + runManualFromIpc: async (request: SubsyncManualRunRequest): Promise => + runSubsyncManualFromIpcRuntime(request, getRuntimeServiceParams()), + }; +}