feat(core): add Discord presence service and extract Jellyfin runtime composition

Introduce Discord presence runtime support and continue composition-root decomposition by moving Jellyfin wiring into dedicated composer modules. This keeps main runtime orchestration thinner while preserving behavior and test coverage across config, runtime, and docs updates.
This commit is contained in:
2026-02-22 14:53:10 -08:00
parent 43a8a37f5b
commit edfe6640ac
52 changed files with 2222 additions and 317 deletions

View File

@@ -1,10 +1,11 @@
--- ---
id: TASK-103 id: TASK-103
title: Extract Jellyfin runtime wiring from main.ts composition root title: Extract Jellyfin runtime wiring from main.ts composition root
status: To Do status: Done
assignee: [] assignee:
- codex-task103-jellyfin-main-composer-20260222T220441Z-m8p1
created_date: '2026-02-22 07:13' created_date: '2026-02-22 07:13'
updated_date: '2026-02-22 07:13' updated_date: '2026-02-22 22:48'
labels: labels:
- refactor - refactor
- maintainability - maintainability
@@ -36,16 +37,52 @@ Goal: finish extraction of Jellyfin-specific dependency construction and command
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 `src/main.ts` no longer contains the full Jellyfin deps-building block. - [x] #1 `src/main.ts` no longer contains the full Jellyfin deps-building block.
- [ ] #2 New Jellyfin composer modules have focused tests covering handler wiring. - [x] #2 New Jellyfin composer modules have focused tests covering handler wiring.
- [ ] #3 `bun run check:main-fanin` stays green after extraction. - [x] #3 `bun run check:main-fanin` stays green after extraction.
- [ ] #4 `bun run build` and `bun run test:core:src` pass. - [x] #4 `bun run build` and `bun run test:core:src` pass.
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
Plan artifact: `docs/plans/2026-02-22-task-103-jellyfin-runtime-wiring.md`.
Execution plan:
1) Add `composeJellyfinRuntimeHandlers` composer module + seam tests and composer barrel export.
2) Replace inline Jellyfin deps-building block in `src/main.ts` with one composer invocation while preserving existing callsites (`runJellyfinCommand`, `openJellyfinSetupWindow`, remote session lifecycle, playback).
3) Update architecture docs ownership bullets for Jellyfin composer boundary.
4) Run required gates: `bun run check:main-fanin`, `bun run build`, `bun run test:core:src`.
5) Record AC/DoD evidence and final summary in Backlog task (no commit).
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-02-22T22:04:41Z: Execution started via Backlog MCP workflow. Loading task context, creating written plan artifact, then implementing extraction and focused validations without commit.
2026-02-22T22:38:58Z: Added new Jellyfin composition entrypoint `src/main/runtime/composers/jellyfin-runtime-composer.ts` and seam test `src/main/runtime/composers/jellyfin-runtime-composer.test.ts`; exported via composer barrel.
2026-02-22T22:38:58Z: Replaced inline Jellyfin deps-building block in `src/main.ts` with a single `composeJellyfinRuntimeHandlers(...)` invocation returning config/client/playback/remote/session/command/setup handlers used by existing callsites.
2026-02-22T22:38:58Z: Validation: `bun test src/main/runtime/composers/jellyfin-runtime-composer.test.ts src/main/runtime/composers/jellyfin-remote-composer.test.ts` PASS (2/2), `bun run check:main-fanin` PASS (`15 import lines, 9 unique runtime paths`), `bun run test:core:src` PASS (241 pass, 6 skip).
2026-02-22T22:38:58Z: `bun run build` currently FAILS in current working tree due pre-existing duplicate/invalid `src/main.ts` imports unrelated to TASK-103 extraction (many TS2300/TS2724 import-surface errors already present in file).
2026-02-22T22:49:05Z: Re-ran required completion gates after user fix: `bun run build` PASS, `bun run test:core:src` PASS (241 pass, 6 skip), `bun run check:main-fanin` PASS (`14 import lines, 11 unique runtime paths`).
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Extracted Jellyfin composition-root wiring from `src/main.ts` into a dedicated runtime composer entrypoint (`composeJellyfinRuntimeHandlers`) so main now delegates Jellyfin config resolution, client-info wiring, playback orchestration, remote session handlers, command dispatch, and setup-window wiring through a single composer surface. Added focused seam coverage in `src/main/runtime/composers/jellyfin-runtime-composer.test.ts`, retained existing `jellyfin-remote-composer` coverage, and updated architecture ownership docs to declare Jellyfin composer boundaries.
Validation run: `bun test src/main/runtime/composers/jellyfin-runtime-composer.test.ts src/main/runtime/composers/jellyfin-remote-composer.test.ts` (pass), `bun run check:main-fanin` (pass), `bun run build` (pass), and `bun run test:core:src` (pass).
<!-- SECTION:FINAL_SUMMARY:END -->
## Definition of Done ## Definition of Done
<!-- DOD:BEGIN --> <!-- DOD:BEGIN -->
- [ ] #1 `src/main.ts` LOC reduced materially from current baseline. - [x] #1 `src/main.ts` LOC reduced materially from current baseline.
- [ ] #2 Jellyfin runtime wiring is centralized in named composer module(s) with clear ownership docs. - [x] #2 Jellyfin runtime wiring is centralized in named composer module(s) with clear ownership docs.
- [ ] #3 No behavior regressions in Jellyfin command/setup flows. - [x] #3 No behavior regressions in Jellyfin command/setup flows.
<!-- DOD:END --> <!-- DOD:END -->

View File

@@ -0,0 +1,62 @@
---
id: TASK-109
title: Add Discord Rich Presence integration with polished activity card
status: In Progress
assignee: []
created_date: '2026-02-22 19:40'
updated_date: '2026-02-22 22:36'
labels:
- feature
- discord
- presence
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add optional Discord Rich Presence support so SubMiner can publish current activity (show/file context, study/mining status, elapsed session) with a polished, readable Discord activity card.
<!-- SECTION:DESCRIPTION:END -->
## Action Steps
<!-- SECTION:PLAN:BEGIN -->
1. Choose and add a Discord RPC client strategy compatible with Electron + launcher runtime.
2. Add config toggle and safe defaults so Discord presence is opt-in.
3. Define presence payload model (state/details/timestamps/assets/buttons) from existing runtime session metadata.
4. Wire lifecycle updates for start, pause/resume, episode/file changes, and stop/quit.
5. Design assets/text for a clean, branded activity box that is informative without noisy updates.
6. Add debounce/rate-limit handling and fallback behavior when Discord is unavailable.
7. Add focused tests for payload mapping and lifecycle transitions; document setup in user docs.
<!-- SECTION:PLAN:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Discord Rich Presence can be enabled via config and remains disabled by default.
- [ ] #2 Activity card shows clear state/details and updates correctly across playback/session transitions.
- [ ] #3 Activity card visuals (assets/text) are polished and consistent with project branding.
- [ ] #4 Runtime handles Discord closed/not installed/disconnected without crashes or noisy logs.
- [ ] #5 Docs include setup steps (app/client id), config keys, and troubleshooting notes.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented opt-in `discordPresence` config surface in types/defaults/resolver/option-registry/template sections with clamped timing fields and focused config tests.
Added `src/core/services/discord-presence.ts` with payload mapping, debounce+interval update throttling, duplicate suppression, and resilient login/set/clear error handling.
Wired MPV/runtime lifecycle hooks to refresh Discord presence on subtitle/media/path/time/pause/connection transitions and added cleanup stop hook in app lifecycle cleanup path + focused tests.
Updated docs and generated config examples with Discord Rich Presence setup/config/troubleshooting guidance.
Validation status: focused config/runtime/discord tests pass and docs build passes. Full `bun run build` currently blocked by pre-existing `src/main.ts` duplicate imports/symbol errors unrelated to TASK-109 scope (existing in working tree).
<!-- SECTION:NOTES:END -->
## Definition of Done
<!-- DOD:BEGIN -->
- [ ] #1 Focused tests cover presence payload mapping and lifecycle update behavior.
- [ ] #2 Manual validation confirms Discord card appearance/updates for at least one real playback session.
- [ ] #3 Build/test/docs gates pass with no regressions.
<!-- DOD:END -->

View File

