diff --git a/backlog/tasks/task-103 - Extract-Jellyfin-runtime-wiring-from-main.ts-composition-root.md b/backlog/tasks/task-103 - Extract-Jellyfin-runtime-wiring-from-main.ts-composition-root.md index 76205f0..0a48b22 100644 --- a/backlog/tasks/task-103 - Extract-Jellyfin-runtime-wiring-from-main.ts-composition-root.md +++ b/backlog/tasks/task-103 - Extract-Jellyfin-runtime-wiring-from-main.ts-composition-root.md @@ -1,10 +1,11 @@ --- id: TASK-103 title: Extract Jellyfin runtime wiring from main.ts composition root -status: To Do -assignee: [] +status: Done +assignee: + - codex-task103-jellyfin-main-composer-20260222T220441Z-m8p1 created_date: '2026-02-22 07:13' -updated_date: '2026-02-22 07:13' +updated_date: '2026-02-22 22:48' labels: - refactor - maintainability @@ -36,16 +37,52 @@ Goal: finish extraction of Jellyfin-specific dependency construction and command ## Acceptance Criteria -- [ ] #1 `src/main.ts` no longer contains the full Jellyfin deps-building block. -- [ ] #2 New Jellyfin composer modules have focused tests covering handler wiring. -- [ ] #3 `bun run check:main-fanin` stays green after extraction. -- [ ] #4 `bun run build` and `bun run test:core:src` pass. +- [x] #1 `src/main.ts` no longer contains the full Jellyfin deps-building block. +- [x] #2 New Jellyfin composer modules have focused tests covering handler wiring. +- [x] #3 `bun run check:main-fanin` stays green after extraction. +- [x] #4 `bun run build` and `bun run test:core:src` pass. +## Implementation Plan + + +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). + + +## Implementation Notes + + +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`). + + +## Final Summary + + +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). + + ## Definition of Done -- [ ] #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. -- [ ] #3 No behavior regressions in Jellyfin command/setup flows. +- [x] #1 `src/main.ts` LOC reduced materially from current baseline. +- [x] #2 Jellyfin runtime wiring is centralized in named composer module(s) with clear ownership docs. +- [x] #3 No behavior regressions in Jellyfin command/setup flows. - diff --git a/backlog/tasks/task-109 - Add-Discord-Rich-Presence-integration-with-polished-activity-card.md b/backlog/tasks/task-109 - Add-Discord-Rich-Presence-integration-with-polished-activity-card.md new file mode 100644 index 0000000..736a532 --- /dev/null +++ b/backlog/tasks/task-109 - Add-Discord-Rich-Presence-integration-with-polished-activity-card.md @@ -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 + + +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. + + +## Action Steps + + +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. + + +## Acceptance Criteria + +- [ ] #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. + + +## Implementation Notes + + +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). + + +## Definition of Done + +- [ ] #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. + diff --git a/bun.lock b/bun.lock index d5d1bb9..c69309c 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@catppuccin/vitepress": "^0.1.2", "axios": "^1.13.5", "commander": "^14.0.3", + "discord-rpc": "^4.0.1", "jsonc-parser": "^3.3.1", "mermaid": "^11.12.3", "ws": "^8.19.0", @@ -441,6 +442,8 @@ "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=="], "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=="], + "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-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=="], + "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=="], "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-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=="], "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=="], + "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=="], "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=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "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=="], @@ -1181,6 +1194,10 @@ "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=="], "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=="], + "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-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=="], diff --git a/config.example.jsonc b/config.example.jsonc index 89cfb3d..9a948c2 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -46,6 +46,125 @@ "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error }, // 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 // Automatic Anki updates and media generation options. @@ -119,124 +238,6 @@ } // Is kiku setting. }, // 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 API configuration and defaults. @@ -273,14 +274,13 @@ // ========================================== // Jellyfin // 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": { "enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "username": "", // Default Jellyfin username used during CLI login. - "accessToken": "", // Access token setting. - "userId": "", // User id setting. "deviceId": "subminer", // Device id setting. "clientName": "SubMiner", // Client name setting. "clientVersion": "0.1.0", // Client version setting. @@ -304,6 +304,26 @@ "transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable. }, // 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 // Enable/disable immersion tracking. diff --git a/docs/architecture.md b/docs/architecture.md index ba2b82c..52fc6db 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 - `subsync-runtime.ts` — subsync command orchestration - `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 Composer modules share contract conventions via `src/main/runtime/composers/contracts.ts`: diff --git a/docs/configuration.md b/docs/configuration.md index c677df8..2c6fafc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -76,6 +76,7 @@ The configuration file includes several main sections: - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults - [**AniList**](#anilist) - Optional post-watch progress updates - [**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 - [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles - [**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, "serverUrl": "http://127.0.0.1:8096", "username": "", - "accessToken": "", - "userId": "", "remoteControlEnabled": true, "remoteControlAutoConnect": true, "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`. +### 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 . +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 Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv: diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc index f4a38da..9a948c2 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -46,6 +46,125 @@ "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error }, // 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 // Automatic Anki updates and media generation options. @@ -119,124 +238,6 @@ } // Is kiku setting. }, // 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 API configuration and defaults. @@ -273,10 +274,8 @@ // ========================================== // Jellyfin // Optional Jellyfin integration for auth, browsing, and playback launch. - // Auth session (access token + user id) is stored in local encrypted storage after login/setup. - // Optional environment overrides: - // SUBMINER_JELLYFIN_ACCESS_TOKEN - // SUBMINER_JELLYFIN_USER_ID + // Access token is stored in local encrypted token storage after login/setup. + // jellyfin.accessToken remains an optional explicit override in config. // ========================================== "jellyfin": { "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. }, // 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 // Enable/disable immersion tracking. diff --git a/docs/subagents/INDEX.md b/docs/subagents/INDEX.md index eb2fcb3..a93b161 100644 --- a/docs/subagents/INDEX.md +++ b/docs/subagents/INDEX.md @@ -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` | | `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-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` | diff --git a/docs/subagents/agents/codex-discord-presence-task-20260222T194048Z-d7k2.md b/docs/subagents/agents/codex-discord-presence-task-20260222T194048Z-d7k2.md new file mode 100644 index 0000000..7aa6d96 --- /dev/null +++ b/docs/subagents/agents/codex-discord-presence-task-20260222T194048Z-d7k2.md @@ -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). diff --git a/docs/subagents/agents/codex-field-grouping-autoupdate-race-20260222T193915Z-m8p4.md b/docs/subagents/agents/codex-field-grouping-autoupdate-race-20260222T193915Z-m8p4.md new file mode 100644 index 0000000..4993ea2 --- /dev/null +++ b/docs/subagents/agents/codex-field-grouping-autoupdate-race-20260222T193915Z-m8p4.md @@ -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. diff --git a/docs/subagents/agents/codex-kiku-modal-overlay-20260222T220502Z-r4m1.md b/docs/subagents/agents/codex-kiku-modal-overlay-20260222T220502Z-r4m1.md new file mode 100644 index 0000000..2a210ca --- /dev/null +++ b/docs/subagents/agents/codex-kiku-modal-overlay-20260222T220502Z-r4m1.md @@ -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). diff --git a/docs/subagents/agents/codex-task103-jellyfin-main-composer-20260222T220441Z-m8p1.md b/docs/subagents/agents/codex-task103-jellyfin-main-composer-20260222T220441Z-m8p1.md new file mode 100644 index 0000000..33cb569 --- /dev/null +++ b/docs/subagents/agents/codex-task103-jellyfin-main-composer-20260222T220441Z-m8p1.md @@ -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`. diff --git a/docs/subagents/agents/codex-task104-launcher-config-20260222T194708Z-z9x1.md b/docs/subagents/agents/codex-task104-launcher-config-20260222T194708Z-z9x1.md new file mode 100644 index 0000000..2060c4a --- /dev/null +++ b/docs/subagents/agents/codex-task104-launcher-config-20260222T194708Z-z9x1.md @@ -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). diff --git a/docs/subagents/agents/codex-task105-sliceb-20260222T195423Z-w8n3.md b/docs/subagents/agents/codex-task105-sliceb-20260222T195423Z-w8n3.md new file mode 100644 index 0000000..b83a15c --- /dev/null +++ b/docs/subagents/agents/codex-task105-sliceb-20260222T195423Z-w8n3.md @@ -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. diff --git a/docs/subagents/agents/codex-task109-discord-presence-20260222T220537Z-lkfv.md b/docs/subagents/agents/codex-task109-discord-presence-20260222T220537Z-lkfv.md new file mode 100644 index 0000000..74de1c3 --- /dev/null +++ b/docs/subagents/agents/codex-task109-discord-presence-20260222T220537Z-lkfv.md @@ -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). diff --git a/docs/subagents/agents/codex-ts-build-errors-20260222T215411Z-h3k7.md b/docs/subagents/agents/codex-ts-build-errors-20260222T215411Z-h3k7.md new file mode 100644 index 0000000..9ff216c --- /dev/null +++ b/docs/subagents/agents/codex-ts-build-errors-20260222T215411Z-h3k7.md @@ -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. diff --git a/docs/subagents/agents/opencode-task103-jellyfin-main-composer-20260222T221152Z-n3p7.md b/docs/subagents/agents/opencode-task103-jellyfin-main-composer-20260222T221152Z-n3p7.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/subagents/collaboration.md b/docs/subagents/collaboration.md index 19c30ce..4202f00 100644 --- a/docs/subagents/collaboration.md +++ b/docs/subagents/collaboration.md @@ -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: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. diff --git a/package.json b/package.json index 15ffa1a..2a72db9 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,8 @@ "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: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: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: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/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:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", @@ -62,6 +62,7 @@ "@catppuccin/vitepress": "^0.1.2", "axios": "^1.13.5", "commander": "^14.0.3", + "discord-rpc": "^4.0.1", "jsonc-parser": "^3.3.1", "mermaid": "^11.12.3", "ws": "^8.19.0" diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts index 85ab0e4..c953ef5 100644 --- a/src/anki-integration/note-update-workflow.test.ts +++ b/src/anki-integration/note-update-workflow.test.ts @@ -1,18 +1,17 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { NoteUpdateWorkflow } from './note-update-workflow'; - -type NoteInfo = { - noteId: number; - fields: Record; -}; +import { + NoteUpdateWorkflow, + type NoteUpdateWorkflowDeps, + type NoteUpdateWorkflowNoteInfo, +} from './note-update-workflow'; function createWorkflowHarness() { const updates: Array<{ noteId: number; fields: Record }> = []; const notifications: Array<{ noteId: number; label: string | number }> = []; const warnings: string[] = []; - const deps = { + const deps: NoteUpdateWorkflowDeps = { client: { notesInfo: async (_noteIds: number[]) => [ @@ -23,7 +22,7 @@ function createWorkflowHarness() { Sentence: { value: '' }, }, }, - ] satisfies NoteInfo[], + ] satisfies NoteUpdateWorkflowNoteInfo[], updateNoteFields: async (noteId: number, fields: Record) => { updates.push({ noteId, fields }); }, @@ -43,7 +42,7 @@ function createWorkflowHarness() { kikuEnabled: false, kikuFieldGrouping: 'disabled' as const, }), - appendKnownWordsFromNoteInfo: (_noteInfo: NoteInfo) => undefined, + appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined, extractFields: (fields: Record) => { const out: Record = {}; for (const [key, value] of Object.entries(fields)) { @@ -51,17 +50,27 @@ function createWorkflowHarness() { } return out; }, - findDuplicateNote: async () => null, - handleFieldGroupingAuto: async () => undefined, - handleFieldGroupingManual: async () => false, - processSentence: (text: string) => text, - resolveConfiguredFieldName: (noteInfo: NoteInfo, preferred?: string) => { + findDuplicateNote: async (_expression, _excludeNoteId, _noteInfo) => null, + handleFieldGroupingAuto: async ( + _originalNoteId, + _newNoteId, + _newNoteInfo, + _expression, + ) => undefined, + handleFieldGroupingManual: async ( + _originalNoteId, + _newNoteId, + _newNoteInfo, + _expression, + ) => false, + processSentence: (text: string, _noteFields: Record) => text, + resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => { if (!preferred) return null; const names = Object.keys(noteInfo.fields); return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null; }, getResolvedSentenceAudioFieldName: () => null, - mergeFieldValue: (_existing: string, next: string) => next, + mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next, generateAudioFilename: () => 'audio_1.mp3', generateAudio: async () => null, generateImageFilename: () => 'image_1.jpg', @@ -74,7 +83,7 @@ function createWorkflowHarness() { showOsdNotification: (_text: string) => undefined, beginUpdateProgress: (_text: string) => undefined, endUpdateProgress: () => undefined, - logWarn: (message: string) => warnings.push(message), + logWarn: (message: string, ..._args: unknown[]) => warnings.push(message), logInfo: (_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.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); +}); diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts index 795a40b..2ffc761 100644 --- a/src/anki-integration/note-update-workflow.ts +++ b/src/anki-integration/note-update-workflow.ts @@ -98,34 +98,13 @@ export class NoteUpdateWorkflow { } const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); - if ( + const shouldRunFieldGrouping = !options?.skipKikuFieldGrouping && sentenceCardConfig.kikuEnabled && - sentenceCardConfig.kikuFieldGrouping !== 'disabled' - ) { - const duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo); - if (duplicateNoteId !== null) { - 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; - } - } - } + sentenceCardConfig.kikuFieldGrouping !== 'disabled'; + let duplicateNoteId: number | null = null; + if (shouldRunFieldGrouping) { + duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo); } const updatedFields: Record = {}; @@ -219,6 +198,37 @@ export class NoteUpdateWorkflow { this.deps.logInfo('Updated card fields for:', 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) { if ((error as Error).message.includes('note was not found')) { this.deps.logWarn('Card was deleted before update:', noteId); diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 4a5d156..e5f22c3 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -23,6 +23,8 @@ test('loads defaults when config is missing', () => { assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.autoAnnounce, false); 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.preserveLineBreaks, false); 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', () => { const dir = makeTempDir(); fs.writeFileSync( @@ -1062,6 +1093,7 @@ test('template generator includes known keys', () => { assert.match(output, /"ankiConnect":/); assert.match(output, /"logging":/); assert.match(output, /"websocket":/); + assert.match(output, /"discordPresence":/); assert.match(output, /"youtubeSubgen":/); assert.match(output, /"preserveLineBreaks": false/); assert.match(output, /"nPlusOne"\s*:\s*\{/); diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 03ac76c..f471b4e 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -31,7 +31,8 @@ const { bind_visible_overlay_to_mpv_sub_visibility, invisibleOverlay, } = 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 { immersionTracking } = IMMERSION_DEFAULT_CONFIG; @@ -51,6 +52,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { jimaku, anilist, jellyfin, + discordPresence, youtubeSubgen, invisibleOverlay, immersionTracking, diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 8199c03..f551675 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -2,7 +2,7 @@ import { ResolvedConfig } from '../../types'; export const INTEGRATIONS_DEFAULT_CONFIG: Pick< ResolvedConfig, - 'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'youtubeSubgen' + 'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'youtubeSubgen' > = { ankiConnect: { enabled: false, @@ -99,6 +99,20 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< directPlayContainers: ['mkv', 'mp4', 'webm', 'mov', 'flac', 'mp3', 'aac'], 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: { mode: 'automatic', whisperBin: '', diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 252cf3f..0af2cd5 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -188,6 +188,78 @@ export function buildIntegrationConfigOptionRegistry( defaultValue: defaultConfig.jellyfin.transcodeVideoCodec, 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', kind: 'enum', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 10f71e6..77f93ee 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -124,6 +124,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ ], 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[] = [ diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts index 3c2b1f3..9020cd6 100644 --- a/src/config/resolve/integrations.ts +++ b/src/config/resolve/integrations.ts @@ -1,5 +1,5 @@ import { ResolveContext } from './context'; -import { asBoolean, asString, isObject } from './shared'; +import { asBoolean, asNumber, asString, isObject } from './shared'; export function applyIntegrationConfig(context: ResolveContext): void { 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.', + ); + } + } } diff --git a/src/config/resolve/jellyfin.test.ts b/src/config/resolve/jellyfin.test.ts index ad905d2..9e6cd2a 100644 --- a/src/config/resolve/jellyfin.test.ts +++ b/src/config/resolve/jellyfin.test.ts @@ -17,7 +17,7 @@ test('jellyfin directPlayContainers are normalized', () => { test('jellyfin legacy auth keys are ignored by resolver', () => { 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); @@ -25,3 +25,61 @@ test('jellyfin legacy auth keys are ignored by resolver', () => { assert.equal('accessToken' in (context.resolved.jellyfin as Record), false); assert.equal('userId' in (context.resolved.jellyfin as Record), 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')); +}); diff --git a/src/core/services/discord-presence.test.ts b/src/core/services/discord-presence.test.ts new file mode 100644 index 0000000..4cd6a3f --- /dev/null +++ b/src/core/services/discord-presence.test.ts @@ -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 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; + }, + 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); +}); diff --git a/src/core/services/discord-presence.ts b/src/core/services/discord-presence.ts new file mode 100644 index 0000000..fdebcb7 --- /dev/null +++ b/src/core/services/discord-presence.ts @@ -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; + setActivity: (activity: DiscordActivityPayload) => Promise; + clearActivity: () => Promise; + destroy: () => void; +}; + +type TimeoutLike = ReturnType; + +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 { + 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 { + 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 { + 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 { + 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; + }, + }; +} diff --git a/src/core/services/field-grouping-overlay.test.ts b/src/core/services/field-grouping-overlay.test.ts index f954edc..47921f6 100644 --- a/src/core/services/field-grouping-overlay.test.ts +++ b/src/core/services/field-grouping-overlay.test.ts @@ -48,7 +48,7 @@ test('createFieldGroupingOverlayRuntime callback cancels when send fails', async setVisibleOverlayVisible: () => {}, setInvisibleOverlayVisible: () => {}, getResolver: () => resolver, - setResolver: (next) => { + setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => { resolver = next; }, 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.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]); +}); diff --git a/src/core/services/field-grouping-overlay.ts b/src/core/services/field-grouping-overlay.ts index 8505d4f..df5feb5 100644 --- a/src/core/services/field-grouping-overlay.ts +++ b/src/core/services/field-grouping-overlay.ts @@ -42,7 +42,12 @@ export function createFieldGroupingOverlayRuntime( runtimeOptions?: { restoreOnModalClose?: T }, ): boolean => { 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({ mainWindow: options.getMainWindow() as never, diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 5bcf1c7..6a159da 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -109,3 +109,4 @@ export { setOverlayDebugVisualizationEnabledRuntime, } from './overlay-manager'; export { createConfigHotReloadRuntime, classifyConfigHotReloadDiff } from './config-hot-reload'; +export { createDiscordPresenceService, buildDiscordPresenceActivity } from './discord-presence'; diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index 17068b5..a4754f6 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -29,12 +29,13 @@ test('on will quit cleanup handler runs all cleanup steps', () => { destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'), clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), + stopDiscordPresenceService: () => calls.push('stop-discord-presence'), }); cleanup(); - assert.equal(calls.length, 20); + assert.equal(calls.length, 21); 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')); }); diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index acbfec3..62f9d3e 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -19,6 +19,7 @@ export function createOnWillQuitCleanupHandler(deps: { destroyJellyfinSetupWindow: () => void; clearJellyfinSetupWindow: () => void; stopJellyfinRemoteSession: () => void; + stopDiscordPresenceService: () => void; }) { return (): void => { deps.destroyTray(); @@ -41,6 +42,7 @@ export function createOnWillQuitCleanupHandler(deps: { deps.destroyJellyfinSetupWindow(); deps.clearJellyfinSetupWindow(); deps.stopJellyfinRemoteSession(); + deps.stopDiscordPresenceService(); }; } diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index 78b5409..70286e2 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -47,6 +47,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), + stopDiscordPresenceService: () => calls.push('stop-discord-presence'), }); 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('clear-immersion-ref')); assert.ok(calls.includes('stop-jellyfin-remote')); + assert.ok(calls.includes('stop-discord-presence')); assert.equal(reconnectTimer, null); assert.equal(immersionTracker, null); }); @@ -92,6 +94,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => { getJellyfinSetupWindow: () => null, clearJellyfinSetupWindow: () => {}, stopJellyfinRemoteSession: () => {}, + stopDiscordPresenceService: () => {}, }); const cleanup = createOnWillQuitCleanupHandler(depsFactory()); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index af6b8b4..e897739 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -45,6 +45,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { clearJellyfinSetupWindow: () => void; stopJellyfinRemoteSession: () => void; + stopDiscordPresenceService: () => void; }) { return () => ({ destroyTray: () => deps.destroyTray(), @@ -96,5 +97,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { }, clearJellyfinSetupWindow: () => deps.clearJellyfinSetupWindow(), stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), + stopDiscordPresenceService: () => deps.stopDiscordPresenceService(), }); } diff --git a/src/main/runtime/composers/index.ts b/src/main/runtime/composers/index.ts index 004bdac..4506366 100644 --- a/src/main/runtime/composers/index.ts +++ b/src/main/runtime/composers/index.ts @@ -4,6 +4,7 @@ export * from './app-ready-composer'; export * from './contracts'; export * from './ipc-runtime-composer'; export * from './jellyfin-remote-composer'; +export * from './jellyfin-runtime-composer'; export * from './mpv-runtime-composer'; export * from './shortcuts-runtime-composer'; export * from './startup-lifecycle-composer'; diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts new file mode 100644 index 0000000..193c2f9 --- /dev/null +++ b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts @@ -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) => + `${defaultServer}${defaultUser}`, + 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'); +}); diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.ts b/src/main/runtime/composers/jellyfin-runtime-composer.ts new file mode 100644 index 0000000..1ba3111 --- /dev/null +++ b/src/main/runtime/composers/jellyfin-runtime-composer.ts @@ -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[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; + 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; + getJellyfinClientInfo: ReturnType; + 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; + startJellyfinRemoteSession: ReturnType; + stopJellyfinRemoteSession: ReturnType; + runJellyfinCommand: ReturnType; + openJellyfinSetupWindow: ReturnType; +}>; + +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[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[0]), + })(), + ); + + let startJellyfinRemoteSession!: ReturnType; + 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 }; diff --git a/src/main/runtime/composers/mpv-runtime-composer.test.ts b/src/main/runtime/composers/mpv-runtime-composer.test.ts index ea0a503..ca4654f 100644 --- a/src/main/runtime/composers/mpv-runtime-composer.test.ts +++ b/src/main/runtime/composers/mpv-runtime-composer.test.ts @@ -61,6 +61,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject subtitleTimingTracker: null, currentSubText: '', currentSubAssText: '', + playbackPaused: null, previousSecondarySubVisibility: null, }, getQuitOnDisconnectArmed: () => false, @@ -71,6 +72,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject logSubtitleTimingError: () => {}, broadcastToOverlayWindows: () => {}, onSubtitleChange: () => {}, + refreshDiscordPresence: () => {}, updateCurrentMediaPath: () => {}, getCurrentAnilistMediaKey: () => null, resetAnilistMediaTracking: () => {}, diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts index c57766e..341359d 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.test.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.test.ts @@ -35,6 +35,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler getJellyfinSetupWindow: () => null, clearJellyfinSetupWindow: () => {}, stopJellyfinRemoteSession: async () => {}, + stopDiscordPresenceService: () => {}, }, shouldRestoreWindowsOnActivateMainDeps: { isOverlayRuntimeInitialized: () => false, diff --git a/src/main/runtime/mpv-client-event-bindings.test.ts b/src/main/runtime/mpv-client-event-bindings.test.ts index ca4156c..16655ab 100644 --- a/src/main/runtime/mpv-client-event-bindings.test.ts +++ b/src/main/runtime/mpv-client-event-bindings.test.ts @@ -10,6 +10,7 @@ test('mpv connection handler reports stop and quits when disconnect guard passes const calls: string[] = []; const handler = createHandleMpvConnectionChangeHandler({ reportJellyfinRemoteStopped: () => calls.push('report-stop'), + refreshDiscordPresence: () => calls.push('presence-refresh'), hasInitialJellyfinPlayArg: () => true, isOverlayRuntimeInitialized: () => false, isQuitOnDisconnectArmed: () => true, @@ -22,7 +23,7 @@ test('mpv connection handler reports stop and quits when disconnect guard passes }); 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', () => { diff --git a/src/main/runtime/mpv-client-event-bindings.ts b/src/main/runtime/mpv-client-event-bindings.ts index 51cd495..98d016b 100644 --- a/src/main/runtime/mpv-client-event-bindings.ts +++ b/src/main/runtime/mpv-client-event-bindings.ts @@ -17,6 +17,7 @@ type MpvEventClient = { export function createHandleMpvConnectionChangeHandler(deps: { reportJellyfinRemoteStopped: () => void; + refreshDiscordPresence: () => void; hasInitialJellyfinPlayArg: () => boolean; isOverlayRuntimeInitialized: () => boolean; isQuitOnDisconnectArmed: () => boolean; @@ -25,6 +26,7 @@ export function createHandleMpvConnectionChangeHandler(deps: { quitApp: () => void; }) { return ({ connected }: { connected: boolean }): void => { + deps.refreshDiscordPresence(); if (connected) return; deps.reportJellyfinRemoteStopped(); if (!deps.hasInitialJellyfinPlayArg()) return; diff --git a/src/main/runtime/mpv-main-event-actions.test.ts b/src/main/runtime/mpv-main-event-actions.test.ts index beca837..61ee595 100644 --- a/src/main/runtime/mpv-main-event-actions.test.ts +++ b/src/main/runtime/mpv-main-event-actions.test.ts @@ -18,10 +18,11 @@ test('subtitle change handler updates state, broadcasts, and forwards', () => { setCurrentSubText: (text) => calls.push(`set:${text}`), broadcastSubtitle: (payload) => calls.push(`broadcast:${payload.text}`), onSubtitleChange: (text) => calls.push(`process:${text}`), + refreshDiscordPresence: () => calls.push('presence'), }); 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', () => { @@ -55,6 +56,7 @@ test('media path change handler reports stop for empty path and probes media key maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), syncImmersionMediaState: () => calls.push('sync'), + refreshDiscordPresence: () => calls.push('presence'), }); handler({ path: '' }); @@ -65,6 +67,7 @@ test('media path change handler reports stop for empty path and probes media key 'probe:show:1', 'guess:show:1', 'sync', + 'presence', ]); }); @@ -75,10 +78,17 @@ test('media title change handler clears guess state and syncs immersion', () => resetAnilistMediaGuessState: () => calls.push('reset-guess'), notifyImmersionTitleUpdate: (title) => calls.push(`notify:${title}`), syncImmersionMediaState: () => calls.push('sync'), + refreshDiscordPresence: () => calls.push('presence'), }); 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', () => { @@ -86,15 +96,24 @@ test('time-pos and pause handlers report progress with correct urgency', () => { const timeHandler = createHandleMpvTimePosChangeHandler({ recordPlaybackPosition: (time) => calls.push(`time:${time}`), reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`), + refreshDiscordPresence: () => calls.push('presence'), }); const pauseHandler = createHandleMpvPauseChangeHandler({ recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`), reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`), + refreshDiscordPresence: () => calls.push('presence'), }); timeHandler({ time: 12.5 }); 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', () => { diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index c33f527..56ec606 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -2,11 +2,13 @@ export function createHandleMpvSubtitleChangeHandler(deps: { setCurrentSubText: (text: string) => void; broadcastSubtitle: (payload: { text: string; tokens: null }) => void; onSubtitleChange: (text: string) => void; + refreshDiscordPresence: () => void; }) { return ({ text }: { text: string }): void => { deps.setCurrentSubText(text); deps.broadcastSubtitle({ text, tokens: null }); deps.onSubtitleChange(text); + deps.refreshDiscordPresence(); }; } @@ -36,6 +38,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: { maybeProbeAnilistDuration: (mediaKey: string) => void; ensureAnilistMediaGuess: (mediaKey: string) => void; syncImmersionMediaState: () => void; + refreshDiscordPresence: () => void; }) { return ({ path }: { path: string }): void => { deps.updateCurrentMediaPath(path); @@ -49,6 +52,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: { deps.ensureAnilistMediaGuess(mediaKey); } deps.syncImmersionMediaState(); + deps.refreshDiscordPresence(); }; } @@ -57,32 +61,38 @@ export function createHandleMpvMediaTitleChangeHandler(deps: { resetAnilistMediaGuessState: () => void; notifyImmersionTitleUpdate: (title: string) => void; syncImmersionMediaState: () => void; + refreshDiscordPresence: () => void; }) { return ({ title }: { title: string }): void => { deps.updateCurrentMediaTitle(title); deps.resetAnilistMediaGuessState(); deps.notifyImmersionTitleUpdate(title); deps.syncImmersionMediaState(); + deps.refreshDiscordPresence(); }; } export function createHandleMpvTimePosChangeHandler(deps: { recordPlaybackPosition: (time: number) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; + refreshDiscordPresence: () => void; }) { return ({ time }: { time: number }): void => { deps.recordPlaybackPosition(time); deps.reportJellyfinRemoteProgress(false); + deps.refreshDiscordPresence(); }; } export function createHandleMpvPauseChangeHandler(deps: { recordPauseState: (paused: boolean) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; + refreshDiscordPresence: () => void; }) { return ({ paused }: { paused: boolean }): void => { deps.recordPauseState(paused); deps.reportJellyfinRemoteProgress(true); + deps.refreshDiscordPresence(); }; } diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index f20ada0..76c5e00 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -28,6 +28,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { setCurrentSubText: (text) => calls.push(`set-sub:${text}`), broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`), onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`), + refreshDiscordPresence: () => calls.push('presence-refresh'), setCurrentSubAssText: (text) => calls.push(`set-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('progress:normal')); assert.ok(calls.includes('progress:force')); + assert.ok(calls.includes('presence-refresh')); }); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index 8c5239d..16be17e 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -35,6 +35,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { setCurrentSubText: (text: string) => void; broadcastSubtitle: (payload: { text: string; tokens: null }) => void; onSubtitleChange: (text: string) => void; + refreshDiscordPresence: () => void; setCurrentSubAssText: (text: string) => void; broadcastSubtitleAss: (text: string) => void; @@ -61,6 +62,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { return (mpvClient: MpvEventClient): void => { const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({ reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(), isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(), @@ -80,6 +82,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { setCurrentSubText: (text) => deps.setCurrentSubText(text), broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload), onSubtitleChange: (text) => deps.onSubtitleChange(text), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), }); const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({ setCurrentSubAssText: (text) => deps.setCurrentSubAssText(text), @@ -96,22 +99,26 @@ export function createBindMpvMainEventHandlersHandler(deps: { maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey), ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey), syncImmersionMediaState: () => deps.syncImmersionMediaState(), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), }); const handleMpvMediaTitleChange = createHandleMpvMediaTitleChangeHandler({ updateCurrentMediaTitle: (title) => deps.updateCurrentMediaTitle(title), resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(), notifyImmersionTitleUpdate: (title) => deps.notifyImmersionTitleUpdate(title), syncImmersionMediaState: () => deps.syncImmersionMediaState(), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), }); const handleMpvTimePosChange = createHandleMpvTimePosChangeHandler({ recordPlaybackPosition: (time) => deps.recordPlaybackPosition(time), reportJellyfinRemoteProgress: (forceImmediate) => deps.reportJellyfinRemoteProgress(forceImmediate), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), }); const handleMpvPauseChange = createHandleMpvPauseChangeHandler({ recordPauseState: (paused) => deps.recordPauseState(paused), reportJellyfinRemoteProgress: (forceImmediate) => deps.reportJellyfinRemoteProgress(forceImmediate), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), }); const handleMpvSubtitleMetricsChange = createHandleMpvSubtitleMetricsChangeHandler({ updateSubtitleRenderMetrics: (patch) => deps.updateSubtitleRenderMetrics(patch), diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index e6651e1..f19d782 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -19,6 +19,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as }, currentSubText: '', currentSubAssText: '', + playbackPaused: null, 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'); }, 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}`), updateCurrentMediaPath: (path) => calls.push(`path:${path}`), 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'), reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`), updateSubtitleRenderMetrics: () => calls.push('metrics'), + refreshDiscordPresence: () => calls.push('presence-refresh'), })(); 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.broadcastSubtitle({ text: 'sub', tokens: null }); deps.onSubtitleChange('sub'); + deps.refreshDiscordPresence(); deps.setCurrentSubAssText('ass'); deps.broadcastSubtitleAss('ass'); 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.currentSubAssText, 'ass'); + assert.equal(appState.playbackPaused, true); assert.equal(appState.previousSecondarySubVisibility, true); assert.ok(calls.includes('remote-stopped')); assert.ok(calls.includes('anilist-post-watch')); assert.ok(calls.includes('sync-immersion')); assert.ok(calls.includes('metrics')); + assert.ok(calls.includes('presence-refresh')); }); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 2468fce..d70e77e 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -14,6 +14,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { } | null; currentSubText: string; currentSubAssText: string; + playbackPaused: boolean | null; previousSecondarySubVisibility: boolean | null; }; getQuitOnDisconnectArmed: () => boolean; @@ -34,6 +35,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { resetAnilistMediaGuessState: () => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; updateSubtitleRenderMetrics: (patch: Record) => void; + refreshDiscordPresence: () => void; }) { return () => ({ reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), @@ -57,15 +59,18 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { broadcastSubtitle: (payload: { text: string; tokens: null }) => deps.broadcastToOverlayWindows('subtitle:set', payload), onSubtitleChange: (text: string) => deps.onSubtitleChange(text), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), setCurrentSubAssText: (text: string) => { 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) => deps.broadcastToOverlayWindows('secondary-subtitle:set', text), updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path), getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(), - resetAnilistMediaTracking: (mediaKey: string | null) => deps.resetAnilistMediaTracking(mediaKey), + resetAnilistMediaTracking: (mediaKey: string | null) => + deps.resetAnilistMediaTracking(mediaKey), maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey), ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey), syncImmersionMediaState: () => deps.syncImmersionMediaState(), @@ -73,10 +78,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(), notifyImmersionTitleUpdate: (title: string) => deps.appState.immersionTracker?.handleMediaTitleUpdate?.(title), - recordPlaybackPosition: (time: number) => deps.appState.immersionTracker?.recordPlaybackPosition?.(time), + recordPlaybackPosition: (time: number) => + deps.appState.immersionTracker?.recordPlaybackPosition?.(time), reportJellyfinRemoteProgress: (forceImmediate: boolean) => 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) => deps.updateSubtitleRenderMetrics(patch), setPreviousSecondarySubVisibility: (visible: boolean) => { diff --git a/src/main/state.ts b/src/main/state.ts index 7f15553..433b260 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -16,6 +16,7 @@ import type { AnkiIntegration } from '../anki-integration'; import type { ImmersionTrackerService } from '../core/services/immersion-tracker-service'; import type { MpvIpcClient } from '../core/services/mpv'; 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 type { RuntimeOptionsManager } from '../runtime-options'; import type { MecabTokenizer } from '../mecab-tokenizer'; @@ -150,6 +151,7 @@ export interface AppState { yomitanParserInitPromise: Promise | null; mpvClient: MpvIpcClient | null; jellyfinRemoteSession: JellyfinRemoteSessionService | null; + discordPresenceService: ReturnType | null; reconnectTimer: ReturnType | null; currentSubText: string; currentSubAssText: string; @@ -160,6 +162,7 @@ export interface AppState { subtitlePosition: SubtitlePosition | null; currentMediaPath: string | null; currentMediaTitle: string | null; + playbackPaused: boolean | null; pendingSubtitlePosition: SubtitlePosition | null; anilistClientSecretState: AnilistSecretResolutionState; mecabTokenizer: MecabTokenizer | null; @@ -222,6 +225,7 @@ export function createAppState(values: AppStateInitialValues): AppState { yomitanParserInitPromise: null, mpvClient: null, jellyfinRemoteSession: null, + discordPresenceService: null, reconnectTimer: null, currentSubText: '', currentSubAssText: '', @@ -232,6 +236,7 @@ export function createAppState(values: AppStateInitialValues): AppState { subtitlePosition: null, currentMediaPath: null, currentMediaTitle: null, + playbackPaused: null, pendingSubtitlePosition: null, anilistClientSecretState: createInitialAnilistSecretResolutionState(), mecabTokenizer: null, diff --git a/src/types.ts b/src/types.ts index 0e22883..0bd9de4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -358,6 +358,21 @@ export interface JellyfinConfig { 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 { startupVisibility?: 'platform-default' | 'visible' | 'hidden'; } @@ -403,6 +418,7 @@ export interface Config { jimaku?: JimakuConfig; anilist?: AnilistConfig; jellyfin?: JellyfinConfig; + discordPresence?: DiscordPresenceConfig; invisibleOverlay?: InvisibleOverlayConfig; youtubeSubgen?: YoutubeSubgenConfig; immersionTracking?: ImmersionTrackingConfig; @@ -528,6 +544,20 @@ export interface ResolvedConfig { directPlayContainers: 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; youtubeSubgen: YoutubeSubgenConfig & { mode: YoutubeSubgenMode;