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 index 736a532..7d79657 100644 --- 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 @@ -2,9 +2,10 @@ id: TASK-109 title: Add Discord Rich Presence integration with polished activity card status: In Progress -assignee: [] +assignee: + - opencode created_date: '2026-02-22 19:40' -updated_date: '2026-02-22 22:36' +updated_date: '2026-02-23 01:15' labels: - feature - discord @@ -40,6 +41,16 @@ Add optional Discord Rich Presence support so SubMiner can publish current activ - [ ] #5 Docs include setup steps (app/client id), config keys, and troubleshooting notes. +## Implementation Plan + + +1) Validate existing TASK-109 implementation in working tree against AC #1-#5 (config default-off, activity mapping/transitions, polished visuals, resilient error handling, docs coverage). +2) Run parallel audit passes (config/docs and runtime/lifecycle) and apply minimal fixes only for confirmed gaps. +3) Execute focused Discord/config tests, then full gates (`bun run build`, `bun run test:fast`, `bun run docs:build`) and record outcomes. +4) Capture manual Discord-session verification status/evidence. +5) Finalize TASK-109 in Backlog with checked AC/DoD, final summary, and status Done (no commit). + + ## Implementation Notes @@ -52,6 +63,12 @@ Wired MPV/runtime lifecycle hooks to refresh Discord presence on subtitle/media/ 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). + +User validation feedback: Discord activity resumed to Playing but with noticeable delay after pause/resume transitions. + +Adjusted default `discordPresence.updateIntervalMs` from `15000` to `3000` to reduce perceived status refresh lag while keeping debounce/duplicate suppression behavior unchanged. + +Updated docs/config examples (`docs/configuration.md`, `config.example.jsonc`, `docs/public/config.example.jsonc`) and config default assertions; reran focused tests: `bun test src/config/config.test.ts src/config/resolve/jellyfin.test.ts` and `bun test src/core/services/discord-presence.test.ts` (all pass). ## Definition of Done diff --git a/config.example.jsonc b/config.example.jsonc index 9a948c2..f57972e 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -5,7 +5,6 @@ * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. */ { - // ========================================== // Overlay Auto-Start // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. @@ -24,7 +23,7 @@ // Control whether browser opens automatically for texthooker. // ========================================== "texthooker": { - "openBrowser": true // Open browser setting. Values: true | false + "openBrowser": true, // Open browser setting. Values: true | false }, // Control whether browser opens automatically for texthooker. // ========================================== @@ -34,7 +33,7 @@ // ========================================== "websocket": { "enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false - "port": 6677 // Built-in subtitle websocket server port. + "port": 6677, // Built-in subtitle websocket server port. }, // Built-in WebSocket server broadcasts subtitle text to connected clients. // ========================================== @@ -43,7 +42,7 @@ // Set to debug for full runtime diagnostics. // ========================================== "logging": { - "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error + "level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error }, // Controls logging verbosity. // ========================================== @@ -65,7 +64,7 @@ "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. + "openJimaku": "Ctrl+Shift+J", // Open jimaku setting. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== @@ -75,7 +74,7 @@ // This edit-mode shortcut is fixed and is not currently configurable. // ========================================== "invisibleOverlay": { - "startupVisibility": "platform-default" // Startup visibility setting. + "startupVisibility": "platform-default", // Startup visibility setting. }, // Startup behavior for the invisible interactive subtitle mining layer. // ========================================== @@ -95,7 +94,7 @@ "secondarySub": { "secondarySubLanguages": [], // Secondary sub languages setting. "autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false - "defaultMode": "hover" // Default mode setting. + "defaultMode": "hover", // Default mode setting. }, // Dual subtitle track options. // ========================================== @@ -106,7 +105,7 @@ "defaultMode": "auto", // Subsync default mode. Values: auto | manual "alass_path": "", // Alass path setting. "ffsubsync_path": "", // Ffsubsync path setting. - "ffmpeg_path": "" // Ffmpeg path setting. + "ffmpeg_path": "", // Ffmpeg path setting. }, // Subsync engine and executable paths. // ========================================== @@ -114,7 +113,7 @@ // Initial vertical subtitle position from the bottom. // ========================================== "subtitlePosition": { - "yPercent": 10 // Y percent setting. + "yPercent": 10, // Y percent setting. }, // Initial vertical subtitle position from the bottom. // ========================================== @@ -139,7 +138,7 @@ "N2": "#f5a97f", // N2 setting. "N3": "#f9e2af", // N3 setting. "N4": "#a6e3a1", // N4 setting. - "N5": "#8aadf4" // N5 setting. + "N5": "#8aadf4", // N5 setting. }, // Jlpt colors setting. "frequencyDictionary": { "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false @@ -147,13 +146,7 @@ "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). + "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. @@ -161,8 +154,8 @@ "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. + "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. // ========================================== @@ -175,15 +168,13 @@ "enabled": false, // Enable AnkiConnect integration. Values: true | false "url": "http://127.0.0.1:8765", // Url setting. "pollingRate": 3000, // Polling interval in milliseconds. - "tags": [ - "SubMiner" - ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. + "tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. "fields": { "audio": "ExpressionAudio", // Audio setting. "image": "Picture", // Image setting. "sentence": "Sentence", // Sentence setting. "miscInfo": "MiscInfo", // Misc info setting. - "translation": "SelectionText" // Translation setting. + "translation": "SelectionText", // Translation setting. }, // Fields setting. "ai": { "enabled": false, // Enabled setting. Values: true | false @@ -192,7 +183,7 @@ "model": "openai/gpt-4o-mini", // Model setting. "baseUrl": "https://openrouter.ai/api", // Base url setting. "targetLanguage": "English", // Target language setting. - "systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting. + "systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting. }, // Ai setting. "media": { "generateAudio": true, // Generate audio setting. Values: true | false @@ -205,7 +196,7 @@ "animatedCrf": 35, // Animated crf setting. "audioPadding": 0.5, // Audio padding setting. "fallbackDuration": 3, // Fallback duration setting. - "maxMediaDuration": 30 // Max media duration setting. + "maxMediaDuration": 30, // Max media duration setting. }, // Media setting. "behavior": { "overwriteAudio": true, // Overwrite audio setting. Values: true | false @@ -213,7 +204,7 @@ "mediaInsertMode": "append", // Media insert mode setting. "highlightWord": true, // Highlight word setting. Values: true | false "notificationType": "osd", // Notification type setting. - "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false + "autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false }, // Behavior setting. "nPlusOne": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false @@ -222,20 +213,20 @@ "decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names. "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). "nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight. - "knownWord": "#a6da95" // Color used for legacy known-word highlights. + "knownWord": "#a6da95", // Color used for legacy known-word highlights. }, // N plus one setting. "metadata": { - "pattern": "[SubMiner] %f (%t)" // Pattern setting. + "pattern": "[SubMiner] %f (%t)", // Pattern setting. }, // Metadata setting. "isLapis": { "enabled": false, // Enabled setting. Values: true | false - "sentenceCardModel": "Japanese sentences" // Sentence card model setting. + "sentenceCardModel": "Japanese sentences", // Sentence card model setting. }, // Is lapis setting. "isKiku": { "enabled": false, // Enabled setting. Values: true | false "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled - "deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false - } // Is kiku setting. + "deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false + }, // Is kiku setting. }, // Automatic Anki updates and media generation options. // ========================================== @@ -245,7 +236,7 @@ "jimaku": { "apiBaseUrl": "https://jimaku.cc", // Api base url setting. "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none - "maxEntryResults": 10 // Maximum Jimaku search results returned. + "maxEntryResults": 10, // Maximum Jimaku search results returned. }, // Jimaku API configuration and defaults. // ========================================== @@ -256,10 +247,7 @@ "mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off "whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine. "whisperModel": "", // Path to whisper model used for fallback transcription. - "primarySubLanguages": [ - "ja", - "jpn" - ] // Comma-separated primary subtitle language priority used by the launcher. + "primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher. }, // Defaults for subminer YouTube subtitle extraction/transcription mode. // ========================================== @@ -268,7 +256,7 @@ // ========================================== "anilist": { "enabled": false, // Enable AniList post-watch progress updates. Values: true | false - "accessToken": "" // Optional explicit AniList access token override; leave empty to use locally stored token from setup. + "accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup. }, // Anilist API credentials and update behavior. // ========================================== @@ -292,36 +280,19 @@ "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false - "directPlayContainers": [ - "mkv", - "mp4", - "webm", - "mov", - "flac", - "mp3", - "aac" - ], // Container allowlist for direct play decisions. - "transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable. + "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions. + "transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable. }, // Optional Jellyfin integration for auth, browsing, and playback launch. // ========================================== // 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. + // Uses official SubMiner Discord app assets for polished card visuals. // ========================================== "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. + "updateIntervalMs": 3000, // 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. // ========================================== @@ -343,7 +314,7 @@ "telemetryDays": 30, // Telemetry retention window in days. "dailyRollupsDays": 365, // Daily rollup retention window in days. "monthlyRollupsDays": 1825, // Monthly rollup retention window in days. - "vacuumIntervalDays": 7 // Minimum days between VACUUM runs. - } // Retention setting. - } // Enable/disable immersion tracking. + "vacuumIntervalDays": 7, // Minimum days between VACUUM runs. + }, // Retention setting. + }, // Enable/disable immersion tracking. } diff --git a/docs/configuration.md b/docs/configuration.md index 2c6fafc..b947eaa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -542,46 +542,34 @@ Discord Rich Presence is optional and disabled by default. When enabled, SubMine { "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, + "updateIntervalMs": 3000, "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 | +| Option | Values | Description | +| ------------------ | --------------- | ---------------------------------------------------------- | +| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) | +| `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. +1. Set `discordPresence.enabled` to `true`. +2. Restart SubMiner. + +SubMiner uses a fixed official activity card style for all users: + +- Details: current media title while playing (fallback: `Mining and crafting (Anki cards)` when idle/disconnected) +- State: `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss` (fallback: `Idle`) +- Large image key/text: `subminer-logo` / `SubMiner` +- Small image key/text: `study` / `Sentence Mining` +- No activity button by default Troubleshooting: -- If the card does not appear, verify Discord desktop app is running and `clientId` is correct. +- If the card does not appear, verify Discord desktop app is running. - 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. diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc index 9a948c2..f57972e 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -5,7 +5,6 @@ * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. */ { - // ========================================== // Overlay Auto-Start // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. @@ -24,7 +23,7 @@ // Control whether browser opens automatically for texthooker. // ========================================== "texthooker": { - "openBrowser": true // Open browser setting. Values: true | false + "openBrowser": true, // Open browser setting. Values: true | false }, // Control whether browser opens automatically for texthooker. // ========================================== @@ -34,7 +33,7 @@ // ========================================== "websocket": { "enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false - "port": 6677 // Built-in subtitle websocket server port. + "port": 6677, // Built-in subtitle websocket server port. }, // Built-in WebSocket server broadcasts subtitle text to connected clients. // ========================================== @@ -43,7 +42,7 @@ // Set to debug for full runtime diagnostics. // ========================================== "logging": { - "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error + "level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error }, // Controls logging verbosity. // ========================================== @@ -65,7 +64,7 @@ "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. + "openJimaku": "Ctrl+Shift+J", // Open jimaku setting. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== @@ -75,7 +74,7 @@ // This edit-mode shortcut is fixed and is not currently configurable. // ========================================== "invisibleOverlay": { - "startupVisibility": "platform-default" // Startup visibility setting. + "startupVisibility": "platform-default", // Startup visibility setting. }, // Startup behavior for the invisible interactive subtitle mining layer. // ========================================== @@ -95,7 +94,7 @@ "secondarySub": { "secondarySubLanguages": [], // Secondary sub languages setting. "autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false - "defaultMode": "hover" // Default mode setting. + "defaultMode": "hover", // Default mode setting. }, // Dual subtitle track options. // ========================================== @@ -106,7 +105,7 @@ "defaultMode": "auto", // Subsync default mode. Values: auto | manual "alass_path": "", // Alass path setting. "ffsubsync_path": "", // Ffsubsync path setting. - "ffmpeg_path": "" // Ffmpeg path setting. + "ffmpeg_path": "", // Ffmpeg path setting. }, // Subsync engine and executable paths. // ========================================== @@ -114,7 +113,7 @@ // Initial vertical subtitle position from the bottom. // ========================================== "subtitlePosition": { - "yPercent": 10 // Y percent setting. + "yPercent": 10, // Y percent setting. }, // Initial vertical subtitle position from the bottom. // ========================================== @@ -139,7 +138,7 @@ "N2": "#f5a97f", // N2 setting. "N3": "#f9e2af", // N3 setting. "N4": "#a6e3a1", // N4 setting. - "N5": "#8aadf4" // N5 setting. + "N5": "#8aadf4", // N5 setting. }, // Jlpt colors setting. "frequencyDictionary": { "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false @@ -147,13 +146,7 @@ "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). + "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. @@ -161,8 +154,8 @@ "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. + "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. // ========================================== @@ -175,15 +168,13 @@ "enabled": false, // Enable AnkiConnect integration. Values: true | false "url": "http://127.0.0.1:8765", // Url setting. "pollingRate": 3000, // Polling interval in milliseconds. - "tags": [ - "SubMiner" - ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. + "tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. "fields": { "audio": "ExpressionAudio", // Audio setting. "image": "Picture", // Image setting. "sentence": "Sentence", // Sentence setting. "miscInfo": "MiscInfo", // Misc info setting. - "translation": "SelectionText" // Translation setting. + "translation": "SelectionText", // Translation setting. }, // Fields setting. "ai": { "enabled": false, // Enabled setting. Values: true | false @@ -192,7 +183,7 @@ "model": "openai/gpt-4o-mini", // Model setting. "baseUrl": "https://openrouter.ai/api", // Base url setting. "targetLanguage": "English", // Target language setting. - "systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting. + "systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting. }, // Ai setting. "media": { "generateAudio": true, // Generate audio setting. Values: true | false @@ -205,7 +196,7 @@ "animatedCrf": 35, // Animated crf setting. "audioPadding": 0.5, // Audio padding setting. "fallbackDuration": 3, // Fallback duration setting. - "maxMediaDuration": 30 // Max media duration setting. + "maxMediaDuration": 30, // Max media duration setting. }, // Media setting. "behavior": { "overwriteAudio": true, // Overwrite audio setting. Values: true | false @@ -213,7 +204,7 @@ "mediaInsertMode": "append", // Media insert mode setting. "highlightWord": true, // Highlight word setting. Values: true | false "notificationType": "osd", // Notification type setting. - "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false + "autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false }, // Behavior setting. "nPlusOne": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false @@ -222,20 +213,20 @@ "decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names. "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). "nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight. - "knownWord": "#a6da95" // Color used for legacy known-word highlights. + "knownWord": "#a6da95", // Color used for legacy known-word highlights. }, // N plus one setting. "metadata": { - "pattern": "[SubMiner] %f (%t)" // Pattern setting. + "pattern": "[SubMiner] %f (%t)", // Pattern setting. }, // Metadata setting. "isLapis": { "enabled": false, // Enabled setting. Values: true | false - "sentenceCardModel": "Japanese sentences" // Sentence card model setting. + "sentenceCardModel": "Japanese sentences", // Sentence card model setting. }, // Is lapis setting. "isKiku": { "enabled": false, // Enabled setting. Values: true | false "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled - "deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false - } // Is kiku setting. + "deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false + }, // Is kiku setting. }, // Automatic Anki updates and media generation options. // ========================================== @@ -245,7 +236,7 @@ "jimaku": { "apiBaseUrl": "https://jimaku.cc", // Api base url setting. "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none - "maxEntryResults": 10 // Maximum Jimaku search results returned. + "maxEntryResults": 10, // Maximum Jimaku search results returned. }, // Jimaku API configuration and defaults. // ========================================== @@ -256,10 +247,7 @@ "mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off "whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine. "whisperModel": "", // Path to whisper model used for fallback transcription. - "primarySubLanguages": [ - "ja", - "jpn" - ] // Comma-separated primary subtitle language priority used by the launcher. + "primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher. }, // Defaults for subminer YouTube subtitle extraction/transcription mode. // ========================================== @@ -268,7 +256,7 @@ // ========================================== "anilist": { "enabled": false, // Enable AniList post-watch progress updates. Values: true | false - "accessToken": "" // Optional explicit AniList access token override; leave empty to use locally stored token from setup. + "accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup. }, // Anilist API credentials and update behavior. // ========================================== @@ -292,36 +280,19 @@ "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false - "directPlayContainers": [ - "mkv", - "mp4", - "webm", - "mov", - "flac", - "mp3", - "aac" - ], // Container allowlist for direct play decisions. - "transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable. + "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions. + "transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable. }, // Optional Jellyfin integration for auth, browsing, and playback launch. // ========================================== // 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. + // Uses official SubMiner Discord app assets for polished card visuals. // ========================================== "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. + "updateIntervalMs": 3000, // 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. // ========================================== @@ -343,7 +314,7 @@ "telemetryDays": 30, // Telemetry retention window in days. "dailyRollupsDays": 365, // Daily rollup retention window in days. "monthlyRollupsDays": 1825, // Monthly rollup retention window in days. - "vacuumIntervalDays": 7 // Minimum days between VACUUM runs. - } // Retention setting. - } // Enable/disable immersion tracking. + "vacuumIntervalDays": 7, // Minimum days between VACUUM runs. + }, // Retention setting. + }, // Enable/disable immersion tracking. } diff --git a/docs/subagents/INDEX.md b/docs/subagents/INDEX.md index a93b161..ee79974 100644 --- a/docs/subagents/INDEX.md +++ b/docs/subagents/INDEX.md @@ -85,3 +85,4 @@ Read first. Keep concise. | `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` | +| `opencode-task109-discord-presence-20260223T011027Z-j9r4` | `opencode-task109-discord-presence` | `Finalize TASK-109 Discord Rich Presence with plan-first workflow and backlog closure.` | `in_progress` | `docs/subagents/agents/opencode-task109-discord-presence-20260223T011027Z-j9r4.md` | `2026-02-23T01:15:39Z` | diff --git a/docs/subagents/agents/opencode-task109-discord-presence-20260223T011027Z-j9r4.md b/docs/subagents/agents/opencode-task109-discord-presence-20260223T011027Z-j9r4.md new file mode 100644 index 0000000..76fc446 --- /dev/null +++ b/docs/subagents/agents/opencode-task109-discord-presence-20260223T011027Z-j9r4.md @@ -0,0 +1,41 @@ +# Agent: `opencode-task109-discord-presence-20260223T011027Z-j9r4` + +- alias: `opencode-task109-discord-presence` +- mission: `Finalize TASK-109 Discord Rich Presence integration end-to-end with plan-first workflow (no commit).` +- status: `in_progress` +- branch: `main` +- started_at: `2026-02-23T01:10:27Z` +- heartbeat_minutes: `5` + +## Current Work (newest first) + +- [2026-02-23T01:10:27Z] intent: load backlog context for TASK-109, write execution plan, run required validations, and finalize backlog ticket if all criteria pass. +- [2026-02-23T01:10:27Z] assumptions: TASK-109 code/docs edits already present in working tree from prior handoff and should be validated/closed instead of reimplemented. +- [2026-02-23T01:11:58Z] progress: created closure plan at `docs/plans/2026-02-23-task-109-discord-rich-presence-closure.md`; next record plan in Backlog task and execute validations. +- [2026-02-23T01:15:39Z] progress: addressed user-reported delayed Playing resume update by reducing `discordPresence.updateIntervalMs` default from 15000 to 3000 and updating docs/examples/tests; focused config + presence tests green. + +## Files Touched + +- `docs/subagents/INDEX.md` +- `docs/subagents/collaboration.md` +- `docs/subagents/agents/opencode-task109-discord-presence-20260223T011027Z-j9r4.md` +- `docs/plans/2026-02-23-task-109-discord-rich-presence-closure.md` +- `src/config/definitions/defaults-integrations.ts` +- `src/config/config.test.ts` +- `src/config/resolve/jellyfin.test.ts` +- `config.example.jsonc` +- `docs/public/config.example.jsonc` +- `docs/configuration.md` + +## Assumptions + +- Backlog MCP available and authoritative for task metadata. +- Existing TASK-109 diffs in working tree are in scope and should be preserved. + +## Open Questions / Blockers + +- None. + +## Next Step + +- Write plan artifact via writing-plans skill, then execute with executing-plans skill including parallel subagents where safe. diff --git a/docs/subagents/collaboration.md b/docs/subagents/collaboration.md index 4202f00..0b40ecf 100644 --- a/docs/subagents/collaboration.md +++ b/docs/subagents/collaboration.md @@ -143,3 +143,8 @@ Shared notes. Append-only. - [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. + +## 2026-02-23 + +- [2026-02-23T01:10:27Z] [opencode-task109-discord-presence-20260223T011027Z-j9r4|opencode-task109-discord-presence] starting TASK-109 closure pass via Backlog MCP + writing-plans/executing-plans; scope validate existing Discord config/runtime/docs changes, close remaining DoD evidence, and finalize task status if gates pass. +- [2026-02-23T01:15:39Z] [opencode-task109-discord-presence-20260223T011027Z-j9r4|opencode-task109-discord-presence] user feedback from real Discord session: status resumed to Playing with noticeable delay; tuned default `discordPresence.updateIntervalMs` from 15000 to 3000 in defaults/docs/examples and updated focused config expectations; reran focused config + discord presence tests green. diff --git a/src/config/config.test.ts b/src/config/config.test.ts index e5f22c3..ed35864 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -24,7 +24,7 @@ test('loads defaults when config is missing', () => { 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.discordPresence.updateIntervalMs, 3_000); assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)'); assert.equal(config.subtitleStyle.preserveLineBreaks, false); assert.equal(config.subtitleStyle.hoverTokenColor, '#c6a0f6'); @@ -248,9 +248,6 @@ test('parses discordPresence fields and warns for invalid types', () => { `{ "discordPresence": { "enabled": true, - "clientId": "123456789012345678", - "detailsTemplate": "Watching {title}", - "stateTemplate": "{status}", "updateIntervalMs": 3000, "debounceMs": 250 } @@ -261,7 +258,6 @@ test('parses discordPresence fields and warns for invalid types', () => { 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); diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index f551675..662265a 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -101,16 +101,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< }, discordPresence: { enabled: false, - clientId: '', - detailsTemplate: 'Mining Japanese', - stateTemplate: 'Idle', - largeImageKey: 'subminer-logo', - largeImageText: 'SubMiner', - smallImageKey: 'study', - smallImageText: 'Sentence Mining', - buttonLabel: '', - buttonUrl: '', - updateIntervalMs: 15_000, + updateIntervalMs: 3_000, debounceMs: 750, }, youtubeSubgen: { diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 0af2cd5..f102207 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -194,60 +194,6 @@ export function buildIntegrationConfigOptionRegistry( 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', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 77f93ee..a4a5a4f 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -128,7 +128,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ 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.', + 'Uses official SubMiner Discord app assets for polished card visuals.', ], key: 'discordPresence', }, diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts index 9020cd6..d517889 100644 --- a/src/config/resolve/integrations.ts +++ b/src/config/resolve/integrations.ts @@ -101,31 +101,6 @@ export function applyIntegrationConfig(context: ResolveContext): void { ); } - 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)); diff --git a/src/config/resolve/jellyfin.test.ts b/src/config/resolve/jellyfin.test.ts index 9e6cd2a..6802875 100644 --- a/src/config/resolve/jellyfin.test.ts +++ b/src/config/resolve/jellyfin.test.ts @@ -30,15 +30,6 @@ 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, }, @@ -47,15 +38,6 @@ test('discordPresence fields are parsed and clamped', () => { 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); }); @@ -64,7 +46,6 @@ 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, }, @@ -73,13 +54,11 @@ test('discordPresence invalid values warn and keep defaults', () => { 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.updateIntervalMs, 3_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 index 4cd6a3f..cefa47c 100644 --- a/src/core/services/discord-presence.test.ts +++ b/src/core/services/discord-presence.test.ts @@ -10,15 +10,6 @@ import { 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; @@ -27,6 +18,8 @@ const baseSnapshot: DiscordPresenceSnapshot = { mediaTitle: 'Sousou no Frieren E01', mediaPath: '/media/Frieren/E01.mkv', subtitleText: '旅立ち', + currentTimeSec: 95, + mediaDurationSec: 1450, paused: false, connected: true, sessionStartedAtMs: 1_700_000_000_000, @@ -34,11 +27,11 @@ const baseSnapshot: DiscordPresenceSnapshot = { 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.details, 'Sousou no Frieren E01'); + assert.equal(payload.state, 'Playing 01:35 / 24:10'); assert.equal(payload.largeImageKey, 'subminer-logo'); assert.equal(payload.smallImageKey, 'study'); - assert.equal(payload.buttons?.[0]?.label, 'GitHub'); + assert.equal(payload.buttons, undefined); assert.equal(payload.startTimestamp, 1_700_000_000); }); @@ -49,9 +42,10 @@ test('buildDiscordPresenceActivity falls back to idle when disconnected', () => mediaPath: null, }); assert.equal(payload.state, 'Idle'); + assert.equal(payload.details, 'Mining and crafting (Anki cards)'); }); -test('service deduplicates identical activity updates and throttles interval', async () => { +test('service deduplicates identical updates and sends changed timeline', async () => { const sent: DiscordActivityPayload[] = []; const timers = new Map void>(); let timerId = 0; @@ -90,11 +84,11 @@ test('service deduplicates identical activity updates and throttles interval', a assert.equal(sent.length, 1); nowMs += 10_001; - service.publish({ ...baseSnapshot, paused: true }); + service.publish({ ...baseSnapshot, paused: true, currentTimeSec: 100 }); timers.get(3)?.(); await Promise.resolve(); assert.equal(sent.length, 2); - assert.equal(sent[1]?.state, 'Paused'); + assert.equal(sent[1]?.state, 'Paused 01:40 / 24:10'); }); test('service handles login failure and stop without throwing', async () => { diff --git a/src/core/services/discord-presence.ts b/src/core/services/discord-presence.ts index fdebcb7..5876a34 100644 --- a/src/core/services/discord-presence.ts +++ b/src/core/services/discord-presence.ts @@ -4,6 +4,8 @@ export interface DiscordPresenceSnapshot { mediaTitle: string | null; mediaPath: string | null; subtitleText: string; + currentTimeSec?: number | null; + mediaDurationSec?: number | null; paused: boolean | null; connected: boolean; sessionStartedAtMs: number; @@ -31,6 +33,16 @@ type DiscordClient = { type TimeoutLike = ReturnType; +const DISCORD_PRESENCE_STYLE = { + fallbackDetails: 'Mining and crafting (Anki cards)', + largeImageKey: 'subminer-logo', + largeImageText: 'SubMiner', + smallImageKey: 'study', + smallImageText: 'Sentence Mining', + buttonLabel: '', + buttonUrl: '', +} as const; + function trimField(value: string, maxLength = 128): string { if (value.length <= maxLength) return value; return `${value.slice(0, Math.max(0, maxLength - 1))}…`; @@ -48,37 +60,39 @@ function basename(filePath: string | null): string { 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'; + return 'Playing'; +} + +function formatClock(totalSeconds: number | null | undefined): string { + if (!Number.isFinite(totalSeconds) || (totalSeconds ?? -1) < 0) return '--:--'; + const rounded = Math.floor(totalSeconds as number); + const hours = Math.floor(rounded / 3600); + const minutes = Math.floor((rounded % 3600) / 60); + const seconds = rounded % 60; + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } export function buildDiscordPresenceActivity( - config: DiscordPresenceConfig, + _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 details = + snapshot.connected && snapshot.mediaPath + ? trimField(title) + : DISCORD_PRESENCE_STYLE.fallbackDetails; + const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`; + const state = + snapshot.connected && snapshot.mediaPath + ? trimField(`${status} ${timeline}`) + : trimField(status); const activity: DiscordActivityPayload = { details, @@ -86,21 +100,27 @@ export function buildDiscordPresenceActivity( startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000), }; - if (config.largeImageKey.trim().length > 0) { - activity.largeImageKey = config.largeImageKey.trim(); + if (DISCORD_PRESENCE_STYLE.largeImageKey.trim().length > 0) { + activity.largeImageKey = DISCORD_PRESENCE_STYLE.largeImageKey.trim(); } - if (config.largeImageText.trim().length > 0) { - activity.largeImageText = trimField(config.largeImageText.trim()); + if (DISCORD_PRESENCE_STYLE.largeImageText.trim().length > 0) { + activity.largeImageText = trimField(DISCORD_PRESENCE_STYLE.largeImageText.trim()); } - if (config.smallImageKey.trim().length > 0) { - activity.smallImageKey = config.smallImageKey.trim(); + if (DISCORD_PRESENCE_STYLE.smallImageKey.trim().length > 0) { + activity.smallImageKey = DISCORD_PRESENCE_STYLE.smallImageKey.trim(); } - if (config.smallImageText.trim().length > 0) { - activity.smallImageText = trimField(config.smallImageText.trim()); + if (DISCORD_PRESENCE_STYLE.smallImageText.trim().length > 0) { + activity.smallImageText = trimField(DISCORD_PRESENCE_STYLE.smallImageText.trim()); } - if (config.buttonLabel.trim().length > 0 && /^https?:\/\//.test(config.buttonUrl.trim())) { + if ( + DISCORD_PRESENCE_STYLE.buttonLabel.trim().length > 0 && + /^https?:\/\//.test(DISCORD_PRESENCE_STYLE.buttonUrl.trim()) + ) { activity.buttons = [ - { label: trimField(config.buttonLabel.trim(), 32), url: config.buttonUrl.trim() }, + { + label: trimField(DISCORD_PRESENCE_STYLE.buttonLabel.trim(), 32), + url: DISCORD_PRESENCE_STYLE.buttonUrl.trim(), + }, ]; } @@ -109,7 +129,7 @@ export function buildDiscordPresenceActivity( export function createDiscordPresenceService(deps: { config: DiscordPresenceConfig; - createClient: (clientId: string) => DiscordClient; + createClient: () => DiscordClient; now?: () => number; setTimeoutFn?: (callback: () => void, delayMs: number) => TimeoutLike; clearTimeoutFn?: (timer: TimeoutLike) => void; @@ -166,13 +186,8 @@ export function createDiscordPresenceService(deps: { 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); + client = deps.createClient(); await client.login(); } catch (error) { client = null; diff --git a/src/main.ts b/src/main.ts index 646a984..c3e3419 100644 --- a/src/main.ts +++ b/src/main.ts @@ -427,6 +427,7 @@ let jellyfinPlayQuitOnDisconnectArmed = false; const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US'; const JELLYFIN_TICKS_PER_SECOND = 10_000_000; const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000; +const DISCORD_PRESENCE_APP_ID = '1475264834730856619'; const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000; const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000; const MPV_JELLYFIN_DEFAULT_ARGS = [ @@ -585,19 +586,38 @@ const appState = createAppState({ texthookerPort: DEFAULT_TEXTHOOKER_PORT, }); const discordPresenceSessionStartedAtMs = Date.now(); +let discordPresenceMediaDurationSec: number | null = null; + +function refreshDiscordPresenceMediaDuration(): void { + const client = appState.mpvClient; + if (!client || !client.connected) return; + void client + .requestProperty('duration') + .then((value) => { + const numeric = Number(value); + discordPresenceMediaDurationSec = Number.isFinite(numeric) && numeric > 0 ? numeric : null; + }) + .catch(() => { + discordPresenceMediaDurationSec = null; + }); +} function publishDiscordPresence(): void { + refreshDiscordPresenceMediaDuration(); appState.discordPresenceService?.publish({ mediaTitle: appState.currentMediaTitle, mediaPath: appState.currentMediaPath, subtitleText: appState.currentSubText, + currentTimeSec: appState.mpvClient?.currentTimePos ?? null, + mediaDurationSec: + discordPresenceMediaDurationSec ?? anilistMediaGuessRuntimeState.mediaDurationSec, paused: appState.playbackPaused, connected: Boolean(appState.mpvClient?.connected), sessionStartedAtMs: discordPresenceSessionStartedAtMs, }); } -function createDiscordRpcClient(clientId: string) { +function createDiscordRpcClient() { const discordRpc = require('discord-rpc') as { Client: new (opts: { transport: 'ipc' }) => { login: (opts: { clientId: string }) => Promise; @@ -609,7 +629,7 @@ function createDiscordRpcClient(clientId: string) { const client = new discordRpc.Client({ transport: 'ipc' }); return { - login: () => client.login({ clientId }), + login: () => client.login({ clientId: DISCORD_PRESENCE_APP_ID }), setActivity: (activity: unknown) => client.setActivity(activity as unknown as Record), clearActivity: () => client.clearActivity(), @@ -620,7 +640,7 @@ function createDiscordRpcClient(clientId: string) { async function initializeDiscordPresenceService(): Promise { appState.discordPresenceService = createDiscordPresenceService({ config: getResolvedConfig().discordPresence, - createClient: (clientId) => createDiscordRpcClient(clientId), + createClient: () => createDiscordRpcClient(), logDebug: (message, meta) => logger.debug(message, meta), }); await appState.discordPresenceService.start(); diff --git a/src/types.ts b/src/types.ts index 0bd9de4..09e1e54 100644 --- a/src/types.ts +++ b/src/types.ts @@ -360,15 +360,6 @@ export interface JellyfinConfig { 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; } @@ -546,15 +537,6 @@ export interface ResolvedConfig { }; 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; };