@@ -8,6 +8,7 @@
"@catppuccin/vitepress": "^0.1.2", "@catppuccin/vitepress": "^0.1.2",
"axios": "^1.13.5", "axios": "^1.13.5",
"commander": "^14.0.3", "commander": "^14.0.3",
"discord-rpc": "^4.0.1",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"mermaid": "^11.12.3", "mermaid": "^11.12.3",
"ws": "^8.19.0", "ws": "^8.19.0",
@@ -441,6 +442,8 @@
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "5.7.1", "inherits": "2.0.4", "readable-stream": "3.6.2" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "5.7.1", "inherits": "2.0.4", "readable-stream": "3.6.2" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
@@ -627,6 +630,8 @@
"dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "3.1.2", "p-limit": "3.1.0" } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "3.1.2", "p-limit": "3.1.0" } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="],
"discord-rpc": ["discord-rpc@4.0.1", "", { "dependencies": { "node-fetch": "^2.6.1", "ws": "^7.3.1" }, "optionalDependencies": { "register-scheme": "github:devsnek/node-register-scheme" } }, "sha512-HOvHpbq5STRZJjQIBzwoKnQ0jHplbEWFWlPDwXXKm/bILh4nzjcg7mNqll0UY7RsjFoaXA7e/oYb/4lvpda2zA=="],
"dmg-builder": ["dmg-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="], "dmg-builder": ["dmg-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="],
"dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "3.0.5", "@types/verror": "1.10.11", "ajv": "6.12.6", "crc": "3.8.0", "iconv-corefoundation": "1.1.7", "plist": "3.1.0", "smart-buffer": "4.2.0", "verror": "1.10.1" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], "dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "3.0.5", "@types/verror": "1.10.11", "ajv": "6.12.6", "crc": "3.8.0", "iconv-corefoundation": "1.1.7", "plist": "3.1.0", "smart-buffer": "4.2.0", "verror": "1.10.1" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="],
@@ -699,6 +704,8 @@
"fdir": ["fdir@6.5.0", "", { "optionalDependencies": { "picomatch": "4.0.3" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "optionalDependencies": { "picomatch": "4.0.3" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "5.1.6" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="], "filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "5.1.6" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="],
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="], "focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
@@ -913,6 +920,8 @@
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "7.7.4" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "7.7.4" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "2.2.1", "exponential-backoff": "3.1.3", "graceful-fs": "4.2.11", "make-fetch-happen": "14.0.3", "nopt": "8.1.0", "proc-log": "5.0.0", "semver": "7.7.4", "tar": "7.5.9", "tinyglobby": "0.2.15", "which": "5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="], "node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "2.2.1", "exponential-backoff": "3.1.3", "graceful-fs": "4.2.11", "make-fetch-happen": "14.0.3", "nopt": "8.1.0", "proc-log": "5.0.0", "semver": "7.7.4", "tar": "7.5.9", "tinyglobby": "0.2.15", "which": "5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="],
"nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "3.0.1" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "3.0.1" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
@@ -1003,6 +1012,8 @@
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"register-scheme": ["register-scheme@github:devsnek/node-register-scheme#e7cc9a6", { "dependencies": { "bindings": "^1.3.0", "node-addon-api": "^1.3.0" } }, "devsnek-node-register-scheme-e7cc9a6"],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
@@ -1117,6 +1128,8 @@
"tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "0.2.5" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "0.2.5" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "1.0.5" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "1.0.5" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="],
@@ -1181,6 +1194,10 @@
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "1.0.4" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "1.0.4" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "3.1.5" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "which": ["which@5.0.0", "", { "dependencies": { "isexe": "3.1.5" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -1273,6 +1290,8 @@
"dir-compare/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "1.1.12" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "dir-compare/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "1.1.12" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"discord-rpc/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
"electron/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], "electron/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="],
"electron-builder-squirrel-windows/app-builder-lib": ["app-builder-lib@26.7.0", "", { "dependencies": { "@develar/schema-utils": "2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "1.8.0", "@electron/get": "3.1.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "2.0.1", "builder-util": "26.4.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "0.2.0", "ci-info": "4.3.1", "debug": "4.4.3", "dotenv": "16.6.1", "dotenv-expand": "11.0.7", "ejs": "3.1.10", "electron-publish": "26.6.0", "fs-extra": "10.1.0", "hosted-git-info": "4.1.0", "isbinaryfile": "5.0.7", "jiti": "2.6.1", "js-yaml": "4.1.1", "json5": "2.2.3", "lazy-val": "1.0.5", "minimatch": "10.2.0", "plist": "3.1.0", "proper-lockfile": "4.1.2", "resedit": "1.7.2", "semver": "7.7.4", "tar": "7.5.9", "temp-file": "3.4.0", "tiny-async-pool": "1.3.0", "which": "5.0.0" }, "peerDependencies": { "dmg-builder": "26.7.0", "electron-builder-squirrel-windows": "26.7.0" } }, "sha512-/UgCD8VrO79Wv8aBNpjMfsS1pIUfIPURoRn0Ik6tMe5avdZF+vQgl/juJgipcMmH3YS0BD573lCdCHyoi84USg=="], "electron-builder-squirrel-windows/app-builder-lib": ["app-builder-lib@26.7.0", "", { "dependencies": { "@develar/schema-utils": "2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "1.8.0", "@electron/get": "3.1.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "2.0.1", "builder-util": "26.4.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "0.2.0", "ci-info": "4.3.1", "debug": "4.4.3", "dotenv": "16.6.1", "dotenv-expand": "11.0.7", "ejs": "3.1.10", "electron-publish": "26.6.0", "fs-extra": "10.1.0", "hosted-git-info": "4.1.0", "isbinaryfile": "5.0.7", "jiti": "2.6.1", "js-yaml": "4.1.1", "json5": "2.2.3", "lazy-val": "1.0.5", "minimatch": "10.2.0", "plist": "3.1.0", "proper-lockfile": "4.1.2", "resedit": "1.7.2", "semver": "7.7.4", "tar": "7.5.9", "temp-file": "3.4.0", "tiny-async-pool": "1.3.0", "which": "5.0.0" }, "peerDependencies": { "dmg-builder": "26.7.0", "electron-builder-squirrel-windows": "26.7.0" } }, "sha512-/UgCD8VrO79Wv8aBNpjMfsS1pIUfIPURoRn0Ik6tMe5avdZF+vQgl/juJgipcMmH3YS0BD573lCdCHyoi84USg=="],

View File

@@ -46,6 +46,125 @@
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
}, // Controls logging verbosity. }, // Controls logging verbosity.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
// ==========================================
"shortcuts": {
"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
// Startup behavior for the invisible interactive subtitle mining layer.
// Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.
// This edit-mode shortcut is fixed and is not currently configurable.
// ==========================================
"invisibleOverlay": {
"startupVisibility": "platform-default" // Startup visibility setting.
}, // Startup behavior for the invisible interactive subtitle mining layer.
// ==========================================
// Keybindings (MPV Commands)
// Extra keybindings that are merged with built-in defaults.
// Set command to null to disable a default keybinding.
// Hot-reload: keybinding changes apply live and update the session help modal on reopen.
// ==========================================
"keybindings": [], // Extra keybindings that are merged with built-in defaults.
// ==========================================
// Secondary Subtitles
// Dual subtitle track options.
// Used by subminer YouTube subtitle generation as secondary language preferences.
// Hot-reload: defaultMode updates live while SubMiner is running.
// ==========================================
"secondarySub": {
"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", // 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 // Y percent setting.
}, // Initial vertical subtitle position from the bottom.
// ==========================================
// Subtitle Appearance
// Primary and secondary subtitle styling.
// Hot-reload: subtitle style changes apply live without restarting SubMiner.
// ==========================================
"subtitleStyle": {
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv.
"fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif", // Font family setting.
"fontSize": 35, // Font size setting.
"fontColor": "#cad3f5", // Font color setting.
"fontWeight": "normal", // Font weight setting.
"fontStyle": "normal", // Font style setting.
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
"knownWordColor": "#a6da95", // Known word color setting.
"jlptColors": {
"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, // 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, // Font size setting.
"fontColor": "#ffffff", // Font color setting.
"backgroundColor": "transparent", // Background color setting.
"fontWeight": "normal", // Font weight setting.
"fontStyle": "normal", // Font style setting.
"fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif" // Font family setting.
} // Secondary setting.
}, // Primary and secondary subtitle styling.
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // Automatic Anki updates and media generation options.
@@ -119,124 +238,6 @@
} // Is kiku setting. } // Is kiku setting.
}, // Automatic Anki updates and media generation options. }, // Automatic Anki updates and media generation options.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
// ==========================================
"shortcuts": {
"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
// Startup behavior for the invisible interactive subtitle mining layer.
// Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.
// This edit-mode shortcut is fixed and is not currently configurable.
// ==========================================
"invisibleOverlay": {
"startupVisibility": "platform-default" // Startup visibility setting.
}, // Startup behavior for the invisible interactive subtitle mining layer.
// ==========================================
// Keybindings (MPV Commands)
// Extra keybindings that are merged with built-in defaults.
// Set command to null to disable a default keybinding.
// Hot-reload: keybinding changes apply live and update the session help modal on reopen.
// ==========================================
"keybindings": [], // Extra keybindings that are merged with built-in defaults.
// ==========================================
// Subtitle Appearance
// Primary and secondary subtitle styling.
// Hot-reload: subtitle style changes apply live without restarting SubMiner.
// ==========================================
"subtitleStyle": {
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"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": "rgb(30, 32, 48, 0.88)", // Background color setting.
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
"knownWordColor": "#a6da95", // Known word color setting.
"jlptColors": {
"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, // 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, // 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
// Dual subtitle track options.
// Used by subminer YouTube subtitle generation as secondary language preferences.
// Hot-reload: defaultMode updates live while SubMiner is running.
// ==========================================
"secondarySub": {
"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", // 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 // Y percent setting.
}, // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
// Jimaku // Jimaku
// Jimaku API configuration and defaults. // Jimaku API configuration and defaults.
@@ -273,14 +274,13 @@
// ========================================== // ==========================================
// Jellyfin // Jellyfin
// Optional Jellyfin integration for auth, browsing, and playback launch. // Optional Jellyfin integration for auth, browsing, and playback launch.
// Access token is stored in config and should be treated as a secret. // Access token is stored in local encrypted token storage after login/setup.
// jellyfin.accessToken remains an optional explicit override in config.
// ========================================== // ==========================================
"jellyfin": { "jellyfin": {
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false "enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"username": "", // Default Jellyfin username used during CLI login. "username": "", // Default Jellyfin username used during CLI login.
"accessToken": "", // Access token setting.
"userId": "", // User id setting.
"deviceId": "subminer", // Device id setting. "deviceId": "subminer", // Device id setting.
"clientName": "SubMiner", // Client name setting. "clientName": "SubMiner", // Client name setting.
"clientVersion": "0.1.0", // Client version setting. "clientVersion": "0.1.0", // Client version setting.
@@ -304,6 +304,26 @@
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable. "transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
}, // Optional Jellyfin integration for auth, browsing, and playback launch. }, // Optional Jellyfin integration for auth, browsing, and playback launch.
// ==========================================
// Discord Rich Presence
// Optional Discord Rich Presence activity card updates for current playback/study session.
// Requires a Discord application client ID and uploaded asset keys.
// ==========================================
"discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"clientId": "", // Discord application client ID used for Rich Presence.
"detailsTemplate": "Mining Japanese", // Details line template for the activity card.
"stateTemplate": "Idle", // State line template for the activity card.
"largeImageKey": "subminer-logo", // Discord asset key for the large activity image.
"largeImageText": "SubMiner", // Hover text for the large activity image.
"smallImageKey": "study", // Discord asset key for the small activity image.
"smallImageText": "Sentence Mining", // Hover text for the small activity image.
"buttonLabel": "", // Optional button label shown on the Discord activity card.
"buttonUrl": "", // Optional button URL shown on the Discord activity card.
"updateIntervalMs": 15000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
// ========================================== // ==========================================
// Immersion Tracking // Immersion Tracking
// Enable/disable immersion tracking. // Enable/disable immersion tracking.

View File

@@ -168,6 +168,7 @@ The composition root (`src/main.ts`) delegates to focused modules in `src/main/`
- `overlay-runtime.ts` — overlay window selection and modal state management - `overlay-runtime.ts` — overlay window selection and modal state management
- `subsync-runtime.ts` — subsync command orchestration - `subsync-runtime.ts` — subsync command orchestration
- `runtime/composers/anilist-tracking-composer.ts` — AniList media tracking/probe/retry wiring - `runtime/composers/anilist-tracking-composer.ts` — AniList media tracking/probe/retry wiring
- `runtime/composers/jellyfin-runtime-composer.ts` — Jellyfin config/client/playback/command/setup composition wiring
- `runtime/composers/mpv-runtime-composer.ts` — MPV event/factory/tokenizer/warmup wiring - `runtime/composers/mpv-runtime-composer.ts` — MPV event/factory/tokenizer/warmup wiring
Composer modules share contract conventions via `src/main/runtime/composers/contracts.ts`: Composer modules share contract conventions via `src/main/runtime/composers/contracts.ts`:

View File

@@ -76,6 +76,7 @@ The configuration file includes several main sections:
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**AniList**](#anilist) - Optional post-watch progress updates - [**AniList**](#anilist) - Optional post-watch progress updates
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch - [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
- [**Keybindings**](#keybindings) - MPV command shortcuts - [**Keybindings**](#keybindings) - MPV command shortcuts
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles - [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
- [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support - [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support
@@ -479,8 +480,6 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
"enabled": true, "enabled": true,
"serverUrl": "http://127.0.0.1:8096", "serverUrl": "http://127.0.0.1:8096",
"username": "", "username": "",
"accessToken": "",
"userId": "",
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
"autoAnnounce": false, "autoAnnounce": false,
@@ -535,6 +534,57 @@ Launcher subcommand equivalents:
Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`. Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`.
### Discord Rich Presence
Discord Rich Presence is optional and disabled by default. When enabled, SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer.
```json
{
"discordPresence": {
"enabled": true,
"clientId": "123456789012345678",
"detailsTemplate": "Watching {title}",
"stateTemplate": "{status}",
"largeImageKey": "subminer-logo",
"largeImageText": "SubMiner",
"smallImageKey": "study",
"smallImageText": "Sentence Mining",
"buttonLabel": "GitHub",
"buttonUrl": "https://github.com/sudacode/SubMiner",
"updateIntervalMs": 15000,
"debounceMs": 750
}
}
```
| Option | Values | Description |
| ------------------ | --------------- | ---------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) |
| `clientId` | string | Discord application client ID |
| `detailsTemplate` | string | Card details line template. Supports `{title}`, `{file}`, `{subtitle}`, `{status}` |
| `stateTemplate` | string | Card state line template. Supports `{title}`, `{file}`, `{subtitle}`, `{status}` |
| `largeImageKey` | string | Large image asset key uploaded to your Discord app |
| `largeImageText` | string | Hover text for large image |
| `smallImageKey` | string | Small image asset key uploaded to your Discord app |
| `smallImageText` | string | Hover text for small image |
| `buttonLabel` | string | Optional button label (requires valid `buttonUrl`) |
| `buttonUrl` | string (URL) | Optional button URL shown on the activity card |
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
Setup steps:
1. Create a Discord application at <https://discord.com/developers/applications>.
2. Open **Rich Presence > Art Assets** and upload image assets referenced by `largeImageKey` / `smallImageKey`.
3. Copy the application ID into `discordPresence.clientId`.
4. Set `discordPresence.enabled` to `true` and restart SubMiner.
Troubleshooting:
- If the card does not appear, verify Discord desktop app is running and `clientId` is correct.
- If images do not render, confirm asset keys exactly match uploaded Discord asset names.
- If Discord is closed/not installed/disconnects, SubMiner continues running and quietly skips presence updates.
### Keybindings ### Keybindings
Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv: Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv:

View File

@@ -46,6 +46,125 @@
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
}, // Controls logging verbosity. }, // Controls logging verbosity.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
// ==========================================
"shortcuts": {
"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
// Startup behavior for the invisible interactive subtitle mining layer.
// Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.
// This edit-mode shortcut is fixed and is not currently configurable.
// ==========================================
"invisibleOverlay": {
"startupVisibility": "platform-default" // Startup visibility setting.
}, // Startup behavior for the invisible interactive subtitle mining layer.
// ==========================================
// Keybindings (MPV Commands)
// Extra keybindings that are merged with built-in defaults.
// Set command to null to disable a default keybinding.
// Hot-reload: keybinding changes apply live and update the session help modal on reopen.
// ==========================================
"keybindings": [], // Extra keybindings that are merged with built-in defaults.
// ==========================================
// Secondary Subtitles
// Dual subtitle track options.
// Used by subminer YouTube subtitle generation as secondary language preferences.
// Hot-reload: defaultMode updates live while SubMiner is running.
// ==========================================
"secondarySub": {
"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", // 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 // Y percent setting.
}, // Initial vertical subtitle position from the bottom.
// ==========================================
// Subtitle Appearance
// Primary and secondary subtitle styling.
// Hot-reload: subtitle style changes apply live without restarting SubMiner.
// ==========================================
"subtitleStyle": {
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv.
"fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif", // Font family setting.
"fontSize": 35, // Font size setting.
"fontColor": "#cad3f5", // Font color setting.
"fontWeight": "normal", // Font weight setting.
"fontStyle": "normal", // Font style setting.
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
"knownWordColor": "#a6da95", // Known word color setting.
"jlptColors": {
"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, // 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, // Font size setting.
"fontColor": "#ffffff", // Font color setting.
"backgroundColor": "transparent", // Background color setting.
"fontWeight": "normal", // Font weight setting.
"fontStyle": "normal", // Font style setting.
"fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif" // Font family setting.
} // Secondary setting.
}, // Primary and secondary subtitle styling.
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // Automatic Anki updates and media generation options.
@@ -119,124 +238,6 @@
} // Is kiku setting. } // Is kiku setting.
}, // Automatic Anki updates and media generation options. }, // Automatic Anki updates and media generation options.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
// ==========================================
"shortcuts": {
"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
// Startup behavior for the invisible interactive subtitle mining layer.
// Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.
// This edit-mode shortcut is fixed and is not currently configurable.
// ==========================================
"invisibleOverlay": {
"startupVisibility": "platform-default" // Startup visibility setting.
}, // Startup behavior for the invisible interactive subtitle mining layer.
// ==========================================
// Keybindings (MPV Commands)
// Extra keybindings that are merged with built-in defaults.
// Set command to null to disable a default keybinding.
// Hot-reload: keybinding changes apply live and update the session help modal on reopen.
// ==========================================
"keybindings": [], // Extra keybindings that are merged with built-in defaults.
// ==========================================
// Subtitle Appearance
// Primary and secondary subtitle styling.
// Hot-reload: subtitle style changes apply live without restarting SubMiner.
// ==========================================
"subtitleStyle": {
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"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": "rgb(30, 32, 48, 0.88)", // Background color setting.
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
"knownWordColor": "#a6da95", // Known word color setting.
"jlptColors": {
"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, // 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, // 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
// Dual subtitle track options.
// Used by subminer YouTube subtitle generation as secondary language preferences.
// Hot-reload: defaultMode updates live while SubMiner is running.
// ==========================================
"secondarySub": {
"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", // 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 // Y percent setting.
}, // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
// Jimaku // Jimaku
// Jimaku API configuration and defaults. // Jimaku API configuration and defaults.
@@ -273,10 +274,8 @@
// ========================================== // ==========================================
// Jellyfin // Jellyfin
// Optional Jellyfin integration for auth, browsing, and playback launch. // Optional Jellyfin integration for auth, browsing, and playback launch.
// Auth session (access token + user id) is stored in local encrypted storage after login/setup. // Access token is stored in local encrypted token storage after login/setup.
// Optional environment overrides: // jellyfin.accessToken remains an optional explicit override in config.
// SUBMINER_JELLYFIN_ACCESS_TOKEN
// SUBMINER_JELLYFIN_USER_ID
// ========================================== // ==========================================
"jellyfin": { "jellyfin": {
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false "enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
@@ -305,6 +304,26 @@
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable. "transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
}, // Optional Jellyfin integration for auth, browsing, and playback launch. }, // Optional Jellyfin integration for auth, browsing, and playback launch.
// ==========================================
// Discord Rich Presence
// Optional Discord Rich Presence activity card updates for current playback/study session.
// Requires a Discord application client ID and uploaded asset keys.
// ==========================================
"discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"clientId": "", // Discord application client ID used for Rich Presence.
"detailsTemplate": "Mining Japanese", // Details line template for the activity card.
"stateTemplate": "Idle", // State line template for the activity card.
"largeImageKey": "subminer-logo", // Discord asset key for the large activity image.
"largeImageText": "SubMiner", // Hover text for the large activity image.
"smallImageKey": "study", // Discord asset key for the small activity image.
"smallImageText": "Sentence Mining", // Hover text for the small activity image.
"buttonLabel": "", // Optional button label shown on the Discord activity card.
"buttonUrl": "", // Optional button URL shown on the Discord activity card.
"updateIntervalMs": 15000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
// ========================================== // ==========================================
// Immersion Tracking // Immersion Tracking
// Enable/disable immersion tracking. // Enable/disable immersion tracking.

View File

@@ -81,3 +81,7 @@ Read first. Keep concise.
| `opencode-task106-immersion-modules-20260222T195109Z-r3m7` | `opencode-task106-immersion-modules` | `Execute TASK-106 decomposition of immersion tracker service into storage session and metadata modules end-to-end without commit` | `done` | `docs/subagents/agents/opencode-task106-immersion-modules-20260222T195109Z-r3m7.md` | `2026-02-22T21:58:45Z` | | `opencode-task106-immersion-modules-20260222T195109Z-r3m7` | `opencode-task106-immersion-modules` | `Execute TASK-106 decomposition of immersion tracker service into storage session and metadata modules end-to-end without commit` | `done` | `docs/subagents/agents/opencode-task106-immersion-modules-20260222T195109Z-r3m7.md` | `2026-02-22T21:58:45Z` |
| `codex-task105-sliceb-20260222T195423Z-w8n3` | `codex-task105-sliceb` | `Implement TASK-105 slice B runtime cast removal in targeted main/runtime modules without commit` | `done` | `docs/subagents/agents/codex-task105-sliceb-20260222T195423Z-w8n3.md` | `2026-02-22T20:02:06Z` | | `codex-task105-sliceb-20260222T195423Z-w8n3` | `codex-task105-sliceb` | `Implement TASK-105 slice B runtime cast removal in targeted main/runtime modules without commit` | `done` | `docs/subagents/agents/codex-task105-sliceb-20260222T195423Z-w8n3.md` | `2026-02-22T20:02:06Z` |
| `codex-ts-build-errors-20260222T215411Z-h3k7` | `codex-ts-build-errors` | `Fix current TypeScript build failures in anki/runtime tests and deps typing contracts; keep behavior unchanged.` | `done` | `docs/subagents/agents/codex-ts-build-errors-20260222T215411Z-h3k7.md` | `2026-02-22T21:55:54Z` | | `codex-ts-build-errors-20260222T215411Z-h3k7` | `codex-ts-build-errors` | `Fix current TypeScript build failures in anki/runtime tests and deps typing contracts; keep behavior unchanged.` | `done` | `docs/subagents/agents/codex-ts-build-errors-20260222T215411Z-h3k7.md` | `2026-02-22T21:55:54Z` |
| `codex-kiku-modal-overlay-20260222T220502Z-r4m1` | `codex-kiku-modal-overlay` | `Fix Kiku field-grouping modal overlay visibility restore when modal closes` | `handoff` | `docs/subagents/agents/codex-kiku-modal-overlay-20260222T220502Z-r4m1.md` | `2026-02-22T22:07:38Z` |
| `codex-task103-jellyfin-main-composer-20260222T220441Z-m8p1` | `codex-task103-jellyfin-main-composer` | `Execute TASK-103 Jellyfin runtime wiring extraction from src/main.ts via plan-first workflow without commit.` | `done` | `docs/subagents/agents/codex-task103-jellyfin-main-composer-20260222T220441Z-m8p1.md` | `2026-02-22T22:49:30Z` |
| `codex-task109-discord-presence-20260222T220537Z-lkfv` | `codex-task109-discord-presence` | `Execute TASK-109 Discord Rich Presence integration end-to-end with plan-first workflow (no commit)` | `handoff` | `docs/subagents/agents/codex-task109-discord-presence-20260222T220537Z-lkfv.md` | `2026-02-22T22:36:40Z` |
| `opencode-task103-jellyfin-main-composer-20260222T221152Z-n3p7` | `opencode-task103-jellyfin-main-composer` | `Implement TASK-103 Jellyfin runtime wiring extraction from main.ts into composer module(s), tests, docs, and required validations (no commit).` | `in_progress` | `docs/subagents/agents/opencode-task103-jellyfin-main-composer-20260222T221152Z-n3p7.md` | `2026-02-22T22:11:52Z` |

View File

@@ -0,0 +1,28 @@
# Agent: `codex-discord-presence-task-20260222T194048Z-d7k2`
- alias: `codex-discord-presence-task`
- mission: `Add backlog task for Discord Rich Presence integration with polished activity card`
- status: `done`
- branch: `main`
- started_at: `2026-02-22T19:40:48Z`
- heartbeat_minutes: `5`
## Current Work (newest first)
- [2026-02-22T19:41:00Z] handoff: added `TASK-109` backlog ticket for Discord Rich Presence integration + polished activity card scope.
- [2026-02-22T19:40:56Z] progress: verified no existing Discord presence backlog ticket via search across backlog directories.
- [2026-02-22T19:40:48Z] intent: create ticket only; no runtime code implementation.
## Files Touched
- `backlog/tasks/task-109 - Add-Discord-Rich-Presence-integration-with-polished-activity-card.md`
- `docs/subagents/INDEX.md`
- `docs/subagents/agents/codex-discord-presence-task-20260222T194048Z-d7k2.md`
## Assumptions
- User request means backlog task creation, not immediate implementation.
- Existing markdown task format in `backlog/tasks` remains canonical.
## Open Questions / Blockers
- None.
## Next Step
- Wait for user follow-up (implement `TASK-109` when requested).

View File

@@ -0,0 +1,52 @@
# codex-field-grouping-autoupdate-race-20260222T193915Z-m8p4
- alias: `codex-field-grouping-autoupdate-race`
- mission: Fix Kiku field-grouping merge race with auto-update so note enrichment completes before duplicate merge.
- status: `handoff`
- last_update_utc: `2026-02-22T19:41:20Z`
- backlog_ticket: `TASK-94` (`backlog/tasks/task-94 - Fix-Kiku-duplicate-detection-for-Yomitan-marked-duplicates.md`) for duplicate/grouping workflow scope.
## Intent
- Reproduce reported ordering bug in `NoteUpdateWorkflow`.
- Add regression test first (TDD): update fields before field-grouping merge.
- Patch workflow ordering with minimal behavior change.
## Planned Files
- `src/anki-integration/note-update-workflow.test.ts`
- `src/anki-integration/note-update-workflow.ts`
## Assumptions
- Bug path: auto-update flow enters duplicate merge before sentence/audio/image enrichment completes.
- Existing field-grouping handlers remain source of merge/delete behavior; only invocation timing changes.
## Progress Log
- 2026-02-22T19:39:15Z: session start; read subagent index/collaboration; identified target workflow and tests.
- 2026-02-22T19:40:15Z: added failing TDD regression in `src/anki-integration/note-update-workflow.test.ts` asserting update-before-auto-merge order.
- 2026-02-22T19:40:45Z: patched `src/anki-integration/note-update-workflow.ts` to defer field-grouping execution until after enrichment/update; refresh note info before merge.
- 2026-02-22T19:41:20Z: verification green:
- `bun test src/anki-integration/note-update-workflow.test.ts`
- `bun test src/anki-integration/field-grouping-workflow.test.ts src/anki-integration.test.ts`
## Files Touched
- `src/anki-integration/note-update-workflow.ts`
- `src/anki-integration/note-update-workflow.test.ts`
- `docs/subagents/INDEX.md`
- `docs/subagents/agents/codex-field-grouping-autoupdate-race-20260222T193915Z-m8p4.md`
## Decisions
- Keep duplicate detection early (single lookup), but run merge/manual handlers only after card update path completes.
- Refresh `noteInfo` after update so grouping handlers operate on enriched fields.
## Blockers
- None.
## Next Step
- Optional: add an integration-level test for keep-both (`kikuDeleteDuplicateInAuto=false`) flow to assert both notes retain enriched data.

View File

@@ -0,0 +1,30 @@
# Agent: `codex-kiku-modal-overlay-20260222T220502Z-r4m1`
- alias: `codex-kiku-modal-overlay`
- mission: `Fix Kiku field-grouping modal overlay visibility restore when modal closes`
- status: `handoff`
- branch: `main`
- started_at: `2026-02-22T22:05:02Z`
- heartbeat_minutes: `5`
## Current Work (newest first)
- [2026-02-22T22:07:38Z] handoff: added failing regression test for hidden->modal->close restore path; patched `field-grouping-overlay` to sync visible overlay state for external senders; focused tests green.
- [2026-02-22T22:05:02Z] intent: investigate Kiku modal + overlay visibility restore regression; add failing test first, then minimal fix.
- [2026-02-22T22:06:10Z] progress: traced leak to `createFieldGroupingOverlayRuntime` path using `sendToActiveOverlayWindow` without syncing `visibleOverlayVisible` state.
## Files Touched
- `docs/subagents/INDEX.md`
- `docs/subagents/collaboration.md`
- `docs/subagents/agents/codex-kiku-modal-overlay-20260222T220502Z-r4m1.md`
- `src/core/services/field-grouping-overlay.ts`
- `src/core/services/field-grouping-overlay.test.ts`
## Assumptions
- Bug reproduces when visible overlay is initially hidden and Kiku modal auto-opens overlay.
- Existing callback restore path should remain source of truth (hide on resolve/cancel).
## Open Questions / Blockers
- none
## Next Step
- User validation in real overlay flow (hidden visible overlay -> Kiku modal open/close -> hidden restored).

View File

@@ -0,0 +1,40 @@
# Agent Session: codex-task103-jellyfin-main-composer-20260222T220441Z-m8p1
- alias: `codex-task103-jellyfin-main-composer`
- mission: `Execute TASK-103 Jellyfin runtime wiring extraction from src/main.ts composition root without commit.`
- status: `done`
- last_update_utc: `2026-02-22T22:49:30Z`
## Intent
- Load TASK-103 from Backlog MCP.
- Produce plan artifact via writing-plans skill.
- Execute plan end-to-end with tests (no commit).
## Planned Files (initial)
- `src/main.ts`
- `src/main/runtime/composers/*jellyfin*`
- `src/main/runtime/*jellyfin*`
- `src/main/runtime/composers/*.test.ts`
- `docs/architecture.md` (if ownership docs required)
- `docs/plans/2026-02-22-task-103-jellyfin-runtime-wiring.md`
## Assumptions
- Existing runtime composer patterns from TASK-94/TASK-97 remain canonical.
- No behavior changes expected; extraction/refactor only.
- User requested no commit in this run.
## Progress Log
- `2026-02-22T22:04:41Z` session created; backlog overview + task guides loaded; TASK-103 context loaded.
- `2026-02-22T22:10:20Z` wrote plan artifact `docs/plans/2026-02-22-task-103-jellyfin-runtime-wiring.md`; saved plan to TASK-103.
- `2026-02-22T22:36:15Z` implemented `src/main/runtime/composers/jellyfin-runtime-composer.ts` and `src/main/runtime/composers/jellyfin-runtime-composer.test.ts`; rewired Jellyfin block in `src/main.ts` to `composeJellyfinRuntimeHandlers(...)`; updated `docs/architecture.md` composer ownership.
- `2026-02-22T22:38:58Z` validations: focused composer tests PASS, `check:main-fanin` PASS, `test:core:src` PASS; `build` blocked by pre-existing duplicate/invalid imports in `src/main.ts`.
- `2026-02-22T22:49:05Z` user-reported build fix validated; reran required gates (`build`, `test:core:src`, `check:main-fanin`) all PASS; TASK-103 finalized Done in Backlog.
## Handoff Notes
- TASK-103 complete: AC1-4 and DoD1-3 checked; status Done.
- New files: `src/main/runtime/composers/jellyfin-runtime-composer.ts`, `src/main/runtime/composers/jellyfin-runtime-composer.test.ts`.

View File

@@ -0,0 +1,46 @@
# Agent: `codex-task104-launcher-config-20260222T194708Z-z9x1`
- alias: `codex-task104-launcher-config`
- mission: `Execute TASK-104 end-to-end with plan-first workflow and no commit`
- status: `done`
- branch: `main`
- started_at: `2026-02-22T19:47:08Z`
- heartbeat_minutes: `5`
## Current Work (newest first)
- [2026-02-22T19:47:08Z] intent: load backlog context, write execution plan with writing-plans skill, execute with executing-plans skill, and validate launcher test lanes.
- [2026-02-22T19:56:26Z] progress: extracted launcher config domain modules (`shared-config-reader`, `youtube-subgen-config`, `jellyfin-config`, `plugin-runtime-config`, `cli-parser-builder`, `args-normalizer`) and reduced `launcher/config.ts` to orchestration facade.
- [2026-02-22T19:56:26Z] test: `bun test launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts`, `bun run test:launcher`, and `bun run test:fast` all passed.
- [2026-02-22T19:56:26Z] handoff: TASK-104 finalized Done in Backlog MCP with AC/DoD checks complete and final summary recorded.
## Files Touched
- `docs/subagents/agents/codex-task104-launcher-config-20260222T194708Z-z9x1.md`
- `docs/subagents/INDEX.md`
- `docs/subagents/collaboration.md`
- `docs/plans/2026-02-22-task-104-launcher-config-domain-parsers-cli-builder.md`
- `launcher/config.ts`
- `launcher/config/shared-config-reader.ts`
- `launcher/config/youtube-subgen-config.ts`
- `launcher/config/jellyfin-config.ts`
- `launcher/config/plugin-runtime-config.ts`
- `launcher/config/cli-parser-builder.ts`
- `launcher/config/args-normalizer.ts`
- `launcher/config-domain-parsers.test.ts`
- `launcher/parse-args.test.ts`
- `launcher/jellyfin.ts`
- `launcher/types.ts`
- `package.json`
## Assumptions
- Existing launcher CLI behavior can be preserved with modular extraction plus regression tests.
## Open Questions / Blockers
- none
## Next Step
- await user follow-up (optional: commit, docs wording update, or additional jellyfin session-source hardening).

View File

@@ -0,0 +1,57 @@
# Agent Session: codex-task105-sliceb-20260222T195423Z-w8n3
- alias: `codex-task105-sliceb`
- mission: `Implement TASK-105 slice B runtime cast removal in targeted main/runtime modules without commit.`
- status: `done`
- started_utc: `2026-02-22T19:54:23Z`
- last_update_utc: `2026-02-22T20:02:06Z`
## Intent
- Remove unsafe non-test casts in requested runtime modules using strict shared contracts.
- Keep behavior unchanged; limit scope to type-safety and minimal fallout tests.
- Run targeted tests for changed modules and report outputs.
## Planned Files
- `src/main/runtime/app-runtime-main-deps.ts`
- `src/main/runtime/cli-command-context-main-deps.ts`
- `src/main/runtime/subtitle-tokenization-main-deps.ts`
- `src/main/runtime/overlay-runtime-options-main-deps.ts`
- `src/main/runtime/dictionary-runtime-main-deps.ts`
- `src/main/runtime/mpv-jellyfin-defaults.ts`
- `src/main/runtime/jellyfin-playback-launch.ts`
- `src/main/runtime/cli-command-context-main-deps.test.ts`
- `src/main/runtime/subtitle-tokenization-main-deps.test.ts`
- `src/main/runtime/mpv-jellyfin-defaults.test.ts`
- `src/main/runtime/mpv-jellyfin-defaults-main-deps.test.ts`
- `src/main/runtime/jellyfin-playback-launch.test.ts`
- `src/main/runtime/jellyfin-playback-launch-main-deps.test.ts`
- `src/main/runtime/**/*.test.ts`
## Files Touched
- `docs/subagents/INDEX.md`
- `docs/subagents/agents/codex-task105-sliceb-20260222T195423Z-w8n3.md`
- `docs/subagents/collaboration.md`
- `src/main/runtime/app-runtime-main-deps.ts`
- `src/main/runtime/cli-command-context-main-deps.ts`
- `src/main/runtime/subtitle-tokenization-main-deps.ts`
- `src/main/runtime/overlay-runtime-options-main-deps.ts`
- `src/main/runtime/mpv-jellyfin-defaults.ts`
- `src/main/runtime/jellyfin-playback-launch.ts`
## Assumptions
- Existing runtime contract types already cover needed dependency boundaries.
- Requested slice B is limited to listed files plus minimal typing-test fallout.
## Phase Log
- `2026-02-22T19:54:23Z` Session started; loaded backlog workflow + task context + subagent docs; preparing cast hotspot edits.
- `2026-02-22T19:59:42Z` Completed slice B edits: removed unsafe casts in targeted runtime modules, tightened contracts to shared runtime types, and ran targeted runtime tests (16 pass / 0 fail).
- `2026-02-22T20:02:06Z` Updated focused tests for stricter contracts (typed stubs/status snapshots) and re-ran targeted suite (16 pass / 0 fail).
## Next Step
- Handoff ready.

View File

@@ -0,0 +1,80 @@
# Agent Log: codex-task109-discord-presence-20260222T220537Z-lkfv
- alias: `codex-task109-discord-presence`
- mission: `Execute TASK-109 Discord Rich Presence integration end-to-end with plan-first workflow (no commit).`
- status: `in_progress`
- started_utc: `2026-02-22T22:05:37Z`
- backlog_task: `TASK-109`
## Intent
- Load TASK-109 context from Backlog MCP.
- Write execution plan via `writing-plans` skill.
- Execute approved plan via `executing-plans` skill.
- Prefer parallel subagents for independent slices.
## Planned Files (initial)
- `src/config/definitions.ts`
- `src/config/service.ts`
- `src/main.ts`
- `src/main/runtime/*discord*`
- `src/core/services/*discord*`
- `docs/configuration.md`
## Assumptions
- Discord integration optional, default off.
- Existing logging standards: avoid noisy transient errors.
- Rich Presence client id/config wired via existing config loading.
## Progress
- `2026-02-22T22:06:00Z`: Loaded backlog workflow overview + TASK-109 details.
- `2026-02-22T22:18:00Z`: Wrote plan at `docs/plans/2026-02-22-task-109-discord-rich-presence.md` and started execution.
- `2026-02-22T22:25:00Z`: Completed config surface for `discordPresence` (types/defaults/resolve/option registry/template + config tests).
- `2026-02-22T22:32:00Z`: Added `src/core/services/discord-presence.ts` with lifecycle + payload mapping + debounce/interval throttling + focused tests.
- `2026-02-22T22:35:00Z`: Wired MPV event handlers + app cleanup hooks for presence updates/stop; focused runtime tests green.
- `2026-02-22T22:36:00Z`: Updated docs/config examples; docs build green; backlog TASK-109 set In Progress with implementation notes.
## Blockers
- Full gate `bun run build` blocked by pre-existing `src/main.ts` duplicate imports/symbol errors unrelated to TASK-109 scope in current dirty workspace.
## Handoff Notes
- Implemented files:
- `src/core/services/discord-presence.ts`
- `src/core/services/discord-presence.test.ts`
- `src/config/definitions/defaults-integrations.ts`
- `src/config/definitions/options-integrations.ts`
- `src/config/definitions/template-sections.ts`
- `src/config/resolve/integrations.ts`
- `src/config/resolve/jellyfin.test.ts`
- `src/config/config.test.ts`
- `src/types.ts`
- `src/main/state.ts`
- `src/main/runtime/mpv-main-event-actions.ts`
- `src/main/runtime/mpv-main-event-actions.test.ts`
- `src/main/runtime/mpv-client-event-bindings.ts`
- `src/main/runtime/mpv-client-event-bindings.test.ts`
- `src/main/runtime/mpv-main-event-bindings.ts`
- `src/main/runtime/mpv-main-event-bindings.test.ts`
- `src/main/runtime/mpv-main-event-main-deps.ts`
- `src/main/runtime/mpv-main-event-main-deps.test.ts`
- `src/main/runtime/app-lifecycle-actions.ts`
- `src/main/runtime/app-lifecycle-actions.test.ts`
- `src/main/runtime/app-lifecycle-main-cleanup.ts`
- `src/main/runtime/app-lifecycle-main-cleanup.test.ts`
- `src/main/runtime/composers/startup-lifecycle-composer.test.ts`
- `src/main/runtime/composers/mpv-runtime-composer.test.ts`
- `src/core/services/field-grouping-overlay.test.ts`
- `src/main.ts`
- `docs/configuration.md`
- `config.example.jsonc`
- `docs/public/config.example.jsonc`
- `package.json`
- `bun.lock`
- Remaining work:
- Resolve unrelated `src/main.ts` duplicate import/symbol breakage in workspace to unblock full build/test gate.
- Manual Discord desktop validation still pending (DoD #2).

View File

@@ -0,0 +1,50 @@
# Agent Log: codex-ts-build-errors-20260222T215411Z-h3k7
- alias: `codex-ts-build-errors`
- mission: `Fix current TypeScript build failures in anki/runtime tests and deps typing contracts; keep behavior unchanged.`
- backlog: `TASK-105 (runtime cast/type tightening fallout) + current build-break triage`
- status: `in_progress`
- last_update_utc: `2026-02-22T21:54:29Z`
## Intent
- Triage all listed TS2322/TS2741/TS2353 errors.
- Prefer test stub/type fixes; minimal production changes only if contract mismatch in runtime composer wiring.
- Re-run `bun run tsc --noEmit` (or project build target) to confirm green.
## Planned Files
- `src/anki-integration/note-update-workflow.test.ts`
- `src/main/runtime/cli-command-context-factory.test.ts`
- `src/main/runtime/composers/app-ready-composer.test.ts`
- `src/main/runtime/composers/mpv-runtime-composer.test.ts`
- `src/main/runtime/composers/mpv-runtime-composer.ts`
- `src/main/runtime/overlay-runtime-bootstrap-handlers.test.ts`
- `src/main/runtime/overlay-runtime-options-main-deps.test.ts`
## Assumptions
- Failures are strict typing drift after recent runtime contract hardening.
- No functional behavior change intended.
## Activity
- `2026-02-22T21:54:29Z` started; reading failing files and applying minimal type-aligned fixes.
## Result
- status: `done`
- last_update_utc: `2026-02-22T21:55:54Z`
- files_touched:
- `src/anki-integration/note-update-workflow.test.ts`
- `docs/subagents/agents/codex-ts-build-errors-20260222T215411Z-h3k7.md`
- `docs/subagents/INDEX.md`
- `docs/subagents/collaboration.md`
- key_decisions:
- Typed harness deps as `NoteUpdateWorkflowDeps` to avoid literal over-narrowing in test overrides.
- Kept fixes test-only; no runtime behavior changes.
- verification:
- `bun run tsc --noEmit` passed.
- `make build` passed.
- blockers: none.
- next_step: optional commit/changelog by user preference.

View File

@@ -134,3 +134,12 @@ Shared notes. Append-only.
- [2026-02-22T21:55:54Z] [codex-ts-build-errors-20260222T215411Z-h3k7|codex-ts-build-errors] completed compile-fix pass: widened `note-update-workflow.test.ts` harness deps to `NoteUpdateWorkflowDeps`, aligned stub callback signatures, and verified `bun run tsc --noEmit` + `make build` green. - [2026-02-22T21:55:54Z] [codex-ts-build-errors-20260222T215411Z-h3k7|codex-ts-build-errors] completed compile-fix pass: widened `note-update-workflow.test.ts` harness deps to `NoteUpdateWorkflowDeps`, aligned stub callback signatures, and verified `bun run tsc --noEmit` + `make build` green.
- [2026-02-22T21:56:30Z] [opencode-task105-unsafe-casts-20260222T194704Z-zfcm|opencode-task105-unsafe-casts] finalized TASK-105: re-ran `bun run build` after compile-fix pass (green), confirmed cast scan 42->0 in scope, and moved backlog task to Done with AC/DoD + final summary. - [2026-02-22T21:56:30Z] [opencode-task105-unsafe-casts-20260222T194704Z-zfcm|opencode-task105-unsafe-casts] finalized TASK-105: re-ran `bun run build` after compile-fix pass (green), confirmed cast scan 42->0 in scope, and moved backlog task to Done with AC/DoD + final summary.
- [2026-02-22T22:06:20Z] [codex-task109-discord-presence-20260222T220537Z-lkfv|codex-task109-discord-presence] starting TASK-109 via Backlog MCP + writing-plans/executing-plans workflow; scope Discord Rich Presence config/runtime/docs/tests, no commit.
- [2026-02-22T22:36:40Z] [codex-task109-discord-presence-20260222T220537Z-lkfv|codex-task109-discord-presence] implemented Discord presence config/service/runtime/docs slices with focused tests + docs build green; full build gate blocked by pre-existing `src/main.ts` duplicate import/symbol errors outside TASK-109 scope.
- [2026-02-22T22:04:41Z] [codex-task103-jellyfin-main-composer-20260222T220441Z-m8p1|codex-task103-jellyfin-main-composer] starting TASK-103 via Backlog MCP + writing-plans/executing-plans workflow; scope jellyfin wiring extraction from `src/main.ts` into runtime composer modules + focused tests, no commit.
- [2026-02-22T22:39:40Z] [codex-task103-jellyfin-main-composer-20260222T220441Z-m8p1|codex-task103-jellyfin-main-composer] TASK-103 implementation pass done: added `jellyfin-runtime-composer` + seam test, rewired `src/main.ts` Jellyfin block to single composer invocation, updated architecture docs, and validated focused composer tests + `check:main-fanin` + `test:core:src`; `bun run build` blocked by pre-existing duplicate/invalid import state already present in `src/main.ts`.
- [2026-02-22T22:49:30Z] [codex-task103-jellyfin-main-composer-20260222T220441Z-m8p1|codex-task103-jellyfin-main-composer] closure: user fixed prior build blocker; reran required TASK-103 gates (`bun run build`, `bun run test:core:src`, `bun run check:main-fanin`) all pass and finalized TASK-103 to Done in Backlog.
- [2026-02-22T22:10:10Z] [codex-kiku-modal-overlay-20260222T220502Z-r4m1|codex-kiku-modal-overlay] overlap note: touching `src/core/services/field-grouping-overlay.ts` + tests to fix Kiku modal auto-shown visible overlay restore when modal closes.
- [2026-02-22T22:07:38Z] [codex-kiku-modal-overlay-20260222T220502Z-r4m1|codex-kiku-modal-overlay] completed fix: synchronized visible-overlay state when Kiku request opens via external sender; added regression test for hidden->open->resolve->hidden visibility restoration; focused field-grouping/overlay tests passing.
- [2026-02-22T22:11:52Z] [opencode-task103-jellyfin-main-composer-20260222T221152Z-n3p7|opencode-task103-jellyfin-main-composer] overlap note: implementing user-requested TASK-103 extraction in `src/main.ts`, `src/main/runtime/composers/jellyfin-*.ts`, composer tests, and `docs/architecture.md`; coordinating with active `codex-task103-...` session to avoid clobber.

View File

@@ -25,8 +25,8 @@
"test:config:smoke:dist": "node --test dist/config/path-resolution.test.js", "test:config:smoke:dist": "node --test dist/config/path-resolution.test.js",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
"test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:core:smoke:dist": "node --test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "node --test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
@@ -62,6 +62,7 @@
"@catppuccin/vitepress": "^0.1.2", "@catppuccin/vitepress": "^0.1.2",
"axios": "^1.13.5", "axios": "^1.13.5",
"commander": "^14.0.3", "commander": "^14.0.3",
"discord-rpc": "^4.0.1",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"mermaid": "^11.12.3", "mermaid": "^11.12.3",
"ws": "^8.19.0" "ws": "^8.19.0"

View File

@@ -1,18 +1,17 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { NoteUpdateWorkflow } from './note-update-workflow'; import {
NoteUpdateWorkflow,
type NoteInfo = { type NoteUpdateWorkflowDeps,
noteId: number; type NoteUpdateWorkflowNoteInfo,
fields: Record<string, { value: string }>; } from './note-update-workflow';
};
function createWorkflowHarness() { function createWorkflowHarness() {
const updates: Array<{ noteId: number; fields: Record<string, string> }> = []; const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const notifications: Array<{ noteId: number; label: string | number }> = []; const notifications: Array<{ noteId: number; label: string | number }> = [];
const warnings: string[] = []; const warnings: string[] = [];
const deps = { const deps: NoteUpdateWorkflowDeps = {
client: { client: {
notesInfo: async (_noteIds: number[]) => notesInfo: async (_noteIds: number[]) =>
[ [
@@ -23,7 +22,7 @@ function createWorkflowHarness() {
Sentence: { value: '' }, Sentence: { value: '' },
}, },
}, },
] satisfies NoteInfo[], ] satisfies NoteUpdateWorkflowNoteInfo[],
updateNoteFields: async (noteId: number, fields: Record<string, string>) => { updateNoteFields: async (noteId: number, fields: Record<string, string>) => {
updates.push({ noteId, fields }); updates.push({ noteId, fields });
}, },
@@ -43,7 +42,7 @@ function createWorkflowHarness() {
kikuEnabled: false, kikuEnabled: false,
kikuFieldGrouping: 'disabled' as const, kikuFieldGrouping: 'disabled' as const,
}), }),
appendKnownWordsFromNoteInfo: (_noteInfo: NoteInfo) => undefined, appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
extractFields: (fields: Record<string, { value: string }>) => { extractFields: (fields: Record<string, { value: string }>) => {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
for (const [key, value] of Object.entries(fields)) { for (const [key, value] of Object.entries(fields)) {
@@ -51,17 +50,27 @@ function createWorkflowHarness() {
} }
return out; return out;
}, },
findDuplicateNote: async () => null, findDuplicateNote: async (_expression, _excludeNoteId, _noteInfo) => null,
handleFieldGroupingAuto: async () => undefined, handleFieldGroupingAuto: async (
handleFieldGroupingManual: async () => false, _originalNoteId,
processSentence: (text: string) => text, _newNoteId,
resolveConfiguredFieldName: (noteInfo: NoteInfo, preferred?: string) => { _newNoteInfo,
_expression,
) => undefined,
handleFieldGroupingManual: async (
_originalNoteId,
_newNoteId,
_newNoteInfo,
_expression,
) => false,
processSentence: (text: string, _noteFields: Record<string, string>) => text,
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
if (!preferred) return null; if (!preferred) return null;
const names = Object.keys(noteInfo.fields); const names = Object.keys(noteInfo.fields);
return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null; return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
}, },
getResolvedSentenceAudioFieldName: () => null, getResolvedSentenceAudioFieldName: () => null,
mergeFieldValue: (_existing: string, next: string) => next, mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next,
generateAudioFilename: () => 'audio_1.mp3', generateAudioFilename: () => 'audio_1.mp3',
generateAudio: async () => null, generateAudio: async () => null,
generateImageFilename: () => 'image_1.jpg', generateImageFilename: () => 'image_1.jpg',
@@ -74,7 +83,7 @@ function createWorkflowHarness() {
showOsdNotification: (_text: string) => undefined, showOsdNotification: (_text: string) => undefined,
beginUpdateProgress: (_text: string) => undefined, beginUpdateProgress: (_text: string) => undefined,
endUpdateProgress: () => undefined, endUpdateProgress: () => undefined,
logWarn: (message: string) => warnings.push(message), logWarn: (message: string, ..._args: unknown[]) => warnings.push(message),
logInfo: (_message: string) => undefined, logInfo: (_message: string) => undefined,
logError: (_message: string) => undefined, logError: (_message: string) => undefined,
}; };
@@ -109,3 +118,56 @@ test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
assert.equal(harness.notifications.length, 0); assert.equal(harness.notifications.length, 0);
assert.equal(harness.warnings.length, 1); assert.equal(harness.warnings.length, 1);
}); });
test('NoteUpdateWorkflow updates note before auto field grouping merge', async () => {
const harness = createWorkflowHarness();
const callOrder: string[] = [];
let notesInfoCallCount = 0;
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
kikuEnabled: true,
kikuFieldGrouping: 'auto',
});
harness.deps.findDuplicateNote = async () => 99;
harness.deps.client.notesInfo = async () => {
notesInfoCallCount += 1;
if (notesInfoCallCount === 1) {
return [
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
}
return [
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: 'subtitle-text' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
};
harness.deps.client.updateNoteFields = async (noteId, fields) => {
callOrder.push('update');
harness.updates.push({ noteId, fields });
};
harness.deps.handleFieldGroupingAuto = async (
_originalNoteId,
_newNoteId,
newNoteInfo,
_expression,
) => {
callOrder.push('auto');
assert.equal(newNoteInfo.fields.Sentence?.value, 'subtitle-text');
};
await harness.workflow.execute(42);
assert.deepEqual(callOrder, ['update', 'auto']);
assert.equal(harness.updates.length, 1);
});

View File

@@ -98,34 +98,13 @@ export class NoteUpdateWorkflow {
} }
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
if ( const shouldRunFieldGrouping =
!options?.skipKikuFieldGrouping && !options?.skipKikuFieldGrouping &&
sentenceCardConfig.kikuEnabled && sentenceCardConfig.kikuEnabled &&
sentenceCardConfig.kikuFieldGrouping !== 'disabled' sentenceCardConfig.kikuFieldGrouping !== 'disabled';
) { let duplicateNoteId: number | null = null;
const duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo); if (shouldRunFieldGrouping) {
if (duplicateNoteId !== null) { duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo);
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
await this.deps.handleFieldGroupingAuto(
duplicateNoteId,
noteId,
noteInfo,
expressionText,
);
return;
}
if (sentenceCardConfig.kikuFieldGrouping === 'manual') {
const handled = await this.deps.handleFieldGroupingManual(
duplicateNoteId,
noteId,
noteInfo,
expressionText,
);
if (handled) {
return;
}
}
}
} }
const updatedFields: Record<string, string> = {}; const updatedFields: Record<string, string> = {};
@@ -219,6 +198,37 @@ export class NoteUpdateWorkflow {
this.deps.logInfo('Updated card fields for:', expressionText); this.deps.logInfo('Updated card fields for:', expressionText);
await this.deps.showNotification(noteId, expressionText); await this.deps.showNotification(noteId, expressionText);
} }
if (shouldRunFieldGrouping && duplicateNoteId !== null) {
let noteInfoForGrouping = noteInfo;
if (updatePerformed) {
const refreshedInfoResult = await this.deps.client.notesInfo([noteId]);
const refreshedInfo = refreshedInfoResult as NoteUpdateWorkflowNoteInfo[];
if (!refreshedInfo || refreshedInfo.length === 0) {
this.deps.logWarn('Card not found after update:', noteId);
return;
}
noteInfoForGrouping = refreshedInfo[0]!;
}
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
await this.deps.handleFieldGroupingAuto(
duplicateNoteId,
noteId,
noteInfoForGrouping,
expressionText,
);
return;
}
if (sentenceCardConfig.kikuFieldGrouping === 'manual') {
await this.deps.handleFieldGroupingManual(
duplicateNoteId,
noteId,
noteInfoForGrouping,
expressionText,
);
}
}
} catch (error) { } catch (error) {
if ((error as Error).message.includes('note was not found')) { if ((error as Error).message.includes('note was not found')) {
this.deps.logWarn('Card was deleted before update:', noteId); this.deps.logWarn('Card was deleted before update:', noteId);

View File

@@ -23,6 +23,8 @@ test('loads defaults when config is missing', () => {
assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false); assert.equal(config.jellyfin.autoAnnounce, false);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner'); assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
assert.equal(config.discordPresence.enabled, false);
assert.equal(config.discordPresence.updateIntervalMs, 15_000);
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)'); assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
assert.equal(config.subtitleStyle.preserveLineBreaks, false); assert.equal(config.subtitleStyle.preserveLineBreaks, false);
assert.equal(config.subtitleStyle.hoverTokenColor, '#c6a0f6'); assert.equal(config.subtitleStyle.hoverTokenColor, '#c6a0f6');
@@ -239,6 +241,35 @@ test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', (
); );
}); });
test('parses discordPresence fields and warns for invalid types', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"discordPresence": {
"enabled": true,
"clientId": "123456789012345678",
"detailsTemplate": "Watching {title}",
"stateTemplate": "{status}",
"updateIntervalMs": 3000,
"debounceMs": 250
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.discordPresence.enabled, true);
assert.equal(config.discordPresence.clientId, '123456789012345678');
assert.equal(config.discordPresence.updateIntervalMs, 3000);
assert.equal(config.discordPresence.debounceMs, 250);
service.patchRawConfig({ discordPresence: { enabled: 'yes' as never } });
assert.equal(service.getConfig().discordPresence.enabled, DEFAULT_CONFIG.discordPresence.enabled);
assert.ok(service.getWarnings().some((warning) => warning.path === 'discordPresence.enabled'));
});
test('accepts immersion tracking config values', () => { test('accepts immersion tracking config values', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
@@ -1062,6 +1093,7 @@ test('template generator includes known keys', () => {
assert.match(output, /"ankiConnect":/); assert.match(output, /"ankiConnect":/);
assert.match(output, /"logging":/); assert.match(output, /"logging":/);
assert.match(output, /"websocket":/); assert.match(output, /"websocket":/);
assert.match(output, /"discordPresence":/);
assert.match(output, /"youtubeSubgen":/); assert.match(output, /"youtubeSubgen":/);
assert.match(output, /"preserveLineBreaks": false/); assert.match(output, /"preserveLineBreaks": false/);
assert.match(output, /"nPlusOne"\s*:\s*\{/); assert.match(output, /"nPlusOne"\s*:\s*\{/);

View File

@@ -31,7 +31,8 @@ const {
bind_visible_overlay_to_mpv_sub_visibility, bind_visible_overlay_to_mpv_sub_visibility,
invisibleOverlay, invisibleOverlay,
} = CORE_DEFAULT_CONFIG; } = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, jellyfin, youtubeSubgen } = INTEGRATIONS_DEFAULT_CONFIG; const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, youtubeSubgen } =
INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG; const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG; const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
@@ -51,6 +52,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
jimaku, jimaku,
anilist, anilist,
jellyfin, jellyfin,
discordPresence,
youtubeSubgen, youtubeSubgen,
invisibleOverlay, invisibleOverlay,
immersionTracking, immersionTracking,

View File

@@ -2,7 +2,7 @@ import { ResolvedConfig } from '../../types';
export const INTEGRATIONS_DEFAULT_CONFIG: Pick< export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig, ResolvedConfig,
'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'youtubeSubgen' 'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'youtubeSubgen'
> = { > = {
ankiConnect: { ankiConnect: {
enabled: false, enabled: false,
@@ -99,6 +99,20 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
directPlayContainers: ['mkv', 'mp4', 'webm', 'mov', 'flac', 'mp3', 'aac'], directPlayContainers: ['mkv', 'mp4', 'webm', 'mov', 'flac', 'mp3', 'aac'],
transcodeVideoCodec: 'h264', transcodeVideoCodec: 'h264',
}, },
discordPresence: {
enabled: false,
clientId: '',
detailsTemplate: 'Mining Japanese',
stateTemplate: 'Idle',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: 'study',
smallImageText: 'Sentence Mining',
buttonLabel: '',
buttonUrl: '',
updateIntervalMs: 15_000,
debounceMs: 750,
},
youtubeSubgen: { youtubeSubgen: {
mode: 'automatic', mode: 'automatic',
whisperBin: '', whisperBin: '',

View File

@@ -188,6 +188,78 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.jellyfin.transcodeVideoCodec, defaultValue: defaultConfig.jellyfin.transcodeVideoCodec,
description: 'Preferred transcode video codec when direct play is unavailable.', description: 'Preferred transcode video codec when direct play is unavailable.',
}, },
{
path: 'discordPresence.enabled',
kind: 'boolean',
defaultValue: defaultConfig.discordPresence.enabled,
description: 'Enable optional Discord Rich Presence updates.',
},
{
path: 'discordPresence.clientId',
kind: 'string',
defaultValue: defaultConfig.discordPresence.clientId,
description: 'Discord application client ID used for Rich Presence.',
},
{
path: 'discordPresence.detailsTemplate',
kind: 'string',
defaultValue: defaultConfig.discordPresence.detailsTemplate,
description: 'Details line template for the activity card.',
},
{
path: 'discordPresence.stateTemplate',
kind: 'string',
defaultValue: defaultConfig.discordPresence.stateTemplate,
description: 'State line template for the activity card.',
},
{
path: 'discordPresence.largeImageKey',
kind: 'string',
defaultValue: defaultConfig.discordPresence.largeImageKey,
description: 'Discord asset key for the large activity image.',
},
{
path: 'discordPresence.largeImageText',
kind: 'string',
defaultValue: defaultConfig.discordPresence.largeImageText,
description: 'Hover text for the large activity image.',
},
{
path: 'discordPresence.smallImageKey',
kind: 'string',
defaultValue: defaultConfig.discordPresence.smallImageKey,
description: 'Discord asset key for the small activity image.',
},
{
path: 'discordPresence.smallImageText',
kind: 'string',
defaultValue: defaultConfig.discordPresence.smallImageText,
description: 'Hover text for the small activity image.',
},
{
path: 'discordPresence.buttonLabel',
kind: 'string',
defaultValue: defaultConfig.discordPresence.buttonLabel,
description: 'Optional button label shown on the Discord activity card.',
},
{
path: 'discordPresence.buttonUrl',
kind: 'string',
defaultValue: defaultConfig.discordPresence.buttonUrl,
description: 'Optional button URL shown on the Discord activity card.',
},
{
path: 'discordPresence.updateIntervalMs',
kind: 'number',
defaultValue: defaultConfig.discordPresence.updateIntervalMs,
description: 'Minimum interval between presence payload updates.',
},
{
path: 'discordPresence.debounceMs',
kind: 'number',
defaultValue: defaultConfig.discordPresence.debounceMs,
description: 'Debounce delay used to collapse bursty presence updates.',
},
{ {
path: 'youtubeSubgen.mode', path: 'youtubeSubgen.mode',
kind: 'enum', kind: 'enum',

View File

@@ -124,6 +124,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
], ],
key: 'jellyfin', key: 'jellyfin',
}, },
{
title: 'Discord Rich Presence',
description: [
'Optional Discord Rich Presence activity card updates for current playback/study session.',
'Requires a Discord application client ID and uploaded asset keys.',
],
key: 'discordPresence',
},
]; ];
const IMMERSION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ const IMMERSION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [

View File

@@ -1,5 +1,5 @@
import { ResolveContext } from './context'; import { ResolveContext } from './context';
import { asBoolean, asString, isObject } from './shared'; import { asBoolean, asNumber, asString, isObject } from './shared';
export function applyIntegrationConfig(context: ResolveContext): void { export function applyIntegrationConfig(context: ResolveContext): void {
const { src, resolved, warn } = context; const { src, resolved, warn } = context;
@@ -87,4 +87,67 @@ export function applyIntegrationConfig(context: ResolveContext): void {
); );
} }
} }
if (isObject(src.discordPresence)) {
const enabled = asBoolean(src.discordPresence.enabled);
if (enabled !== undefined) {
resolved.discordPresence.enabled = enabled;
} else if (src.discordPresence.enabled !== undefined) {
warn(
'discordPresence.enabled',
src.discordPresence.enabled,
resolved.discordPresence.enabled,
'Expected boolean.',
);
}
const stringKeys = [
'clientId',
'detailsTemplate',
'stateTemplate',
'largeImageKey',
'largeImageText',
'smallImageKey',
'smallImageText',
'buttonLabel',
'buttonUrl',
] as const;
for (const key of stringKeys) {
const value = asString(src.discordPresence[key]);
if (value !== undefined) {
resolved.discordPresence[key] = value;
} else if (src.discordPresence[key] !== undefined) {
warn(
`discordPresence.${key}`,
src.discordPresence[key],
resolved.discordPresence[key],
'Expected string.',
);
}
}
const updateIntervalMs = asNumber(src.discordPresence.updateIntervalMs);
if (updateIntervalMs !== undefined) {
resolved.discordPresence.updateIntervalMs = Math.max(1_000, Math.floor(updateIntervalMs));
} else if (src.discordPresence.updateIntervalMs !== undefined) {
warn(
'discordPresence.updateIntervalMs',
src.discordPresence.updateIntervalMs,
resolved.discordPresence.updateIntervalMs,
'Expected number.',
);
}
const debounceMs = asNumber(src.discordPresence.debounceMs);
if (debounceMs !== undefined) {
resolved.discordPresence.debounceMs = Math.max(0, Math.floor(debounceMs));
} else if (src.discordPresence.debounceMs !== undefined) {
warn(
'discordPresence.debounceMs',
src.discordPresence.debounceMs,
resolved.discordPresence.debounceMs,
'Expected number.',
);
}
}
} }

