mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
feat(core): add Discord Rich Presence integration
Introduce optional Discord activity updates across config, runtime, tests, and docs so playback context appears in Discord without destabilizing app lifecycle. Tune default refresh cadence to reduce pause/resume lag during real sessions.
This commit is contained in:
@@ -2,9 +2,10 @@
|
|||||||
id: TASK-109
|
id: TASK-109
|
||||||
title: Add Discord Rich Presence integration with polished activity card
|
title: Add Discord Rich Presence integration with polished activity card
|
||||||
status: In Progress
|
status: In Progress
|
||||||
assignee: []
|
assignee:
|
||||||
|
- opencode
|
||||||
created_date: '2026-02-22 19:40'
|
created_date: '2026-02-22 19:40'
|
||||||
updated_date: '2026-02-22 22:36'
|
updated_date: '2026-02-23 01:15'
|
||||||
labels:
|
labels:
|
||||||
- feature
|
- feature
|
||||||
- discord
|
- 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.
|
- [ ] #5 Docs include setup steps (app/client id), config keys, and troubleshooting notes.
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
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).
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
@@ -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.
|
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).
|
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).
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Overlay Auto-Start
|
// Overlay Auto-Start
|
||||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
// Control whether browser opens automatically for texthooker.
|
// Control whether browser opens automatically for texthooker.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"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.
|
}, // Control whether browser opens automatically for texthooker.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -34,7 +33,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
"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.
|
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -43,7 +42,7 @@
|
|||||||
// Set to debug for full runtime diagnostics.
|
// Set to debug for full runtime diagnostics.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"logging": {
|
"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.
|
}, // Controls logging verbosity.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -65,7 +64,7 @@
|
|||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options 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.
|
}, // 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.
|
// This edit-mode shortcut is fixed and is not currently configurable.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"invisibleOverlay": {
|
"invisibleOverlay": {
|
||||||
"startupVisibility": "platform-default" // Startup visibility setting.
|
"startupVisibility": "platform-default", // Startup visibility setting.
|
||||||
}, // Startup behavior for the invisible interactive subtitle mining layer.
|
}, // Startup behavior for the invisible interactive subtitle mining layer.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -95,7 +94,7 @@
|
|||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
"secondarySubLanguages": [], // Secondary sub languages setting.
|
||||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
||||||
"defaultMode": "hover" // Default mode setting.
|
"defaultMode": "hover", // Default mode setting.
|
||||||
}, // Dual subtitle track options.
|
}, // Dual subtitle track options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -106,7 +105,7 @@
|
|||||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "" // Ffmpeg path setting.
|
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -114,7 +113,7 @@
|
|||||||
// Initial vertical subtitle position from the bottom.
|
// Initial vertical subtitle position from the bottom.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitlePosition": {
|
"subtitlePosition": {
|
||||||
"yPercent": 10 // Y percent setting.
|
"yPercent": 10, // Y percent setting.
|
||||||
}, // Initial vertical subtitle position from the bottom.
|
}, // Initial vertical subtitle position from the bottom.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -139,7 +138,7 @@
|
|||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
"N3": "#f9e2af", // N3 setting.
|
"N3": "#f9e2af", // N3 setting.
|
||||||
"N4": "#a6e3a1", // N4 setting.
|
"N4": "#a6e3a1", // N4 setting.
|
||||||
"N5": "#8aadf4" // N5 setting.
|
"N5": "#8aadf4", // N5 setting.
|
||||||
}, // Jlpt colors setting.
|
}, // Jlpt colors setting.
|
||||||
"frequencyDictionary": {
|
"frequencyDictionary": {
|
||||||
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
"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).
|
"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
|
"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`.
|
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
||||||
"bandedColors": [
|
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
"#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.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"fontSize": 24, // Font size setting.
|
"fontSize": 24, // Font size setting.
|
||||||
@@ -161,8 +154,8 @@
|
|||||||
"backgroundColor": "transparent", // Background color setting.
|
"backgroundColor": "transparent", // Background color setting.
|
||||||
"fontWeight": "normal", // Font weight setting.
|
"fontWeight": "normal", // Font weight setting.
|
||||||
"fontStyle": "normal", // Font style 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.
|
"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.
|
}, // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -175,15 +168,13 @@
|
|||||||
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
||||||
"url": "http://127.0.0.1:8765", // Url setting.
|
"url": "http://127.0.0.1:8765", // Url setting.
|
||||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||||
"tags": [
|
"tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
"SubMiner"
|
|
||||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
|
||||||
"fields": {
|
"fields": {
|
||||||
"audio": "ExpressionAudio", // Audio setting.
|
"audio": "ExpressionAudio", // Audio setting.
|
||||||
"image": "Picture", // Image setting.
|
"image": "Picture", // Image setting.
|
||||||
"sentence": "Sentence", // Sentence setting.
|
"sentence": "Sentence", // Sentence setting.
|
||||||
"miscInfo": "MiscInfo", // Misc info setting.
|
"miscInfo": "MiscInfo", // Misc info setting.
|
||||||
"translation": "SelectionText" // Translation setting.
|
"translation": "SelectionText", // Translation setting.
|
||||||
}, // Fields setting.
|
}, // Fields setting.
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
@@ -192,7 +183,7 @@
|
|||||||
"model": "openai/gpt-4o-mini", // Model setting.
|
"model": "openai/gpt-4o-mini", // Model setting.
|
||||||
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
||||||
"targetLanguage": "English", // Target language 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.
|
}, // Ai setting.
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
"generateAudio": true, // Generate audio setting. Values: true | false
|
||||||
@@ -205,7 +196,7 @@
|
|||||||
"animatedCrf": 35, // Animated crf setting.
|
"animatedCrf": 35, // Animated crf setting.
|
||||||
"audioPadding": 0.5, // Audio padding setting.
|
"audioPadding": 0.5, // Audio padding setting.
|
||||||
"fallbackDuration": 3, // Fallback duration setting.
|
"fallbackDuration": 3, // Fallback duration setting.
|
||||||
"maxMediaDuration": 30 // Max media duration setting.
|
"maxMediaDuration": 30, // Max media duration setting.
|
||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
||||||
@@ -213,7 +204,7 @@
|
|||||||
"mediaInsertMode": "append", // Media insert mode setting.
|
"mediaInsertMode": "append", // Media insert mode setting.
|
||||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
"highlightWord": true, // Highlight word setting. Values: true | false
|
||||||
"notificationType": "osd", // Notification type setting.
|
"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.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"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.
|
"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).
|
"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.
|
"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.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
"pattern": "[SubMiner] %f (%t)", // Pattern setting.
|
||||||
}, // Metadata setting.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
"sentenceCardModel": "Japanese sentences", // Sentence card model setting.
|
||||||
}, // Is lapis setting.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
"deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false
|
||||||
} // Is kiku setting.
|
}, // Is kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -245,7 +236,7 @@
|
|||||||
"jimaku": {
|
"jimaku": {
|
||||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"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.
|
}, // Jimaku API configuration and defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -256,10 +247,7 @@
|
|||||||
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
|
"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.
|
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
|
||||||
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher.
|
||||||
"ja",
|
|
||||||
"jpn"
|
|
||||||
] // Comma-separated primary subtitle language priority used by the launcher.
|
|
||||||
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -268,7 +256,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"anilist": {
|
"anilist": {
|
||||||
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
"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.
|
}, // Anilist API credentials and update behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -292,36 +280,19 @@
|
|||||||
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
"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.
|
"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
|
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||||
"directPlayContainers": [
|
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
|
||||||
"mkv",
|
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
|
||||||
"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.
|
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Discord Rich Presence
|
// Discord Rich Presence
|
||||||
// Optional Discord Rich Presence activity card updates for current playback/study session.
|
// 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": {
|
"discordPresence": {
|
||||||
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||||
"clientId": "", // Discord application client ID used for Rich Presence.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"detailsTemplate": "Mining Japanese", // Details line template for the activity card.
|
"debounceMs": 750, // Debounce delay used to collapse bursty presence updates.
|
||||||
"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.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -343,7 +314,7 @@
|
|||||||
"telemetryDays": 30, // Telemetry retention window in days.
|
"telemetryDays": 30, // Telemetry retention window in days.
|
||||||
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
||||||
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
||||||
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
|
"vacuumIntervalDays": 7, // Minimum days between VACUUM runs.
|
||||||
} // Retention setting.
|
}, // Retention setting.
|
||||||
} // Enable/disable immersion tracking.
|
}, // Enable/disable immersion tracking.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -542,46 +542,34 @@ Discord Rich Presence is optional and disabled by default. When enabled, SubMine
|
|||||||
{
|
{
|
||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientId": "123456789012345678",
|
"updateIntervalMs": 3000,
|
||||||
"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
|
"debounceMs": 750
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------ | --------------- | ---------------------------------------------------------------------------------- |
|
| ------------------ | --------------- | ---------------------------------------------------------- |
|
||||||
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) |
|
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) |
|
||||||
| `clientId` | string | Discord application client ID |
|
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
|
||||||
| `detailsTemplate` | string | Card details line template. Supports `{title}`, `{file}`, `{subtitle}`, `{status}` |
|
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
|
||||||
| `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:
|
Setup steps:
|
||||||
|
|
||||||
1. Create a Discord application at <https://discord.com/developers/applications>.
|
1. Set `discordPresence.enabled` to `true`.
|
||||||
2. Open **Rich Presence > Art Assets** and upload image assets referenced by `largeImageKey` / `smallImageKey`.
|
2. Restart SubMiner.
|
||||||
3. Copy the application ID into `discordPresence.clientId`.
|
|
||||||
4. Set `discordPresence.enabled` to `true` and 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:
|
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 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.
|
- If Discord is closed/not installed/disconnects, SubMiner continues running and quietly skips presence updates.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Overlay Auto-Start
|
// Overlay Auto-Start
|
||||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
// Control whether browser opens automatically for texthooker.
|
// Control whether browser opens automatically for texthooker.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"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.
|
}, // Control whether browser opens automatically for texthooker.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -34,7 +33,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
"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.
|
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -43,7 +42,7 @@
|
|||||||
// Set to debug for full runtime diagnostics.
|
// Set to debug for full runtime diagnostics.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"logging": {
|
"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.
|
}, // Controls logging verbosity.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -65,7 +64,7 @@
|
|||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options 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.
|
}, // 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.
|
// This edit-mode shortcut is fixed and is not currently configurable.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"invisibleOverlay": {
|
"invisibleOverlay": {
|
||||||
"startupVisibility": "platform-default" // Startup visibility setting.
|
"startupVisibility": "platform-default", // Startup visibility setting.
|
||||||
}, // Startup behavior for the invisible interactive subtitle mining layer.
|
}, // Startup behavior for the invisible interactive subtitle mining layer.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -95,7 +94,7 @@
|
|||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
"secondarySubLanguages": [], // Secondary sub languages setting.
|
||||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
||||||
"defaultMode": "hover" // Default mode setting.
|
"defaultMode": "hover", // Default mode setting.
|
||||||
}, // Dual subtitle track options.
|
}, // Dual subtitle track options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -106,7 +105,7 @@
|
|||||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "" // Ffmpeg path setting.
|
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -114,7 +113,7 @@
|
|||||||
// Initial vertical subtitle position from the bottom.
|
// Initial vertical subtitle position from the bottom.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitlePosition": {
|
"subtitlePosition": {
|
||||||
"yPercent": 10 // Y percent setting.
|
"yPercent": 10, // Y percent setting.
|
||||||
}, // Initial vertical subtitle position from the bottom.
|
}, // Initial vertical subtitle position from the bottom.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -139,7 +138,7 @@
|
|||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
"N3": "#f9e2af", // N3 setting.
|
"N3": "#f9e2af", // N3 setting.
|
||||||
"N4": "#a6e3a1", // N4 setting.
|
"N4": "#a6e3a1", // N4 setting.
|
||||||
"N5": "#8aadf4" // N5 setting.
|
"N5": "#8aadf4", // N5 setting.
|
||||||
}, // Jlpt colors setting.
|
}, // Jlpt colors setting.
|
||||||
"frequencyDictionary": {
|
"frequencyDictionary": {
|
||||||
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
"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).
|
"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
|
"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`.
|
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
||||||
"bandedColors": [
|
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
"#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.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"fontSize": 24, // Font size setting.
|
"fontSize": 24, // Font size setting.
|
||||||
@@ -161,8 +154,8 @@
|
|||||||
"backgroundColor": "transparent", // Background color setting.
|
"backgroundColor": "transparent", // Background color setting.
|
||||||
"fontWeight": "normal", // Font weight setting.
|
"fontWeight": "normal", // Font weight setting.
|
||||||
"fontStyle": "normal", // Font style 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.
|
"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.
|
}, // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -175,15 +168,13 @@
|
|||||||
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
||||||
"url": "http://127.0.0.1:8765", // Url setting.
|
"url": "http://127.0.0.1:8765", // Url setting.
|
||||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||||
"tags": [
|
"tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
"SubMiner"
|
|
||||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
|
||||||
"fields": {
|
"fields": {
|
||||||
"audio": "ExpressionAudio", // Audio setting.
|
"audio": "ExpressionAudio", // Audio setting.
|
||||||
"image": "Picture", // Image setting.
|
"image": "Picture", // Image setting.
|
||||||
"sentence": "Sentence", // Sentence setting.
|
"sentence": "Sentence", // Sentence setting.
|
||||||
"miscInfo": "MiscInfo", // Misc info setting.
|
"miscInfo": "MiscInfo", // Misc info setting.
|
||||||
"translation": "SelectionText" // Translation setting.
|
"translation": "SelectionText", // Translation setting.
|
||||||
}, // Fields setting.
|
}, // Fields setting.
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
@@ -192,7 +183,7 @@
|
|||||||
"model": "openai/gpt-4o-mini", // Model setting.
|
"model": "openai/gpt-4o-mini", // Model setting.
|
||||||
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
||||||
"targetLanguage": "English", // Target language 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.
|
}, // Ai setting.
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
"generateAudio": true, // Generate audio setting. Values: true | false
|
||||||
@@ -205,7 +196,7 @@
|
|||||||
"animatedCrf": 35, // Animated crf setting.
|
"animatedCrf": 35, // Animated crf setting.
|
||||||
"audioPadding": 0.5, // Audio padding setting.
|
"audioPadding": 0.5, // Audio padding setting.
|
||||||
"fallbackDuration": 3, // Fallback duration setting.
|
"fallbackDuration": 3, // Fallback duration setting.
|
||||||
"maxMediaDuration": 30 // Max media duration setting.
|
"maxMediaDuration": 30, // Max media duration setting.
|
||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
||||||
@@ -213,7 +204,7 @@
|
|||||||
"mediaInsertMode": "append", // Media insert mode setting.
|
"mediaInsertMode": "append", // Media insert mode setting.
|
||||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
"highlightWord": true, // Highlight word setting. Values: true | false
|
||||||
"notificationType": "osd", // Notification type setting.
|
"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.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"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.
|
"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).
|
"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.
|
"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.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
"pattern": "[SubMiner] %f (%t)", // Pattern setting.
|
||||||
}, // Metadata setting.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
"sentenceCardModel": "Japanese sentences", // Sentence card model setting.
|
||||||
}, // Is lapis setting.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
"deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false
|
||||||
} // Is kiku setting.
|
}, // Is kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -245,7 +236,7 @@
|
|||||||
"jimaku": {
|
"jimaku": {
|
||||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"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.
|
}, // Jimaku API configuration and defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -256,10 +247,7 @@
|
|||||||
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
|
"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.
|
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
|
||||||
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher.
|
||||||
"ja",
|
|
||||||
"jpn"
|
|
||||||
] // Comma-separated primary subtitle language priority used by the launcher.
|
|
||||||
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -268,7 +256,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"anilist": {
|
"anilist": {
|
||||||
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
"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.
|
}, // Anilist API credentials and update behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -292,36 +280,19 @@
|
|||||||
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
"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.
|
"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
|
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||||
"directPlayContainers": [
|
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
|
||||||
"mkv",
|
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
|
||||||
"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.
|
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Discord Rich Presence
|
// Discord Rich Presence
|
||||||
// Optional Discord Rich Presence activity card updates for current playback/study session.
|
// 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": {
|
"discordPresence": {
|
||||||
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||||
"clientId": "", // Discord application client ID used for Rich Presence.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"detailsTemplate": "Mining Japanese", // Details line template for the activity card.
|
"debounceMs": 750, // Debounce delay used to collapse bursty presence updates.
|
||||||
"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.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -343,7 +314,7 @@
|
|||||||
"telemetryDays": 30, // Telemetry retention window in days.
|
"telemetryDays": 30, // Telemetry retention window in days.
|
||||||
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
||||||
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
||||||
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
|
"vacuumIntervalDays": 7, // Minimum days between VACUUM runs.
|
||||||
} // Retention setting.
|
}, // Retention setting.
|
||||||
} // Enable/disable immersion tracking.
|
}, // Enable/disable immersion tracking.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-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` |
|
| `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-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` |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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: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: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-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.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.jellyfin.autoAnnounce, false);
|
assert.equal(config.jellyfin.autoAnnounce, false);
|
||||||
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
|
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
|
||||||
assert.equal(config.discordPresence.enabled, false);
|
assert.equal(config.discordPresence.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.backgroundColor, 'rgb(30, 32, 48, 0.88)');
|
||||||
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
|
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
|
||||||
assert.equal(config.subtitleStyle.hoverTokenColor, '#c6a0f6');
|
assert.equal(config.subtitleStyle.hoverTokenColor, '#c6a0f6');
|
||||||
@@ -248,9 +248,6 @@ test('parses discordPresence fields and warns for invalid types', () => {
|
|||||||
`{
|
`{
|
||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientId": "123456789012345678",
|
|
||||||
"detailsTemplate": "Watching {title}",
|
|
||||||
"stateTemplate": "{status}",
|
|
||||||
"updateIntervalMs": 3000,
|
"updateIntervalMs": 3000,
|
||||||
"debounceMs": 250
|
"debounceMs": 250
|
||||||
}
|
}
|
||||||
@@ -261,7 +258,6 @@ test('parses discordPresence fields and warns for invalid types', () => {
|
|||||||
const service = new ConfigService(dir);
|
const service = new ConfigService(dir);
|
||||||
const config = service.getConfig();
|
const config = service.getConfig();
|
||||||
assert.equal(config.discordPresence.enabled, true);
|
assert.equal(config.discordPresence.enabled, true);
|
||||||
assert.equal(config.discordPresence.clientId, '123456789012345678');
|
|
||||||
assert.equal(config.discordPresence.updateIntervalMs, 3000);
|
assert.equal(config.discordPresence.updateIntervalMs, 3000);
|
||||||
assert.equal(config.discordPresence.debounceMs, 250);
|
assert.equal(config.discordPresence.debounceMs, 250);
|
||||||
|
|
||||||
|
|||||||
@@ -101,16 +101,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
},
|
},
|
||||||
discordPresence: {
|
discordPresence: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
clientId: '',
|
updateIntervalMs: 3_000,
|
||||||
detailsTemplate: 'Mining Japanese',
|
|
||||||
stateTemplate: 'Idle',
|
|
||||||
largeImageKey: 'subminer-logo',
|
|
||||||
largeImageText: 'SubMiner',
|
|
||||||
smallImageKey: 'study',
|
|
||||||
smallImageText: 'Sentence Mining',
|
|
||||||
buttonLabel: '',
|
|
||||||
buttonUrl: '',
|
|
||||||
updateIntervalMs: 15_000,
|
|
||||||
debounceMs: 750,
|
debounceMs: 750,
|
||||||
},
|
},
|
||||||
youtubeSubgen: {
|
youtubeSubgen: {
|
||||||
|
|||||||
@@ -194,60 +194,6 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.discordPresence.enabled,
|
defaultValue: defaultConfig.discordPresence.enabled,
|
||||||
description: 'Enable optional Discord Rich Presence updates.',
|
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',
|
path: 'discordPresence.updateIntervalMs',
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
title: 'Discord Rich Presence',
|
title: 'Discord Rich Presence',
|
||||||
description: [
|
description: [
|
||||||
'Optional Discord Rich Presence activity card updates for current playback/study session.',
|
'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',
|
key: 'discordPresence',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
const updateIntervalMs = asNumber(src.discordPresence.updateIntervalMs);
|
||||||
if (updateIntervalMs !== undefined) {
|
if (updateIntervalMs !== undefined) {
|
||||||
resolved.discordPresence.updateIntervalMs = Math.max(1_000, Math.floor(updateIntervalMs));
|
resolved.discordPresence.updateIntervalMs = Math.max(1_000, Math.floor(updateIntervalMs));
|
||||||
|
|||||||
@@ -30,15 +30,6 @@ test('discordPresence fields are parsed and clamped', () => {
|
|||||||
const { context } = createResolveContext({
|
const { context } = createResolveContext({
|
||||||
discordPresence: {
|
discordPresence: {
|
||||||
enabled: true,
|
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,
|
updateIntervalMs: 500,
|
||||||
debounceMs: -100,
|
debounceMs: -100,
|
||||||
},
|
},
|
||||||
@@ -47,15 +38,6 @@ test('discordPresence fields are parsed and clamped', () => {
|
|||||||
applyIntegrationConfig(context);
|
applyIntegrationConfig(context);
|
||||||
|
|
||||||
assert.equal(context.resolved.discordPresence.enabled, true);
|
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.updateIntervalMs, 1000);
|
||||||
assert.equal(context.resolved.discordPresence.debounceMs, 0);
|
assert.equal(context.resolved.discordPresence.debounceMs, 0);
|
||||||
});
|
});
|
||||||
@@ -64,7 +46,6 @@ test('discordPresence invalid values warn and keep defaults', () => {
|
|||||||
const { context, warnings } = createResolveContext({
|
const { context, warnings } = createResolveContext({
|
||||||
discordPresence: {
|
discordPresence: {
|
||||||
enabled: 'true' as never,
|
enabled: 'true' as never,
|
||||||
clientId: 123 as never,
|
|
||||||
updateIntervalMs: 'fast' as never,
|
updateIntervalMs: 'fast' as never,
|
||||||
debounceMs: null as never,
|
debounceMs: null as never,
|
||||||
},
|
},
|
||||||
@@ -73,13 +54,11 @@ test('discordPresence invalid values warn and keep defaults', () => {
|
|||||||
applyIntegrationConfig(context);
|
applyIntegrationConfig(context);
|
||||||
|
|
||||||
assert.equal(context.resolved.discordPresence.enabled, false);
|
assert.equal(context.resolved.discordPresence.enabled, false);
|
||||||
assert.equal(context.resolved.discordPresence.clientId, '');
|
assert.equal(context.resolved.discordPresence.updateIntervalMs, 3_000);
|
||||||
assert.equal(context.resolved.discordPresence.updateIntervalMs, 15_000);
|
|
||||||
assert.equal(context.resolved.discordPresence.debounceMs, 750);
|
assert.equal(context.resolved.discordPresence.debounceMs, 750);
|
||||||
|
|
||||||
const warnedPaths = warnings.map((warning) => warning.path);
|
const warnedPaths = warnings.map((warning) => warning.path);
|
||||||
assert.ok(warnedPaths.includes('discordPresence.enabled'));
|
assert.ok(warnedPaths.includes('discordPresence.enabled'));
|
||||||
assert.ok(warnedPaths.includes('discordPresence.clientId'));
|
|
||||||
assert.ok(warnedPaths.includes('discordPresence.updateIntervalMs'));
|
assert.ok(warnedPaths.includes('discordPresence.updateIntervalMs'));
|
||||||
assert.ok(warnedPaths.includes('discordPresence.debounceMs'));
|
assert.ok(warnedPaths.includes('discordPresence.debounceMs'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,15 +10,6 @@ import {
|
|||||||
|
|
||||||
const baseConfig = {
|
const baseConfig = {
|
||||||
enabled: true,
|
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,
|
updateIntervalMs: 10_000,
|
||||||
debounceMs: 200,
|
debounceMs: 200,
|
||||||
} as const;
|
} as const;
|
||||||
@@ -27,6 +18,8 @@ const baseSnapshot: DiscordPresenceSnapshot = {
|
|||||||
mediaTitle: 'Sousou no Frieren E01',
|
mediaTitle: 'Sousou no Frieren E01',
|
||||||
mediaPath: '/media/Frieren/E01.mkv',
|
mediaPath: '/media/Frieren/E01.mkv',
|
||||||
subtitleText: '旅立ち',
|
subtitleText: '旅立ち',
|
||||||
|
currentTimeSec: 95,
|
||||||
|
mediaDurationSec: 1450,
|
||||||
paused: false,
|
paused: false,
|
||||||
connected: true,
|
connected: true,
|
||||||
sessionStartedAtMs: 1_700_000_000_000,
|
sessionStartedAtMs: 1_700_000_000_000,
|
||||||
@@ -34,11 +27,11 @@ const baseSnapshot: DiscordPresenceSnapshot = {
|
|||||||
|
|
||||||
test('buildDiscordPresenceActivity maps polished payload fields', () => {
|
test('buildDiscordPresenceActivity maps polished payload fields', () => {
|
||||||
const payload = buildDiscordPresenceActivity(baseConfig, baseSnapshot);
|
const payload = buildDiscordPresenceActivity(baseConfig, baseSnapshot);
|
||||||
assert.equal(payload.details, 'Watching Sousou no Frieren E01');
|
assert.equal(payload.details, 'Sousou no Frieren E01');
|
||||||
assert.equal(payload.state, 'Watching');
|
assert.equal(payload.state, 'Playing 01:35 / 24:10');
|
||||||
assert.equal(payload.largeImageKey, 'subminer-logo');
|
assert.equal(payload.largeImageKey, 'subminer-logo');
|
||||||
assert.equal(payload.smallImageKey, 'study');
|
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);
|
assert.equal(payload.startTimestamp, 1_700_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,9 +42,10 @@ test('buildDiscordPresenceActivity falls back to idle when disconnected', () =>
|
|||||||
mediaPath: null,
|
mediaPath: null,
|
||||||
});
|
});
|
||||||
assert.equal(payload.state, 'Idle');
|
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 sent: DiscordActivityPayload[] = [];
|
||||||
const timers = new Map<number, () => void>();
|
const timers = new Map<number, () => void>();
|
||||||
let timerId = 0;
|
let timerId = 0;
|
||||||
@@ -90,11 +84,11 @@ test('service deduplicates identical activity updates and throttles interval', a
|
|||||||
assert.equal(sent.length, 1);
|
assert.equal(sent.length, 1);
|
||||||
|
|
||||||
nowMs += 10_001;
|
nowMs += 10_001;
|
||||||
service.publish({ ...baseSnapshot, paused: true });
|
service.publish({ ...baseSnapshot, paused: true, currentTimeSec: 100 });
|
||||||
timers.get(3)?.();
|
timers.get(3)?.();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
assert.equal(sent.length, 2);
|
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 () => {
|
test('service handles login failure and stop without throwing', async () => {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface DiscordPresenceSnapshot {
|
|||||||
mediaTitle: string | null;
|
mediaTitle: string | null;
|
||||||
mediaPath: string | null;
|
mediaPath: string | null;
|
||||||
subtitleText: string;
|
subtitleText: string;
|
||||||
|
currentTimeSec?: number | null;
|
||||||
|
mediaDurationSec?: number | null;
|
||||||
paused: boolean | null;
|
paused: boolean | null;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
sessionStartedAtMs: number;
|
sessionStartedAtMs: number;
|
||||||
@@ -31,6 +33,16 @@ type DiscordClient = {
|
|||||||
|
|
||||||
type TimeoutLike = ReturnType<typeof setTimeout>;
|
type TimeoutLike = ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
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 {
|
function trimField(value: string, maxLength = 128): string {
|
||||||
if (value.length <= maxLength) return value;
|
if (value.length <= maxLength) return value;
|
||||||
return `${value.slice(0, Math.max(0, maxLength - 1))}…`;
|
return `${value.slice(0, Math.max(0, maxLength - 1))}…`;
|
||||||
@@ -48,37 +60,39 @@ function basename(filePath: string | null): string {
|
|||||||
return parts[parts.length - 1] ?? '';
|
return parts[parts.length - 1] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function interpolate(template: string, values: Record<string, string>): string {
|
|
||||||
return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key: string) => values[key] ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStatus(snapshot: DiscordPresenceSnapshot): string {
|
function buildStatus(snapshot: DiscordPresenceSnapshot): string {
|
||||||
if (!snapshot.connected || !snapshot.mediaPath) return 'Idle';
|
if (!snapshot.connected || !snapshot.mediaPath) return 'Idle';
|
||||||
if (snapshot.paused) return 'Paused';
|
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(
|
export function buildDiscordPresenceActivity(
|
||||||
config: DiscordPresenceConfig,
|
_config: DiscordPresenceConfig,
|
||||||
snapshot: DiscordPresenceSnapshot,
|
snapshot: DiscordPresenceSnapshot,
|
||||||
): DiscordActivityPayload {
|
): DiscordActivityPayload {
|
||||||
const status = buildStatus(snapshot);
|
const status = buildStatus(snapshot);
|
||||||
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
|
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
|
||||||
const subtitle = sanitizeText(snapshot.subtitleText, '');
|
const details =
|
||||||
const file = sanitizeText(basename(snapshot.mediaPath), '');
|
snapshot.connected && snapshot.mediaPath
|
||||||
const values = {
|
? trimField(title)
|
||||||
title,
|
: DISCORD_PRESENCE_STYLE.fallbackDetails;
|
||||||
file,
|
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
||||||
subtitle,
|
const state =
|
||||||
status,
|
snapshot.connected && snapshot.mediaPath
|
||||||
};
|
? trimField(`${status} ${timeline}`)
|
||||||
|
: trimField(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 = {
|
const activity: DiscordActivityPayload = {
|
||||||
details,
|
details,
|
||||||
@@ -86,21 +100,27 @@ export function buildDiscordPresenceActivity(
|
|||||||
startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000),
|
startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.largeImageKey.trim().length > 0) {
|
if (DISCORD_PRESENCE_STYLE.largeImageKey.trim().length > 0) {
|
||||||
activity.largeImageKey = config.largeImageKey.trim();
|
activity.largeImageKey = DISCORD_PRESENCE_STYLE.largeImageKey.trim();
|
||||||
}
|
}
|
||||||
if (config.largeImageText.trim().length > 0) {
|
if (DISCORD_PRESENCE_STYLE.largeImageText.trim().length > 0) {
|
||||||
activity.largeImageText = trimField(config.largeImageText.trim());
|
activity.largeImageText = trimField(DISCORD_PRESENCE_STYLE.largeImageText.trim());
|
||||||
}
|
}
|
||||||
if (config.smallImageKey.trim().length > 0) {
|
if (DISCORD_PRESENCE_STYLE.smallImageKey.trim().length > 0) {
|
||||||
activity.smallImageKey = config.smallImageKey.trim();
|
activity.smallImageKey = DISCORD_PRESENCE_STYLE.smallImageKey.trim();
|
||||||
}
|
}
|
||||||
if (config.smallImageText.trim().length > 0) {
|
if (DISCORD_PRESENCE_STYLE.smallImageText.trim().length > 0) {
|
||||||
activity.smallImageText = trimField(config.smallImageText.trim());
|
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 = [
|
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: {
|
export function createDiscordPresenceService(deps: {
|
||||||
config: DiscordPresenceConfig;
|
config: DiscordPresenceConfig;
|
||||||
createClient: (clientId: string) => DiscordClient;
|
createClient: () => DiscordClient;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
setTimeoutFn?: (callback: () => void, delayMs: number) => TimeoutLike;
|
setTimeoutFn?: (callback: () => void, delayMs: number) => TimeoutLike;
|
||||||
clearTimeoutFn?: (timer: TimeoutLike) => void;
|
clearTimeoutFn?: (timer: TimeoutLike) => void;
|
||||||
@@ -166,13 +186,8 @@ export function createDiscordPresenceService(deps: {
|
|||||||
return {
|
return {
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
if (!deps.config.enabled) return;
|
if (!deps.config.enabled) return;
|
||||||
const clientId = deps.config.clientId.trim();
|
|
||||||
if (!clientId) {
|
|
||||||
logDebug('[discord-presence] enabled but clientId missing; skipping start');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
client = deps.createClient(clientId);
|
client = deps.createClient();
|
||||||
await client.login();
|
await client.login();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
client = null;
|
client = null;
|
||||||
|
|||||||
26
src/main.ts
26
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_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
|
||||||
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
|
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
|
||||||
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
|
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
|
||||||
|
const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
|
||||||
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
|
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
|
||||||
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
|
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
|
||||||
const MPV_JELLYFIN_DEFAULT_ARGS = [
|
const MPV_JELLYFIN_DEFAULT_ARGS = [
|
||||||
@@ -585,19 +586,38 @@ const appState = createAppState({
|
|||||||
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
||||||
});
|
});
|
||||||
const discordPresenceSessionStartedAtMs = Date.now();
|
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 {
|
function publishDiscordPresence(): void {
|
||||||
|
refreshDiscordPresenceMediaDuration();
|
||||||
appState.discordPresenceService?.publish({
|
appState.discordPresenceService?.publish({
|
||||||
mediaTitle: appState.currentMediaTitle,
|
mediaTitle: appState.currentMediaTitle,
|
||||||
mediaPath: appState.currentMediaPath,
|
mediaPath: appState.currentMediaPath,
|
||||||
subtitleText: appState.currentSubText,
|
subtitleText: appState.currentSubText,
|
||||||
|
currentTimeSec: appState.mpvClient?.currentTimePos ?? null,
|
||||||
|
mediaDurationSec:
|
||||||
|
discordPresenceMediaDurationSec ?? anilistMediaGuessRuntimeState.mediaDurationSec,
|
||||||
paused: appState.playbackPaused,
|
paused: appState.playbackPaused,
|
||||||
connected: Boolean(appState.mpvClient?.connected),
|
connected: Boolean(appState.mpvClient?.connected),
|
||||||
sessionStartedAtMs: discordPresenceSessionStartedAtMs,
|
sessionStartedAtMs: discordPresenceSessionStartedAtMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDiscordRpcClient(clientId: string) {
|
function createDiscordRpcClient() {
|
||||||
const discordRpc = require('discord-rpc') as {
|
const discordRpc = require('discord-rpc') as {
|
||||||
Client: new (opts: { transport: 'ipc' }) => {
|
Client: new (opts: { transport: 'ipc' }) => {
|
||||||
login: (opts: { clientId: string }) => Promise<void>;
|
login: (opts: { clientId: string }) => Promise<void>;
|
||||||
@@ -609,7 +629,7 @@ function createDiscordRpcClient(clientId: string) {
|
|||||||
const client = new discordRpc.Client({ transport: 'ipc' });
|
const client = new discordRpc.Client({ transport: 'ipc' });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
login: () => client.login({ clientId }),
|
login: () => client.login({ clientId: DISCORD_PRESENCE_APP_ID }),
|
||||||
setActivity: (activity: unknown) =>
|
setActivity: (activity: unknown) =>
|
||||||
client.setActivity(activity as unknown as Record<string, unknown>),
|
client.setActivity(activity as unknown as Record<string, unknown>),
|
||||||
clearActivity: () => client.clearActivity(),
|
clearActivity: () => client.clearActivity(),
|
||||||
@@ -620,7 +640,7 @@ function createDiscordRpcClient(clientId: string) {
|
|||||||
async function initializeDiscordPresenceService(): Promise<void> {
|
async function initializeDiscordPresenceService(): Promise<void> {
|
||||||
appState.discordPresenceService = createDiscordPresenceService({
|
appState.discordPresenceService = createDiscordPresenceService({
|
||||||
config: getResolvedConfig().discordPresence,
|
config: getResolvedConfig().discordPresence,
|
||||||
createClient: (clientId) => createDiscordRpcClient(clientId),
|
createClient: () => createDiscordRpcClient(),
|
||||||
logDebug: (message, meta) => logger.debug(message, meta),
|
logDebug: (message, meta) => logger.debug(message, meta),
|
||||||
});
|
});
|
||||||
await appState.discordPresenceService.start();
|
await appState.discordPresenceService.start();
|
||||||
|
|||||||
18
src/types.ts
18
src/types.ts
@@ -360,15 +360,6 @@ export interface JellyfinConfig {
|
|||||||
|
|
||||||
export interface DiscordPresenceConfig {
|
export interface DiscordPresenceConfig {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
clientId?: string;
|
|
||||||
detailsTemplate?: string;
|
|
||||||
stateTemplate?: string;
|
|
||||||
largeImageKey?: string;
|
|
||||||
largeImageText?: string;
|
|
||||||
smallImageKey?: string;
|
|
||||||
smallImageText?: string;
|
|
||||||
buttonLabel?: string;
|
|
||||||
buttonUrl?: string;
|
|
||||||
updateIntervalMs?: number;
|
updateIntervalMs?: number;
|
||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
}
|
}
|
||||||
@@ -546,15 +537,6 @@ export interface ResolvedConfig {
|
|||||||
};
|
};
|
||||||
discordPresence: {
|
discordPresence: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
clientId: string;
|
|
||||||
detailsTemplate: string;
|
|
||||||
stateTemplate: string;
|
|
||||||
largeImageKey: string;
|
|
||||||
largeImageText: string;
|
|
||||||
smallImageKey: string;
|
|
||||||
smallImageText: string;
|
|
||||||
buttonLabel: string;
|
|
||||||
buttonUrl: string;
|
|
||||||
updateIntervalMs: number;
|
updateIntervalMs: number;
|
||||||
debounceMs: number;
|
debounceMs: number;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user