View File

@@ -17,7 +17,7 @@ test('jellyfin directPlayContainers are normalized', () => {
test('jellyfin legacy auth keys are ignored by resolver', () => { test('jellyfin legacy auth keys are ignored by resolver', () => {
const { context } = createResolveContext({ const { context } = createResolveContext({
jellyfin: ({ accessToken: 'legacy-token', userId: 'legacy-user' } as unknown) as never, jellyfin: { accessToken: 'legacy-token', userId: 'legacy-user' } as unknown as never,
}); });
applyIntegrationConfig(context); applyIntegrationConfig(context);
@@ -25,3 +25,61 @@ test('jellyfin legacy auth keys are ignored by resolver', () => {
assert.equal('accessToken' in (context.resolved.jellyfin as Record<string, unknown>), false); assert.equal('accessToken' in (context.resolved.jellyfin as Record<string, unknown>), false);
assert.equal('userId' in (context.resolved.jellyfin as Record<string, unknown>), false); assert.equal('userId' in (context.resolved.jellyfin as Record<string, unknown>), false);
}); });
test('discordPresence fields are parsed and clamped', () => {
const { context } = createResolveContext({
discordPresence: {
enabled: true,
clientId: '123456789',
detailsTemplate: 'Watching {title}',
stateTemplate: 'Paused',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner Runtime',
smallImageKey: 'pause',
smallImageText: 'Paused',
buttonLabel: 'Open Repo',
buttonUrl: 'https://github.com/sudacode/SubMiner',
updateIntervalMs: 500,
debounceMs: -100,
},
});
applyIntegrationConfig(context);
assert.equal(context.resolved.discordPresence.enabled, true);
assert.equal(context.resolved.discordPresence.clientId, '123456789');
assert.equal(context.resolved.discordPresence.detailsTemplate, 'Watching {title}');
assert.equal(context.resolved.discordPresence.stateTemplate, 'Paused');
assert.equal(context.resolved.discordPresence.largeImageKey, 'subminer-logo');
assert.equal(context.resolved.discordPresence.largeImageText, 'SubMiner Runtime');
assert.equal(context.resolved.discordPresence.smallImageKey, 'pause');
assert.equal(context.resolved.discordPresence.smallImageText, 'Paused');
assert.equal(context.resolved.discordPresence.buttonLabel, 'Open Repo');
assert.equal(context.resolved.discordPresence.buttonUrl, 'https://github.com/sudacode/SubMiner');
assert.equal(context.resolved.discordPresence.updateIntervalMs, 1000);
assert.equal(context.resolved.discordPresence.debounceMs, 0);
});
test('discordPresence invalid values warn and keep defaults', () => {
const { context, warnings } = createResolveContext({
discordPresence: {
enabled: 'true' as never,
clientId: 123 as never,
updateIntervalMs: 'fast' as never,
debounceMs: null as never,
},
});
applyIntegrationConfig(context);
assert.equal(context.resolved.discordPresence.enabled, false);
assert.equal(context.resolved.discordPresence.clientId, '');
assert.equal(context.resolved.discordPresence.updateIntervalMs, 15_000);
assert.equal(context.resolved.discordPresence.debounceMs, 750);
const warnedPaths = warnings.map((warning) => warning.path);
assert.ok(warnedPaths.includes('discordPresence.enabled'));
assert.ok(warnedPaths.includes('discordPresence.clientId'));
assert.ok(warnedPaths.includes('discordPresence.updateIntervalMs'));
assert.ok(warnedPaths.includes('discordPresence.debounceMs'));
});

View File

@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildDiscordPresenceActivity,
createDiscordPresenceService,
type DiscordActivityPayload,
type DiscordPresenceSnapshot,
} from './discord-presence';
const baseConfig = {
enabled: true,
clientId: '1234',
detailsTemplate: 'Watching {title}',
stateTemplate: '{status}',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: 'study',
smallImageText: 'Sentence Mining',
buttonLabel: 'GitHub',
buttonUrl: 'https://github.com/sudacode/SubMiner',
updateIntervalMs: 10_000,
debounceMs: 200,
} as const;
const baseSnapshot: DiscordPresenceSnapshot = {
mediaTitle: 'Sousou no Frieren E01',
mediaPath: '/media/Frieren/E01.mkv',
subtitleText: '旅立ち',
paused: false,
connected: true,
sessionStartedAtMs: 1_700_000_000_000,
};
test('buildDiscordPresenceActivity maps polished payload fields', () => {
const payload = buildDiscordPresenceActivity(baseConfig, baseSnapshot);
assert.equal(payload.details, 'Watching Sousou no Frieren E01');
assert.equal(payload.state, 'Watching');
assert.equal(payload.largeImageKey, 'subminer-logo');
assert.equal(payload.smallImageKey, 'study');
assert.equal(payload.buttons?.[0]?.label, 'GitHub');
assert.equal(payload.startTimestamp, 1_700_000_000);
});
test('buildDiscordPresenceActivity falls back to idle when disconnected', () => {
const payload = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot,
connected: false,
mediaPath: null,
});
assert.equal(payload.state, 'Idle');
});
test('service deduplicates identical activity updates and throttles interval', async () => {
const sent: DiscordActivityPayload[] = [];
const timers = new Map<number, () => void>();
let timerId = 0;
let nowMs = 100_000;
const service = createDiscordPresenceService({
config: baseConfig,
createClient: () => ({
login: async () => {},
setActivity: async (activity) => {
sent.push(activity);
},
clearActivity: async () => {},
destroy: () => {},
}),
now: () => nowMs,
setTimeoutFn: (callback) => {
const id = ++timerId;
timers.set(id, callback);
return id as unknown as ReturnType<typeof setTimeout>;
},
clearTimeoutFn: (id) => {
timers.delete(id as unknown as number);
},
});
await service.start();
service.publish(baseSnapshot);
timers.get(1)?.();
await Promise.resolve();
assert.equal(sent.length, 1);
service.publish(baseSnapshot);
timers.get(2)?.();
await Promise.resolve();
assert.equal(sent.length, 1);
nowMs += 10_001;
service.publish({ ...baseSnapshot, paused: true });
timers.get(3)?.();
await Promise.resolve();
assert.equal(sent.length, 2);
assert.equal(sent[1]?.state, 'Paused');
});
test('service handles login failure and stop without throwing', async () => {
let destroyed = false;
const service = createDiscordPresenceService({
config: baseConfig,
createClient: () => ({
login: async () => {
throw new Error('discord not running');
},
setActivity: async () => {},
clearActivity: async () => {},
destroy: () => {
destroyed = true;
},
}),
});
await assert.doesNotReject(async () => service.start());
await assert.doesNotReject(async () => service.stop());
assert.equal(destroyed, false);
});

View File

@@ -0,0 +1,208 @@
import type { ResolvedConfig } from '../../types';
export interface DiscordPresenceSnapshot {
mediaTitle: string | null;
mediaPath: string | null;
subtitleText: string;
paused: boolean | null;
connected: boolean;
sessionStartedAtMs: number;
}
type DiscordPresenceConfig = ResolvedConfig['discordPresence'];
export interface DiscordActivityPayload {
details: string;
state: string;
startTimestamp: number;
largeImageKey?: string;
largeImageText?: string;
smallImageKey?: string;
smallImageText?: string;
buttons?: Array<{ label: string; url: string }>;
}
type DiscordClient = {
login: () => Promise<void>;
setActivity: (activity: DiscordActivityPayload) => Promise<void>;
clearActivity: () => Promise<void>;
destroy: () => void;
};
type TimeoutLike = ReturnType<typeof setTimeout>;
function trimField(value: string, maxLength = 128): string {
if (value.length <= maxLength) return value;
return `${value.slice(0, Math.max(0, maxLength - 1))}`;
}
function sanitizeText(value: string | null | undefined, fallback: string): string {
const text = value?.trim();
if (!text) return fallback;
return text;
}
function basename(filePath: string | null): string {
if (!filePath) return '';
const parts = filePath.split(/[\\/]/);
return parts[parts.length - 1] ?? '';
}
function interpolate(template: string, values: Record<string, string>): string {
return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key: string) => values[key] ?? '');
}
function buildStatus(snapshot: DiscordPresenceSnapshot): string {
if (!snapshot.connected || !snapshot.mediaPath) return 'Idle';
if (snapshot.paused) return 'Paused';
return 'Watching';
}
export function buildDiscordPresenceActivity(
config: DiscordPresenceConfig,
snapshot: DiscordPresenceSnapshot,
): DiscordActivityPayload {
const status = buildStatus(snapshot);
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
const subtitle = sanitizeText(snapshot.subtitleText, '');
const file = sanitizeText(basename(snapshot.mediaPath), '');
const values = {
title,
file,
subtitle,
status,
};
const details = trimField(
interpolate(config.detailsTemplate, values).trim() || `Watching ${title}`,
);
const state = trimField(
interpolate(config.stateTemplate, values).trim() || `${status} with SubMiner`,
);
const activity: DiscordActivityPayload = {
details,
state,
startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000),
};
if (config.largeImageKey.trim().length > 0) {
activity.largeImageKey = config.largeImageKey.trim();
}
if (config.largeImageText.trim().length > 0) {
activity.largeImageText = trimField(config.largeImageText.trim());
}
if (config.smallImageKey.trim().length > 0) {
activity.smallImageKey = config.smallImageKey.trim();
}
if (config.smallImageText.trim().length > 0) {
activity.smallImageText = trimField(config.smallImageText.trim());
}
if (config.buttonLabel.trim().length > 0 && /^https?:\/\//.test(config.buttonUrl.trim())) {
activity.buttons = [
{ label: trimField(config.buttonLabel.trim(), 32), url: config.buttonUrl.trim() },
];
}
return activity;
}
export function createDiscordPresenceService(deps: {
config: DiscordPresenceConfig;
createClient: (clientId: string) => DiscordClient;
now?: () => number;
setTimeoutFn?: (callback: () => void, delayMs: number) => TimeoutLike;
clearTimeoutFn?: (timer: TimeoutLike) => void;
logDebug?: (message: string, meta?: unknown) => void;
}) {
const now = deps.now ?? (() => Date.now());
const setTimeoutFn = deps.setTimeoutFn ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const clearTimeoutFn = deps.clearTimeoutFn ?? ((timer) => clearTimeout(timer));
const logDebug = deps.logDebug ?? (() => {});
let client: DiscordClient | null = null;
let pendingSnapshot: DiscordPresenceSnapshot | null = null;
let debounceTimer: TimeoutLike | null = null;
let intervalTimer: TimeoutLike | null = null;
let lastActivityKey = '';
let lastSentAtMs = 0;
async function flush(): Promise<void> {
if (!client || !pendingSnapshot) return;
const elapsed = now() - lastSentAtMs;
if (elapsed < deps.config.updateIntervalMs) {
const delay = Math.max(0, deps.config.updateIntervalMs - elapsed);
if (intervalTimer) clearTimeoutFn(intervalTimer);
intervalTimer = setTimeoutFn(() => {
void flush();
}, delay);
return;
}
const payload = buildDiscordPresenceActivity(deps.config, pendingSnapshot);
const activityKey = JSON.stringify(payload);
if (activityKey === lastActivityKey) return;
try {
await client.setActivity(payload);
lastSentAtMs = now();
lastActivityKey = activityKey;
} catch (error) {
logDebug('[discord-presence] failed to set activity', error);
}
}
function scheduleFlush(snapshot: DiscordPresenceSnapshot): void {
pendingSnapshot = snapshot;
if (debounceTimer) {
clearTimeoutFn(debounceTimer);
}
debounceTimer = setTimeoutFn(() => {
debounceTimer = null;
void flush();
}, deps.config.debounceMs);
}
return {
async start(): Promise<void> {
if (!deps.config.enabled) return;
const clientId = deps.config.clientId.trim();
if (!clientId) {
logDebug('[discord-presence] enabled but clientId missing; skipping start');
return;
}
try {
client = deps.createClient(clientId);
await client.login();
} catch (error) {
client = null;
logDebug('[discord-presence] login failed', error);
}
},
publish(snapshot: DiscordPresenceSnapshot): void {
if (!client) return;
scheduleFlush(snapshot);
},
async stop(): Promise<void> {
if (debounceTimer) {
clearTimeoutFn(debounceTimer);
debounceTimer = null;
}
if (intervalTimer) {
clearTimeoutFn(intervalTimer);
intervalTimer = null;
}
pendingSnapshot = null;
lastActivityKey = '';
lastSentAtMs = 0;
if (!client) return;
try {
await client.clearActivity();
} catch (error) {
logDebug('[discord-presence] clear activity failed', error);
}
client.destroy();
client = null;
},
};
}

View File

@@ -48,7 +48,7 @@ test('createFieldGroupingOverlayRuntime callback cancels when send fails', async
setVisibleOverlayVisible: () => {}, setVisibleOverlayVisible: () => {},
setInvisibleOverlayVisible: () => {}, setInvisibleOverlayVisible: () => {},
getResolver: () => resolver, getResolver: () => resolver,
setResolver: (next) => { setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => {
resolver = next; resolver = next;
}, },
getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(), getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(),
@@ -78,3 +78,64 @@ test('createFieldGroupingOverlayRuntime callback cancels when send fails', async
assert.equal(result.keepNoteId, 0); assert.equal(result.keepNoteId, 0);
assert.equal(result.deleteNoteId, 0); assert.equal(result.deleteNoteId, 0);
}); });
test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay after resolver settles', async () => {
let resolver: unknown = null;
let visible = false;
const visibilityTransitions: boolean[] = [];
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => visible,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: (nextVisible) => {
visible = nextVisible;
visibilityTransitions.push(nextVisible);
},
setInvisibleOverlayVisible: () => {},
getResolver: () => resolver as ((choice: KikuFieldGroupingChoice) => void) | null,
setResolver: (nextResolver: ((choice: KikuFieldGroupingChoice) => void) | null) => {
resolver = nextResolver;
},
getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(),
sendToVisibleOverlay: () => true,
});
const callback = runtime.createFieldGroupingCallback();
const pendingChoice = callback({
original: {
noteId: 1,
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: 'b',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,
},
});
assert.equal(visible, true);
assert.ok(resolver);
if (typeof resolver !== 'function') {
throw new Error('expected field grouping resolver to be assigned');
}
(resolver as (choice: KikuFieldGroupingChoice) => void)({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: true,
cancelled: false,
});
await pendingChoice;
assert.equal(visible, false);
assert.deepEqual(visibilityTransitions, [true, false]);
});

View File

@@ -42,7 +42,12 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
runtimeOptions?: { restoreOnModalClose?: T }, runtimeOptions?: { restoreOnModalClose?: T },
): boolean => { ): boolean => {
if (options.sendToVisibleOverlay) { if (options.sendToVisibleOverlay) {
return options.sendToVisibleOverlay(channel, payload, runtimeOptions); const wasVisible = options.getVisibleOverlayVisible();
const sent = options.sendToVisibleOverlay(channel, payload, runtimeOptions);
if (sent && !wasVisible && !options.getVisibleOverlayVisible()) {
options.setVisibleOverlayVisible(true);
}
return sent;
} }
return sendToVisibleOverlayRuntime({ return sendToVisibleOverlayRuntime({
mainWindow: options.getMainWindow() as never, mainWindow: options.getMainWindow() as never,

View File

@@ -109,3 +109,4 @@ export {
setOverlayDebugVisualizationEnabledRuntime, setOverlayDebugVisualizationEnabledRuntime,
} from './overlay-manager'; } from './overlay-manager';
export { createConfigHotReloadRuntime, classifyConfigHotReloadDiff } from './config-hot-reload'; export { createConfigHotReloadRuntime, classifyConfigHotReloadDiff } from './config-hot-reload';
export { createDiscordPresenceService, buildDiscordPresenceActivity } from './discord-presence';

View File

@@ -29,12 +29,13 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'), destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'),
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'), clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
cleanup(); cleanup();
assert.equal(calls.length, 20); assert.equal(calls.length, 21);
assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-jellyfin-remote'); assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
}); });

View File

@@ -19,6 +19,7 @@ export function createOnWillQuitCleanupHandler(deps: {
destroyJellyfinSetupWindow: () => void; destroyJellyfinSetupWindow: () => void;
clearJellyfinSetupWindow: () => void; clearJellyfinSetupWindow: () => void;
stopJellyfinRemoteSession: () => void; stopJellyfinRemoteSession: () => void;
stopDiscordPresenceService: () => void;
}) { }) {
return (): void => { return (): void => {
deps.destroyTray(); deps.destroyTray();
@@ -41,6 +42,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.destroyJellyfinSetupWindow(); deps.destroyJellyfinSetupWindow();
deps.clearJellyfinSetupWindow(); deps.clearJellyfinSetupWindow();
deps.stopJellyfinRemoteSession(); deps.stopJellyfinRemoteSession();
deps.stopDiscordPresenceService();
}; };
} }

View File

@@ -47,6 +47,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'), clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
const cleanup = createOnWillQuitCleanupHandler(depsFactory()); const cleanup = createOnWillQuitCleanupHandler(depsFactory());
@@ -60,6 +61,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('destroy-immersion')); assert.ok(calls.includes('destroy-immersion'));
assert.ok(calls.includes('clear-immersion-ref')); assert.ok(calls.includes('clear-immersion-ref'));
assert.ok(calls.includes('stop-jellyfin-remote')); assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('stop-discord-presence'));
assert.equal(reconnectTimer, null); assert.equal(reconnectTimer, null);
assert.equal(immersionTracker, null); assert.equal(immersionTracker, null);
}); });
@@ -92,6 +94,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
getJellyfinSetupWindow: () => null, getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {}, clearJellyfinSetupWindow: () => {},
stopJellyfinRemoteSession: () => {}, stopJellyfinRemoteSession: () => {},
stopDiscordPresenceService: () => {},
}); });
const cleanup = createOnWillQuitCleanupHandler(depsFactory()); const cleanup = createOnWillQuitCleanupHandler(depsFactory());

View File

@@ -45,6 +45,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearJellyfinSetupWindow: () => void; clearJellyfinSetupWindow: () => void;
stopJellyfinRemoteSession: () => void; stopJellyfinRemoteSession: () => void;
stopDiscordPresenceService: () => void;
}) { }) {
return () => ({ return () => ({
destroyTray: () => deps.destroyTray(), destroyTray: () => deps.destroyTray(),
@@ -96,5 +97,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
}, },
clearJellyfinSetupWindow: () => deps.clearJellyfinSetupWindow(), clearJellyfinSetupWindow: () => deps.clearJellyfinSetupWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
}); });
} }

View File

@@ -4,6 +4,7 @@ export * from './app-ready-composer';
export * from './contracts'; export * from './contracts';
export * from './ipc-runtime-composer'; export * from './ipc-runtime-composer';
export * from './jellyfin-remote-composer'; export * from './jellyfin-remote-composer';
export * from './jellyfin-runtime-composer';
export * from './mpv-runtime-composer'; export * from './mpv-runtime-composer';
export * from './shortcuts-runtime-composer'; export * from './shortcuts-runtime-composer';
export * from './startup-lifecycle-composer'; export * from './startup-lifecycle-composer';

View File

@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeJellyfinRuntimeHandlers } from './jellyfin-runtime-composer';
test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers', () => {
let activePlayback: unknown = null;
let lastProgressAtMs = 0;
const composed = composeJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => ({ jellyfin: { enabled: false, serverUrl: '' } }) as never,
loadStoredSession: () => null,
getEnv: () => undefined,
},
getJellyfinClientInfoMainDeps: {
getResolvedJellyfinConfig: () => ({}) as never,
getDefaultJellyfinConfig: () => ({
clientName: 'SubMiner',
clientVersion: 'test',
deviceId: 'dev',
}),
},
waitForMpvConnectedMainDeps: {
getMpvClient: () => null,
now: () => Date.now(),
sleep: async () => {},
},
launchMpvIdleForJellyfinPlaybackMainDeps: {
getSocketPath: () => '/tmp/test-mpv.sock',
platform: 'linux',
execPath: process.execPath,
defaultMpvLogPath: '/tmp/test-mpv.log',
defaultMpvArgs: [],
removeSocketPath: () => {},
spawnMpv: () => ({ unref: () => {} }) as never,
logWarn: () => {},
logInfo: () => {},
},
ensureMpvConnectedForJellyfinPlaybackMainDeps: {
getMpvClient: () => null,
setMpvClient: () => {},
createMpvClient: () => ({}) as never,
getAutoLaunchInFlight: () => null,
setAutoLaunchInFlight: () => {},
connectTimeoutMs: 10,
autoLaunchTimeoutMs: 10,
},
preloadJellyfinExternalSubtitlesMainDeps: {
listJellyfinSubtitleTracks: async () => [],
getMpvClient: () => null,
sendMpvCommand: () => {},
wait: async () => {},
logDebug: () => {},
},
playJellyfinItemInMpvMainDeps: {
getMpvClient: () => null,
resolvePlaybackPlan: async () => ({
mode: 'direct',
url: 'https://example.test/video.m3u8',
title: 'Episode 1',
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
sendMpvCommand: () => {},
armQuitOnDisconnect: () => {},
schedule: () => undefined,
convertTicksToSeconds: () => 0,
setActivePlayback: (value) => {
activePlayback = value;
},
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
reportPlaying: () => {},
showMpvOsd: () => {},
},
remoteComposerOptions: {
getConfiguredSession: () => null,
logWarn: () => {},
getMpvClient: () => null,
sendMpvCommand: () => {},
jellyfinTicksToSeconds: () => 0,
getActivePlayback: () => activePlayback as never,
clearActivePlayback: () => {
activePlayback = null;
},
getSession: () => null,
getNow: () => Date.now(),
getLastProgressAtMs: () => lastProgressAtMs,
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
},
handleJellyfinAuthCommandsMainDeps: {
patchRawConfig: () => {},
authenticateWithPassword: async () => ({
serverUrl: 'https://example.test',
username: 'user',
accessToken: 'token',
userId: 'id',
}),
saveStoredSession: () => {},
clearStoredSession: () => {},
logInfo: () => {},
},
handleJellyfinListCommandsMainDeps: {
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
logInfo: () => {},
},
handleJellyfinPlayCommandMainDeps: {
logWarn: () => {},
},
handleJellyfinRemoteAnnounceCommandMainDeps: {
getRemoteSession: () => null,
logInfo: () => {},
logWarn: () => {},
},
startJellyfinRemoteSessionMainDeps: {
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: () =>
({
start: async () => {},
}) as never,
defaultDeviceId: 'dev',
defaultClientName: 'SubMiner',
defaultClientVersion: 'test',
logInfo: () => {},
logWarn: () => {},
},
stopJellyfinRemoteSessionMainDeps: {
getCurrentSession: () => null,
setCurrentSession: () => {},
clearActivePlayback: () => {
activePlayback = null;
},
},
runJellyfinCommandMainDeps: {
defaultServerUrl: 'https://example.test',
},
maybeFocusExistingJellyfinSetupWindowMainDeps: {
getSetupWindow: () => null,
},
openJellyfinSetupWindowMainDeps: {
createSetupWindow: () =>
({
focus: () => {},
webContents: { on: () => {} },
loadURL: () => {},
on: () => {},
isDestroyed: () => false,
close: () => {},
}) as never,
buildSetupFormHtml: (defaultServer, defaultUser) =>
`<html>${defaultServer}${defaultUser}</html>`,
parseSubmissionUrl: () => null,
authenticateWithPassword: async () => ({
serverUrl: 'https://example.test',
username: 'user',
accessToken: 'token',
userId: 'id',
}),
saveStoredSession: () => {},
patchJellyfinConfig: () => {},
logInfo: () => {},
logError: () => {},
showMpvOsd: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {},
encodeURIComponent,
},
});
assert.equal(typeof composed.getResolvedJellyfinConfig, 'function');
assert.equal(typeof composed.getJellyfinClientInfo, 'function');
assert.equal(typeof composed.reportJellyfinRemoteProgress, 'function');
assert.equal(typeof composed.reportJellyfinRemoteStopped, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlay, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
assert.equal(typeof composed.playJellyfinItemInMpv, 'function');
assert.equal(typeof composed.startJellyfinRemoteSession, 'function');
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
assert.equal(typeof composed.runJellyfinCommand, 'function');
assert.equal(typeof composed.openJellyfinSetupWindow, 'function');
});

View File

@@ -0,0 +1,290 @@
import {
buildJellyfinSetupFormHtml,
createEnsureMpvConnectedForJellyfinPlaybackHandler,
createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler,
createBuildGetJellyfinClientInfoMainDepsHandler,
createBuildGetResolvedJellyfinConfigMainDepsHandler,
createBuildHandleJellyfinAuthCommandsMainDepsHandler,
createBuildHandleJellyfinListCommandsMainDepsHandler,
createBuildHandleJellyfinPlayCommandMainDepsHandler,
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler,
createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler,
createBuildOpenJellyfinSetupWindowMainDepsHandler,
createBuildPlayJellyfinItemInMpvMainDepsHandler,
createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler,
createBuildRunJellyfinCommandMainDepsHandler,
createBuildStartJellyfinRemoteSessionMainDepsHandler,
createBuildStopJellyfinRemoteSessionMainDepsHandler,
createBuildWaitForMpvConnectedMainDepsHandler,
createGetJellyfinClientInfoHandler,
createGetResolvedJellyfinConfigHandler,
createHandleJellyfinAuthCommands,
createHandleJellyfinListCommands,
createHandleJellyfinPlayCommand,
createHandleJellyfinRemoteAnnounceCommand,
createLaunchMpvIdleForJellyfinPlaybackHandler,
createOpenJellyfinSetupWindowHandler,
createPlayJellyfinItemInMpvHandler,
createPreloadJellyfinExternalSubtitlesHandler,
createRunJellyfinCommandHandler,
createStartJellyfinRemoteSessionHandler,
createStopJellyfinRemoteSessionHandler,
createWaitForMpvConnectedHandler,
createMaybeFocusExistingJellyfinSetupWindowHandler,
parseJellyfinSetupSubmissionUrl,
} from '../domains/jellyfin';
import {
composeJellyfinRemoteHandlers,
type JellyfinRemoteComposerOptions,
} from './jellyfin-remote-composer';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type EnsureMpvConnectedMainDeps = Parameters<
typeof createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler
>[0];
type PlayJellyfinItemMainDeps = Parameters<
typeof createBuildPlayJellyfinItemInMpvMainDepsHandler
>[0];
type HandlePlayCommandMainDeps = Parameters<
typeof createBuildHandleJellyfinPlayCommandMainDepsHandler
>[0];
type HandleRemoteAnnounceMainDeps = Parameters<
typeof createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler
>[0];
type StartRemoteSessionMainDeps = Parameters<
typeof createBuildStartJellyfinRemoteSessionMainDepsHandler
>[0];
type RunJellyfinCommandMainDeps = Parameters<
typeof createBuildRunJellyfinCommandMainDepsHandler
>[0];
type OpenJellyfinSetupWindowMainDeps = Parameters<
typeof createBuildOpenJellyfinSetupWindowMainDepsHandler
>[0];
export type JellyfinRuntimeComposerOptions = ComposerInputs<{
getResolvedJellyfinConfigMainDeps: Parameters<
typeof createBuildGetResolvedJellyfinConfigMainDepsHandler
>[0];
getJellyfinClientInfoMainDeps: Parameters<
typeof createBuildGetJellyfinClientInfoMainDepsHandler
>[0];
waitForMpvConnectedMainDeps: Parameters<typeof createBuildWaitForMpvConnectedMainDepsHandler>[0];
launchMpvIdleForJellyfinPlaybackMainDeps: Parameters<
typeof createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler
>[0];
ensureMpvConnectedForJellyfinPlaybackMainDeps: Omit<
EnsureMpvConnectedMainDeps,
'waitForMpvConnected' | 'launchMpvIdleForJellyfinPlayback'
>;
preloadJellyfinExternalSubtitlesMainDeps: Parameters<
typeof createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler
>[0];
playJellyfinItemInMpvMainDeps: Omit<
PlayJellyfinItemMainDeps,
'ensureMpvConnectedForPlayback' | 'preloadExternalSubtitles'
>;
remoteComposerOptions: Omit<
JellyfinRemoteComposerOptions,
'getClientInfo' | 'getJellyfinConfig' | 'playJellyfinItem'
>;
handleJellyfinAuthCommandsMainDeps: Parameters<
typeof createBuildHandleJellyfinAuthCommandsMainDepsHandler
>[0];
handleJellyfinListCommandsMainDeps: Parameters<
typeof createBuildHandleJellyfinListCommandsMainDepsHandler
>[0];
handleJellyfinPlayCommandMainDeps: Omit<HandlePlayCommandMainDeps, 'playJellyfinItemInMpv'>;
handleJellyfinRemoteAnnounceCommandMainDeps: Omit<
HandleRemoteAnnounceMainDeps,
'startJellyfinRemoteSession'
>;
startJellyfinRemoteSessionMainDeps: Omit<
StartRemoteSessionMainDeps,
'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand'
>;
stopJellyfinRemoteSessionMainDeps: Parameters<
typeof createBuildStopJellyfinRemoteSessionMainDepsHandler
>[0];
runJellyfinCommandMainDeps: Omit<
RunJellyfinCommandMainDeps,
| 'getJellyfinConfig'
| 'getJellyfinClientInfo'
| 'handleAuthCommands'
| 'handleRemoteAnnounceCommand'
| 'handleListCommands'
| 'handlePlayCommand'
>;
maybeFocusExistingJellyfinSetupWindowMainDeps: Parameters<
typeof createMaybeFocusExistingJellyfinSetupWindowHandler
>[0];
openJellyfinSetupWindowMainDeps: Omit<
OpenJellyfinSetupWindowMainDeps,
'maybeFocusExistingSetupWindow' | 'getResolvedJellyfinConfig' | 'getJellyfinClientInfo'
>;
}>;
export type JellyfinRuntimeComposerResult = ComposerOutputs<{
getResolvedJellyfinConfig: ReturnType<typeof createGetResolvedJellyfinConfigHandler>;
getJellyfinClientInfo: ReturnType<typeof createGetJellyfinClientInfoHandler>;
reportJellyfinRemoteProgress: ReturnType<
typeof composeJellyfinRemoteHandlers
>['reportJellyfinRemoteProgress'];
reportJellyfinRemoteStopped: ReturnType<
typeof composeJellyfinRemoteHandlers
>['reportJellyfinRemoteStopped'];
handleJellyfinRemotePlay: ReturnType<
typeof composeJellyfinRemoteHandlers
>['handleJellyfinRemotePlay'];
handleJellyfinRemotePlaystate: ReturnType<
typeof composeJellyfinRemoteHandlers
>['handleJellyfinRemotePlaystate'];
handleJellyfinRemoteGeneralCommand: ReturnType<
typeof composeJellyfinRemoteHandlers
>['handleJellyfinRemoteGeneralCommand'];
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>;
openJellyfinSetupWindow: ReturnType<typeof createOpenJellyfinSetupWindowHandler>;
}>;
export function composeJellyfinRuntimeHandlers(
options: JellyfinRuntimeComposerOptions,
): JellyfinRuntimeComposerResult {
const getResolvedJellyfinConfig = createGetResolvedJellyfinConfigHandler(
createBuildGetResolvedJellyfinConfigMainDepsHandler(
options.getResolvedJellyfinConfigMainDeps,
)(),
);
const getJellyfinClientInfo = createGetJellyfinClientInfoHandler(
createBuildGetJellyfinClientInfoMainDepsHandler(options.getJellyfinClientInfoMainDeps)(),
);
const waitForMpvConnected = createWaitForMpvConnectedHandler(
createBuildWaitForMpvConnectedMainDepsHandler(options.waitForMpvConnectedMainDeps)(),
);
const launchMpvIdleForJellyfinPlayback = createLaunchMpvIdleForJellyfinPlaybackHandler(
createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
options.launchMpvIdleForJellyfinPlaybackMainDeps,
)(),
);
const ensureMpvConnectedForJellyfinPlayback = createEnsureMpvConnectedForJellyfinPlaybackHandler(
createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler({
...options.ensureMpvConnectedForJellyfinPlaybackMainDeps,
waitForMpvConnected: (timeoutMs) => waitForMpvConnected(timeoutMs),
launchMpvIdleForJellyfinPlayback: () => launchMpvIdleForJellyfinPlayback(),
})(),
);
const preloadJellyfinExternalSubtitles = createPreloadJellyfinExternalSubtitlesHandler(
createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
options.preloadJellyfinExternalSubtitlesMainDeps,
)(),
);
const playJellyfinItemInMpv = createPlayJellyfinItemInMpvHandler(
createBuildPlayJellyfinItemInMpvMainDepsHandler({
...options.playJellyfinItemInMpvMainDeps,
ensureMpvConnectedForPlayback: () => ensureMpvConnectedForJellyfinPlayback(),
preloadExternalSubtitles: (params) => {
void preloadJellyfinExternalSubtitles(params);
},
})(),
);
const {
reportJellyfinRemoteProgress,
reportJellyfinRemoteStopped,
handleJellyfinRemotePlay,
handleJellyfinRemotePlaystate,
handleJellyfinRemoteGeneralCommand,
} = composeJellyfinRemoteHandlers({
...options.remoteComposerOptions,
getClientInfo: () => getJellyfinClientInfo(),
getJellyfinConfig: () => getResolvedJellyfinConfig(),
playJellyfinItem: (params) =>
playJellyfinItemInMpv(params as Parameters<typeof playJellyfinItemInMpv>[0]),
});
const handleJellyfinAuthCommands = createHandleJellyfinAuthCommands(
createBuildHandleJellyfinAuthCommandsMainDepsHandler(
options.handleJellyfinAuthCommandsMainDeps,
)(),
);
const handleJellyfinListCommands = createHandleJellyfinListCommands(
createBuildHandleJellyfinListCommandsMainDepsHandler(
options.handleJellyfinListCommandsMainDeps,
)(),
);
const handleJellyfinPlayCommand = createHandleJellyfinPlayCommand(
createBuildHandleJellyfinPlayCommandMainDepsHandler({
...options.handleJellyfinPlayCommandMainDeps,
playJellyfinItemInMpv: (params) =>
playJellyfinItemInMpv(params as Parameters<typeof playJellyfinItemInMpv>[0]),
})(),
);
let startJellyfinRemoteSession!: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
const handleJellyfinRemoteAnnounceCommand = createHandleJellyfinRemoteAnnounceCommand(
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
...options.handleJellyfinRemoteAnnounceCommandMainDeps,
startJellyfinRemoteSession: () => startJellyfinRemoteSession(),
})(),
);
startJellyfinRemoteSession = createStartJellyfinRemoteSessionHandler(
createBuildStartJellyfinRemoteSessionMainDepsHandler({
...options.startJellyfinRemoteSessionMainDeps,
getJellyfinConfig: () => getResolvedJellyfinConfig(),
handlePlay: (payload) => handleJellyfinRemotePlay(payload),
handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload),
handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload),
})(),
);
const stopJellyfinRemoteSession = createStopJellyfinRemoteSessionHandler(
createBuildStopJellyfinRemoteSessionMainDepsHandler(
options.stopJellyfinRemoteSessionMainDeps,
)(),
);
const runJellyfinCommand = createRunJellyfinCommandHandler(
createBuildRunJellyfinCommandMainDepsHandler({
...options.runJellyfinCommandMainDeps,
getJellyfinConfig: () => getResolvedJellyfinConfig(),
getJellyfinClientInfo: (jellyfinConfig) => getJellyfinClientInfo(jellyfinConfig),
handleAuthCommands: (params) => handleJellyfinAuthCommands(params),
handleRemoteAnnounceCommand: (args) => handleJellyfinRemoteAnnounceCommand(args),
handleListCommands: (params) => handleJellyfinListCommands(params),
handlePlayCommand: (params) => handleJellyfinPlayCommand(params),
})(),
);
const maybeFocusExistingJellyfinSetupWindow = createMaybeFocusExistingJellyfinSetupWindowHandler(
options.maybeFocusExistingJellyfinSetupWindowMainDeps,
);
const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler(
createBuildOpenJellyfinSetupWindowMainDepsHandler({
...options.openJellyfinSetupWindowMainDeps,
maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow,
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getJellyfinClientInfo: () => getJellyfinClientInfo(),
})(),
);
return {
getResolvedJellyfinConfig,
getJellyfinClientInfo,
reportJellyfinRemoteProgress,
reportJellyfinRemoteStopped,
handleJellyfinRemotePlay,
handleJellyfinRemotePlaystate,
handleJellyfinRemoteGeneralCommand,
playJellyfinItemInMpv,
startJellyfinRemoteSession,
stopJellyfinRemoteSession,
runJellyfinCommand,
openJellyfinSetupWindow,
};
}
export { buildJellyfinSetupFormHtml, parseJellyfinSetupSubmissionUrl };

View File

@@ -61,6 +61,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
subtitleTimingTracker: null, subtitleTimingTracker: null,
currentSubText: '', currentSubText: '',
currentSubAssText: '', currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null, previousSecondarySubVisibility: null,
}, },
getQuitOnDisconnectArmed: () => false, getQuitOnDisconnectArmed: () => false,
@@ -71,6 +72,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
logSubtitleTimingError: () => {}, logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {}, broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {}, onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
updateCurrentMediaPath: () => {}, updateCurrentMediaPath: () => {},
getCurrentAnilistMediaKey: () => null, getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {}, resetAnilistMediaTracking: () => {},

View File

@@ -35,6 +35,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
getJellyfinSetupWindow: () => null, getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {}, clearJellyfinSetupWindow: () => {},
stopJellyfinRemoteSession: async () => {}, stopJellyfinRemoteSession: async () => {},
stopDiscordPresenceService: () => {},
}, },
shouldRestoreWindowsOnActivateMainDeps: { shouldRestoreWindowsOnActivateMainDeps: {
isOverlayRuntimeInitialized: () => false, isOverlayRuntimeInitialized: () => false,

View File

@@ -10,6 +10,7 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
const calls: string[] = []; const calls: string[] = [];
const handler = createHandleMpvConnectionChangeHandler({ const handler = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => calls.push('report-stop'), reportJellyfinRemoteStopped: () => calls.push('report-stop'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
hasInitialJellyfinPlayArg: () => true, hasInitialJellyfinPlayArg: () => true,
isOverlayRuntimeInitialized: () => false, isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => true, isQuitOnDisconnectArmed: () => true,
@@ -22,7 +23,7 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
}); });
handler({ connected: false }); handler({ connected: false });
assert.deepEqual(calls, ['report-stop', 'schedule', 'quit']); assert.deepEqual(calls, ['presence-refresh', 'report-stop', 'schedule', 'quit']);
}); });
test('mpv subtitle timing handler ignores blank subtitle lines', () => { test('mpv subtitle timing handler ignores blank subtitle lines', () => {

View File

@@ -17,6 +17,7 @@ type MpvEventClient = {
export function createHandleMpvConnectionChangeHandler(deps: { export function createHandleMpvConnectionChangeHandler(deps: {
reportJellyfinRemoteStopped: () => void; reportJellyfinRemoteStopped: () => void;
refreshDiscordPresence: () => void;
hasInitialJellyfinPlayArg: () => boolean; hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean; isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean; isQuitOnDisconnectArmed: () => boolean;
@@ -25,6 +26,7 @@ export function createHandleMpvConnectionChangeHandler(deps: {
quitApp: () => void; quitApp: () => void;
}) { }) {
return ({ connected }: { connected: boolean }): void => { return ({ connected }: { connected: boolean }): void => {
deps.refreshDiscordPresence();
if (connected) return; if (connected) return;
deps.reportJellyfinRemoteStopped(); deps.reportJellyfinRemoteStopped();
if (!deps.hasInitialJellyfinPlayArg()) return; if (!deps.hasInitialJellyfinPlayArg()) return;

View File

@@ -18,10 +18,11 @@ test('subtitle change handler updates state, broadcasts, and forwards', () => {
setCurrentSubText: (text) => calls.push(`set:${text}`), setCurrentSubText: (text) => calls.push(`set:${text}`),
broadcastSubtitle: (payload) => calls.push(`broadcast:${payload.text}`), broadcastSubtitle: (payload) => calls.push(`broadcast:${payload.text}`),
onSubtitleChange: (text) => calls.push(`process:${text}`), onSubtitleChange: (text) => calls.push(`process:${text}`),
refreshDiscordPresence: () => calls.push('presence'),
}); });
handler({ text: 'line' }); handler({ text: 'line' });
assert.deepEqual(calls, ['set:line', 'broadcast:line', 'process:line']); assert.deepEqual(calls, ['set:line', 'broadcast:line', 'process:line', 'presence']);
}); });
test('subtitle ass change handler updates state and broadcasts', () => { test('subtitle ass change handler updates state and broadcasts', () => {
@@ -55,6 +56,7 @@ test('media path change handler reports stop for empty path and probes media key
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'), syncImmersionMediaState: () => calls.push('sync'),
refreshDiscordPresence: () => calls.push('presence'),
}); });
handler({ path: '' }); handler({ path: '' });
@@ -65,6 +67,7 @@ test('media path change handler reports stop for empty path and probes media key
'probe:show:1', 'probe:show:1',
'guess:show:1', 'guess:show:1',
'sync', 'sync',
'presence',
]); ]);
}); });
@@ -75,10 +78,17 @@ test('media title change handler clears guess state and syncs immersion', () =>
resetAnilistMediaGuessState: () => calls.push('reset-guess'), resetAnilistMediaGuessState: () => calls.push('reset-guess'),
notifyImmersionTitleUpdate: (title) => calls.push(`notify:${title}`), notifyImmersionTitleUpdate: (title) => calls.push(`notify:${title}`),
syncImmersionMediaState: () => calls.push('sync'), syncImmersionMediaState: () => calls.push('sync'),
refreshDiscordPresence: () => calls.push('presence'),
}); });
handler({ title: 'Episode 1' }); handler({ title: 'Episode 1' });
assert.deepEqual(calls, ['title:Episode 1', 'reset-guess', 'notify:Episode 1', 'sync']); assert.deepEqual(calls, [
'title:Episode 1',
'reset-guess',
'notify:Episode 1',
'sync',
'presence',
]);
}); });
test('time-pos and pause handlers report progress with correct urgency', () => { test('time-pos and pause handlers report progress with correct urgency', () => {
@@ -86,15 +96,24 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
const timeHandler = createHandleMpvTimePosChangeHandler({ const timeHandler = createHandleMpvTimePosChangeHandler({
recordPlaybackPosition: (time) => calls.push(`time:${time}`), recordPlaybackPosition: (time) => calls.push(`time:${time}`),
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`), reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
refreshDiscordPresence: () => calls.push('presence'),
}); });
const pauseHandler = createHandleMpvPauseChangeHandler({ const pauseHandler = createHandleMpvPauseChangeHandler({
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`), recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`), reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
refreshDiscordPresence: () => calls.push('presence'),
}); });
timeHandler({ time: 12.5 }); timeHandler({ time: 12.5 });
pauseHandler({ paused: true }); pauseHandler({ paused: true });
assert.deepEqual(calls, ['time:12.5', 'progress:normal', 'pause:yes', 'progress:force']); assert.deepEqual(calls, [
'time:12.5',
'progress:normal',
'presence',
'pause:yes',
'progress:force',
'presence',
]);
}); });
test('subtitle metrics change handler forwards patch payload', () => { test('subtitle metrics change handler forwards patch payload', () => {

View File

@@ -2,11 +2,13 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
setCurrentSubText: (text: string) => void; setCurrentSubText: (text: string) => void;
broadcastSubtitle: (payload: { text: string; tokens: null }) => void; broadcastSubtitle: (payload: { text: string; tokens: null }) => void;
onSubtitleChange: (text: string) => void; onSubtitleChange: (text: string) => void;
refreshDiscordPresence: () => void;
}) { }) {
return ({ text }: { text: string }): void => { return ({ text }: { text: string }): void => {
deps.setCurrentSubText(text); deps.setCurrentSubText(text);
deps.broadcastSubtitle({ text, tokens: null }); deps.broadcastSubtitle({ text, tokens: null });
deps.onSubtitleChange(text); deps.onSubtitleChange(text);
deps.refreshDiscordPresence();
}; };
} }
@@ -36,6 +38,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
maybeProbeAnilistDuration: (mediaKey: string) => void; maybeProbeAnilistDuration: (mediaKey: string) => void;
ensureAnilistMediaGuess: (mediaKey: string) => void; ensureAnilistMediaGuess: (mediaKey: string) => void;
syncImmersionMediaState: () => void; syncImmersionMediaState: () => void;
refreshDiscordPresence: () => void;
}) { }) {
return ({ path }: { path: string }): void => { return ({ path }: { path: string }): void => {
deps.updateCurrentMediaPath(path); deps.updateCurrentMediaPath(path);
@@ -49,6 +52,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
deps.ensureAnilistMediaGuess(mediaKey); deps.ensureAnilistMediaGuess(mediaKey);
} }
deps.syncImmersionMediaState(); deps.syncImmersionMediaState();
deps.refreshDiscordPresence();
}; };
} }
@@ -57,32 +61,38 @@ export function createHandleMpvMediaTitleChangeHandler(deps: {
resetAnilistMediaGuessState: () => void; resetAnilistMediaGuessState: () => void;
notifyImmersionTitleUpdate: (title: string) => void; notifyImmersionTitleUpdate: (title: string) => void;
syncImmersionMediaState: () => void; syncImmersionMediaState: () => void;
refreshDiscordPresence: () => void;
}) { }) {
return ({ title }: { title: string }): void => { return ({ title }: { title: string }): void => {
deps.updateCurrentMediaTitle(title); deps.updateCurrentMediaTitle(title);
deps.resetAnilistMediaGuessState(); deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate(title); deps.notifyImmersionTitleUpdate(title);
deps.syncImmersionMediaState(); deps.syncImmersionMediaState();
deps.refreshDiscordPresence();
}; };
} }
export function createHandleMpvTimePosChangeHandler(deps: { export function createHandleMpvTimePosChangeHandler(deps: {
recordPlaybackPosition: (time: number) => void; recordPlaybackPosition: (time: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
refreshDiscordPresence: () => void;
}) { }) {
return ({ time }: { time: number }): void => { return ({ time }: { time: number }): void => {
deps.recordPlaybackPosition(time); deps.recordPlaybackPosition(time);
deps.reportJellyfinRemoteProgress(false); deps.reportJellyfinRemoteProgress(false);
deps.refreshDiscordPresence();
}; };
} }
export function createHandleMpvPauseChangeHandler(deps: { export function createHandleMpvPauseChangeHandler(deps: {
recordPauseState: (paused: boolean) => void; recordPauseState: (paused: boolean) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
refreshDiscordPresence: () => void;
}) { }) {
return ({ paused }: { paused: boolean }): void => { return ({ paused }: { paused: boolean }): void => {
deps.recordPauseState(paused); deps.recordPauseState(paused);
deps.reportJellyfinRemoteProgress(true); deps.reportJellyfinRemoteProgress(true);
deps.refreshDiscordPresence();
}; };
} }

View File

@@ -28,6 +28,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
setCurrentSubText: (text) => calls.push(`set-sub:${text}`), setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`), broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`), onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
refreshDiscordPresence: () => calls.push('presence-refresh'),
setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`), setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`),
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`), broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
@@ -73,4 +74,5 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
assert.ok(calls.includes('notify-title:Episode 1')); assert.ok(calls.includes('notify-title:Episode 1'));
assert.ok(calls.includes('progress:normal')); assert.ok(calls.includes('progress:normal'));
assert.ok(calls.includes('progress:force')); assert.ok(calls.includes('progress:force'));
assert.ok(calls.includes('presence-refresh'));
}); });

View File

@@ -35,6 +35,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
setCurrentSubText: (text: string) => void; setCurrentSubText: (text: string) => void;
broadcastSubtitle: (payload: { text: string; tokens: null }) => void; broadcastSubtitle: (payload: { text: string; tokens: null }) => void;
onSubtitleChange: (text: string) => void; onSubtitleChange: (text: string) => void;
refreshDiscordPresence: () => void;
setCurrentSubAssText: (text: string) => void; setCurrentSubAssText: (text: string) => void;
broadcastSubtitleAss: (text: string) => void; broadcastSubtitleAss: (text: string) => void;
@@ -61,6 +62,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
return (mpvClient: MpvEventClient): void => { return (mpvClient: MpvEventClient): void => {
const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({ const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(), hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(), isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
@@ -80,6 +82,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
setCurrentSubText: (text) => deps.setCurrentSubText(text), setCurrentSubText: (text) => deps.setCurrentSubText(text),
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload), broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
onSubtitleChange: (text) => deps.onSubtitleChange(text), onSubtitleChange: (text) => deps.onSubtitleChange(text),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
}); });
const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({ const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({
setCurrentSubAssText: (text) => deps.setCurrentSubAssText(text), setCurrentSubAssText: (text) => deps.setCurrentSubAssText(text),
@@ -96,22 +99,26 @@ export function createBindMpvMainEventHandlersHandler(deps: {
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey), maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey), ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey),
syncImmersionMediaState: () => deps.syncImmersionMediaState(), syncImmersionMediaState: () => deps.syncImmersionMediaState(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
}); });
const handleMpvMediaTitleChange = createHandleMpvMediaTitleChangeHandler({ const handleMpvMediaTitleChange = createHandleMpvMediaTitleChangeHandler({
updateCurrentMediaTitle: (title) => deps.updateCurrentMediaTitle(title), updateCurrentMediaTitle: (title) => deps.updateCurrentMediaTitle(title),
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(), resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
notifyImmersionTitleUpdate: (title) => deps.notifyImmersionTitleUpdate(title), notifyImmersionTitleUpdate: (title) => deps.notifyImmersionTitleUpdate(title),
syncImmersionMediaState: () => deps.syncImmersionMediaState(), syncImmersionMediaState: () => deps.syncImmersionMediaState(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
}); });
const handleMpvTimePosChange = createHandleMpvTimePosChangeHandler({ const handleMpvTimePosChange = createHandleMpvTimePosChangeHandler({
recordPlaybackPosition: (time) => deps.recordPlaybackPosition(time), recordPlaybackPosition: (time) => deps.recordPlaybackPosition(time),
reportJellyfinRemoteProgress: (forceImmediate) => reportJellyfinRemoteProgress: (forceImmediate) =>
deps.reportJellyfinRemoteProgress(forceImmediate), deps.reportJellyfinRemoteProgress(forceImmediate),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
}); });
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({ const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
recordPauseState: (paused) => deps.recordPauseState(paused), recordPauseState: (paused) => deps.recordPauseState(paused),
reportJellyfinRemoteProgress: (forceImmediate) => reportJellyfinRemoteProgress: (forceImmediate) =>
deps.reportJellyfinRemoteProgress(forceImmediate), deps.reportJellyfinRemoteProgress(forceImmediate),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
}); });
const handleMpvSubtitleMetricsChange = createHandleMpvSubtitleMetricsChangeHandler({ const handleMpvSubtitleMetricsChange = createHandleMpvSubtitleMetricsChangeHandler({
updateSubtitleRenderMetrics: (patch) => deps.updateSubtitleRenderMetrics(patch), updateSubtitleRenderMetrics: (patch) => deps.updateSubtitleRenderMetrics(patch),

View File

@@ -19,6 +19,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
}, },
currentSubText: '', currentSubText: '',
currentSubAssText: '', currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false, previousSecondarySubVisibility: false,
}; };
@@ -35,7 +36,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
calls.push('anilist-post-watch'); calls.push('anilist-post-watch');
}, },
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`), logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
broadcastToOverlayWindows: (channel, payload) => calls.push(`broadcast:${channel}:${String(payload)}`), broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`), onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`), updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
getCurrentAnilistMediaKey: () => 'media-key', getCurrentAnilistMediaKey: () => 'media-key',
@@ -47,6 +49,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
resetAnilistMediaGuessState: () => calls.push('reset-guess'), resetAnilistMediaGuessState: () => calls.push('reset-guess'),
reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`), reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`),
updateSubtitleRenderMetrics: () => calls.push('metrics'), updateSubtitleRenderMetrics: () => calls.push('metrics'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
})(); })();
assert.equal(deps.hasInitialJellyfinPlayArg(), true); assert.equal(deps.hasInitialJellyfinPlayArg(), true);
@@ -64,6 +67,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.setCurrentSubText('sub'); deps.setCurrentSubText('sub');
deps.broadcastSubtitle({ text: 'sub', tokens: null }); deps.broadcastSubtitle({ text: 'sub', tokens: null });
deps.onSubtitleChange('sub'); deps.onSubtitleChange('sub');
deps.refreshDiscordPresence();
deps.setCurrentSubAssText('ass'); deps.setCurrentSubAssText('ass');
deps.broadcastSubtitleAss('ass'); deps.broadcastSubtitleAss('ass');
deps.broadcastSecondarySubtitle('sec'); deps.broadcastSecondarySubtitle('sec');
@@ -84,9 +88,11 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.equal(appState.currentSubText, 'sub'); assert.equal(appState.currentSubText, 'sub');
assert.equal(appState.currentSubAssText, 'ass'); assert.equal(appState.currentSubAssText, 'ass');
assert.equal(appState.playbackPaused, true);
assert.equal(appState.previousSecondarySubVisibility, true); assert.equal(appState.previousSecondarySubVisibility, true);
assert.ok(calls.includes('remote-stopped')); assert.ok(calls.includes('remote-stopped'));
assert.ok(calls.includes('anilist-post-watch')); assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('sync-immersion')); assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('metrics')); assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('presence-refresh'));
}); });

View File

@@ -14,6 +14,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
} | null; } | null;
currentSubText: string; currentSubText: string;
currentSubAssText: string; currentSubAssText: string;
playbackPaused: boolean | null;
previousSecondarySubVisibility: boolean | null; previousSecondarySubVisibility: boolean | null;
}; };
getQuitOnDisconnectArmed: () => boolean; getQuitOnDisconnectArmed: () => boolean;
@@ -34,6 +35,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
resetAnilistMediaGuessState: () => void; resetAnilistMediaGuessState: () => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void; updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
refreshDiscordPresence: () => void;
}) { }) {
return () => ({ return () => ({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
@@ -57,15 +59,18 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
broadcastSubtitle: (payload: { text: string; tokens: null }) => broadcastSubtitle: (payload: { text: string; tokens: null }) =>
deps.broadcastToOverlayWindows('subtitle:set', payload), deps.broadcastToOverlayWindows('subtitle:set', payload),
onSubtitleChange: (text: string) => deps.onSubtitleChange(text), onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
setCurrentSubAssText: (text: string) => { setCurrentSubAssText: (text: string) => {
deps.appState.currentSubAssText = text; deps.appState.currentSubAssText = text;
}, },
broadcastSubtitleAss: (text: string) => deps.broadcastToOverlayWindows('subtitle-ass:set', text), broadcastSubtitleAss: (text: string) =>
deps.broadcastToOverlayWindows('subtitle-ass:set', text),
broadcastSecondarySubtitle: (text: string) => broadcastSecondarySubtitle: (text: string) =>
deps.broadcastToOverlayWindows('secondary-subtitle:set', text), deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path), updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(), getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey: string | null) => deps.resetAnilistMediaTracking(mediaKey), resetAnilistMediaTracking: (mediaKey: string | null) =>
deps.resetAnilistMediaTracking(mediaKey),
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey), maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey), ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
syncImmersionMediaState: () => deps.syncImmersionMediaState(), syncImmersionMediaState: () => deps.syncImmersionMediaState(),
@@ -73,10 +78,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(), resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
notifyImmersionTitleUpdate: (title: string) => notifyImmersionTitleUpdate: (title: string) =>
deps.appState.immersionTracker?.handleMediaTitleUpdate?.(title), deps.appState.immersionTracker?.handleMediaTitleUpdate?.(title),
recordPlaybackPosition: (time: number) => deps.appState.immersionTracker?.recordPlaybackPosition?.(time), recordPlaybackPosition: (time: number) =>
deps.appState.immersionTracker?.recordPlaybackPosition?.(time),
reportJellyfinRemoteProgress: (forceImmediate: boolean) => reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate), deps.reportJellyfinRemoteProgress(forceImmediate),
recordPauseState: (paused: boolean) => deps.appState.immersionTracker?.recordPauseState?.(paused), recordPauseState: (paused: boolean) => {
deps.appState.playbackPaused = paused;
deps.appState.immersionTracker?.recordPauseState?.(paused);
},
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
deps.updateSubtitleRenderMetrics(patch), deps.updateSubtitleRenderMetrics(patch),
setPreviousSecondarySubVisibility: (visible: boolean) => { setPreviousSecondarySubVisibility: (visible: boolean) => {

View File

@@ -16,6 +16,7 @@ import type { AnkiIntegration } from '../anki-integration';
import type { ImmersionTrackerService } from '../core/services/immersion-tracker-service'; import type { ImmersionTrackerService } from '../core/services/immersion-tracker-service';
import type { MpvIpcClient } from '../core/services/mpv'; import type { MpvIpcClient } from '../core/services/mpv';
import type { JellyfinRemoteSessionService } from '../core/services/jellyfin-remote'; import type { JellyfinRemoteSessionService } from '../core/services/jellyfin-remote';
import type { createDiscordPresenceService } from '../core/services/discord-presence';
import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from '../core/services/mpv-render-metrics'; import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from '../core/services/mpv-render-metrics';
import type { RuntimeOptionsManager } from '../runtime-options'; import type { RuntimeOptionsManager } from '../runtime-options';
import type { MecabTokenizer } from '../mecab-tokenizer'; import type { MecabTokenizer } from '../mecab-tokenizer';
@@ -150,6 +151,7 @@ export interface AppState {
yomitanParserInitPromise: Promise<boolean> | null; yomitanParserInitPromise: Promise<boolean> | null;
mpvClient: MpvIpcClient | null; mpvClient: MpvIpcClient | null;
jellyfinRemoteSession: JellyfinRemoteSessionService | null; jellyfinRemoteSession: JellyfinRemoteSessionService | null;
discordPresenceService: ReturnType<typeof createDiscordPresenceService> | null;
reconnectTimer: ReturnType<typeof setTimeout> | null; reconnectTimer: ReturnType<typeof setTimeout> | null;
currentSubText: string; currentSubText: string;
currentSubAssText: string; currentSubAssText: string;
@@ -160,6 +162,7 @@ export interface AppState {
subtitlePosition: SubtitlePosition | null; subtitlePosition: SubtitlePosition | null;
currentMediaPath: string | null; currentMediaPath: string | null;
currentMediaTitle: string | null; currentMediaTitle: string | null;
playbackPaused: boolean | null;
pendingSubtitlePosition: SubtitlePosition | null; pendingSubtitlePosition: SubtitlePosition | null;
anilistClientSecretState: AnilistSecretResolutionState; anilistClientSecretState: AnilistSecretResolutionState;
mecabTokenizer: MecabTokenizer | null; mecabTokenizer: MecabTokenizer | null;
@@ -222,6 +225,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
yomitanParserInitPromise: null, yomitanParserInitPromise: null,
mpvClient: null, mpvClient: null,
jellyfinRemoteSession: null, jellyfinRemoteSession: null,
discordPresenceService: null,
reconnectTimer: null, reconnectTimer: null,
currentSubText: '', currentSubText: '',
currentSubAssText: '', currentSubAssText: '',
@@ -232,6 +236,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
subtitlePosition: null, subtitlePosition: null,
currentMediaPath: null, currentMediaPath: null,
currentMediaTitle: null, currentMediaTitle: null,
playbackPaused: null,
pendingSubtitlePosition: null, pendingSubtitlePosition: null,
anilistClientSecretState: createInitialAnilistSecretResolutionState(), anilistClientSecretState: createInitialAnilistSecretResolutionState(),
mecabTokenizer: null, mecabTokenizer: null,

View File

@@ -358,6 +358,21 @@ export interface JellyfinConfig {
transcodeVideoCodec?: string; transcodeVideoCodec?: string;
} }
export interface DiscordPresenceConfig {
enabled?: boolean;
clientId?: string;
detailsTemplate?: string;
stateTemplate?: string;
largeImageKey?: string;
largeImageText?: string;
smallImageKey?: string;
smallImageText?: string;
buttonLabel?: string;
buttonUrl?: string;
updateIntervalMs?: number;
debounceMs?: number;
}
export interface InvisibleOverlayConfig { export interface InvisibleOverlayConfig {
startupVisibility?: 'platform-default' | 'visible' | 'hidden'; startupVisibility?: 'platform-default' | 'visible' | 'hidden';
} }
@@ -403,6 +418,7 @@ export interface Config {
jimaku?: JimakuConfig; jimaku?: JimakuConfig;
anilist?: AnilistConfig; anilist?: AnilistConfig;
jellyfin?: JellyfinConfig; jellyfin?: JellyfinConfig;
discordPresence?: DiscordPresenceConfig;
invisibleOverlay?: InvisibleOverlayConfig; invisibleOverlay?: InvisibleOverlayConfig;
youtubeSubgen?: YoutubeSubgenConfig; youtubeSubgen?: YoutubeSubgenConfig;
immersionTracking?: ImmersionTrackingConfig; immersionTracking?: ImmersionTrackingConfig;
@@ -528,6 +544,20 @@ export interface ResolvedConfig {
directPlayContainers: string[]; directPlayContainers: string[];
transcodeVideoCodec: string; transcodeVideoCodec: string;
}; };
discordPresence: {
enabled: boolean;
clientId: string;
detailsTemplate: string;
stateTemplate: string;
largeImageKey: string;
largeImageText: string;
smallImageKey: string;
smallImageText: string;
buttonLabel: string;
buttonUrl: string;
updateIntervalMs: number;
debounceMs: number;
};
invisibleOverlay: Required<InvisibleOverlayConfig>; invisibleOverlay: Required<InvisibleOverlayConfig>;
youtubeSubgen: YoutubeSubgenConfig & { youtubeSubgen: YoutubeSubgenConfig & {
mode: YoutubeSubgenMode; mode: YoutubeSubgenMode;