feat(discord): add configurable presence style presets

Replace the hardcoded "Mining and crafting (Anki cards)" meme message
with a preset system. New `discordPresence.presenceStyle` option
supports four presets: "default" (clean bilingual), "meme" (the OG
Minecraft joke), "japanese" (fully JP), and "minimal". The default
preset shows "Sentence Mining" with 日本語学習中 as the small image
tooltip. Existing users can set presenceStyle to "meme" to keep the
old behavior.
This commit is contained in:
2026-03-29 15:28:01 -07:00
parent 6648ed1332
commit 33319a102d
9 changed files with 148 additions and 37 deletions

View File

@@ -498,6 +498,7 @@
// ========================================== // ==========================================
"discordPresence": { "discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false "enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
"updateIntervalMs": 3000, // Minimum interval between presence payload updates. "updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence 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.

View File

@@ -1197,30 +1197,38 @@ Discord Rich Presence is optional and disabled by default. When enabled, SubMine
{ {
"discordPresence": { "discordPresence": {
"enabled": true, "enabled": true,
"presenceStyle": "default",
"updateIntervalMs": 3000, "updateIntervalMs": 3000,
"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`) |
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | | `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | | `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
Setup steps: Setup steps:
1. Set `discordPresence.enabled` to `true`. 1. Set `discordPresence.enabled` to `true`.
2. Restart SubMiner. 2. Optionally set `discordPresence.presenceStyle` to choose a card text preset.
3. Restart SubMiner.
SubMiner uses a fixed official activity card style for all users: #### Presence style presets
- Details: current media title while playing (fallback: `Mining and crafting (Anki cards)` when idle/disconnected) While playing media, the **Details** line always shows the current media title and **State** shows `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss`. The preset controls what appears when idle and the tooltip text on images.
- State: `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss` (fallback: `Idle`)
- Large image key/text: `subminer-logo` / `SubMiner` | Preset | Idle details | Small image text | Vibe |
- Small image key/text: `study` / `Sentence Mining` | ------------ | ----------------------------------- | ------------------ | --------------------------------------- |
- No activity button by default | **`default`**| `Sentence Mining` | `日本語学習中` | Clean, bilingual flair |
| `meme` | `Mining and crafting (Anki cards)` | `Sentence Mining` | Minecraft-inspired joke |
| `japanese` | `文の採掘中` | `イマージョン学習` | Fully Japanese |
| `minimal` | `SubMiner` | *(none)* | Bare essentials, no small image overlay |
All presets use the `subminer-logo` large image with `SubMiner` tooltip. No activity button is shown by default.
Troubleshooting: Troubleshooting:

View File

@@ -498,6 +498,7 @@
// ========================================== // ==========================================
"discordPresence": { "discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false "enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
"updateIntervalMs": 3000, // Minimum interval between presence payload updates. "updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence 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.

View File

@@ -129,6 +129,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
}, },
discordPresence: { discordPresence: {
enabled: false, enabled: false,
presenceStyle: 'default' as const,
updateIntervalMs: 3_000, updateIntervalMs: 3_000,
debounceMs: 750, debounceMs: 750,
}, },

View File

@@ -323,6 +323,13 @@ 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.presenceStyle',
kind: 'string',
defaultValue: defaultConfig.discordPresence.presenceStyle,
description:
'Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".',
},
{ {
path: 'discordPresence.updateIntervalMs', path: 'discordPresence.updateIntervalMs',
kind: 'number', kind: 'number',

View File

@@ -10,6 +10,7 @@ import {
const baseConfig = { const baseConfig = {
enabled: true, enabled: true,
presenceStyle: 'default' as const,
updateIntervalMs: 10_000, updateIntervalMs: 10_000,
debounceMs: 200, debounceMs: 200,
} as const; } as const;
@@ -27,24 +28,67 @@ const baseSnapshot: DiscordPresenceSnapshot = {
sessionStartedAtMs: BASE_SESSION_STARTED_AT_MS, sessionStartedAtMs: BASE_SESSION_STARTED_AT_MS,
}; };
test('buildDiscordPresenceActivity maps polished payload fields', () => { test('buildDiscordPresenceActivity maps polished payload fields (default style)', () => {
const payload = buildDiscordPresenceActivity(baseConfig, baseSnapshot); const payload = buildDiscordPresenceActivity(baseConfig, baseSnapshot);
assert.equal(payload.details, 'Sousou no Frieren E01'); assert.equal(payload.details, 'Sousou no Frieren E01');
assert.equal(payload.state, 'Playing 01:35 / 24:10'); 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.smallImageText, '日本語学習中');
assert.equal(payload.buttons, undefined); assert.equal(payload.buttons, undefined);
assert.equal(payload.startTimestamp, Math.floor(BASE_SESSION_STARTED_AT_MS / 1000)); assert.equal(payload.startTimestamp, Math.floor(BASE_SESSION_STARTED_AT_MS / 1000));
}); });
test('buildDiscordPresenceActivity falls back to idle when disconnected', () => { test('buildDiscordPresenceActivity falls back to idle with default style', () => {
const payload = buildDiscordPresenceActivity(baseConfig, { const payload = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot, ...baseSnapshot,
connected: false, connected: false,
mediaPath: null, mediaPath: null,
}); });
assert.equal(payload.state, 'Idle'); assert.equal(payload.state, 'Idle');
assert.equal(payload.details, 'Sentence Mining');
});
test('buildDiscordPresenceActivity uses meme style fallback', () => {
const memeConfig = { ...baseConfig, presenceStyle: 'meme' as const };
const payload = buildDiscordPresenceActivity(memeConfig, {
...baseSnapshot,
connected: false,
mediaPath: null,
});
assert.equal(payload.details, 'Mining and crafting (Anki cards)'); assert.equal(payload.details, 'Mining and crafting (Anki cards)');
assert.equal(payload.smallImageText, 'Sentence Mining');
});
test('buildDiscordPresenceActivity uses japanese style', () => {
const jpConfig = { ...baseConfig, presenceStyle: 'japanese' as const };
const payload = buildDiscordPresenceActivity(jpConfig, {
...baseSnapshot,
connected: false,
mediaPath: null,
});
assert.equal(payload.details, '文の採掘中');
assert.equal(payload.smallImageText, 'イマージョン学習');
});
test('buildDiscordPresenceActivity uses minimal style', () => {
const minConfig = { ...baseConfig, presenceStyle: 'minimal' as const };
const payload = buildDiscordPresenceActivity(minConfig, {
...baseSnapshot,
connected: false,
mediaPath: null,
});
assert.equal(payload.details, 'SubMiner');
assert.equal(payload.smallImageKey, undefined);
assert.equal(payload.smallImageText, undefined);
});
test('buildDiscordPresenceActivity shows media title regardless of style', () => {
for (const presenceStyle of ['default', 'meme', 'japanese', 'minimal'] as const) {
const payload = buildDiscordPresenceActivity({ ...baseConfig, presenceStyle }, baseSnapshot);
assert.equal(payload.details, 'Sousou no Frieren E01');
assert.equal(payload.state, 'Playing 01:35 / 24:10');
}
}); });
test('service deduplicates identical updates and sends changed timeline', async () => { test('service deduplicates identical updates and sends changed timeline', async () => {

View File

@@ -1,3 +1,4 @@
import type { DiscordPresenceStylePreset } from '../../types/integrations';
import type { ResolvedConfig } from '../../types'; import type { ResolvedConfig } from '../../types';
export interface DiscordPresenceSnapshot { export interface DiscordPresenceSnapshot {
@@ -33,15 +34,58 @@ type DiscordClient = {
type TimeoutLike = ReturnType<typeof setTimeout>; type TimeoutLike = ReturnType<typeof setTimeout>;
const DISCORD_PRESENCE_STYLE = { interface PresenceStyleDefinition {
fallbackDetails: 'Mining and crafting (Anki cards)', fallbackDetails: string;
largeImageKey: 'subminer-logo', largeImageKey: string;
largeImageText: 'SubMiner', largeImageText: string;
smallImageKey: 'study', smallImageKey: string;
smallImageText: 'Sentence Mining', smallImageText: string;
buttonLabel: '', buttonLabel: string;
buttonUrl: '', buttonUrl: string;
} as const; }
const PRESENCE_STYLES: Record<DiscordPresenceStylePreset, PresenceStyleDefinition> = {
default: {
fallbackDetails: 'Sentence Mining',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: 'study',
smallImageText: '日本語学習中',
buttonLabel: '',
buttonUrl: '',
},
meme: {
fallbackDetails: 'Mining and crafting (Anki cards)',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: 'study',
smallImageText: 'Sentence Mining',
buttonLabel: '',
buttonUrl: '',
},
japanese: {
fallbackDetails: '文の採掘中',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: 'study',
smallImageText: 'イマージョン学習',
buttonLabel: '',
buttonUrl: '',
},
minimal: {
fallbackDetails: 'SubMiner',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: '',
smallImageText: '',
buttonLabel: '',
buttonUrl: '',
},
};
function resolvePresenceStyle(preset: DiscordPresenceStylePreset | undefined): PresenceStyleDefinition {
return PRESENCE_STYLES[preset ?? 'default'] ?? PRESENCE_STYLES.default;
}
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;
@@ -79,15 +123,16 @@ function formatClock(totalSeconds: number | null | undefined): string {
} }
export function buildDiscordPresenceActivity( export function buildDiscordPresenceActivity(
_config: DiscordPresenceConfig, config: DiscordPresenceConfig,
snapshot: DiscordPresenceSnapshot, snapshot: DiscordPresenceSnapshot,
): DiscordActivityPayload { ): DiscordActivityPayload {
const style = resolvePresenceStyle(config.presenceStyle);
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 details = const details =
snapshot.connected && snapshot.mediaPath snapshot.connected && snapshot.mediaPath
? trimField(title) ? trimField(title)
: DISCORD_PRESENCE_STYLE.fallbackDetails; : style.fallbackDetails;
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`; const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
const state = const state =
snapshot.connected && snapshot.mediaPath snapshot.connected && snapshot.mediaPath
@@ -100,26 +145,26 @@ export function buildDiscordPresenceActivity(
startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000), startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000),
}; };
if (DISCORD_PRESENCE_STYLE.largeImageKey.trim().length > 0) { if (style.largeImageKey.trim().length > 0) {
activity.largeImageKey = DISCORD_PRESENCE_STYLE.largeImageKey.trim(); activity.largeImageKey = style.largeImageKey.trim();
} }
if (DISCORD_PRESENCE_STYLE.largeImageText.trim().length > 0) { if (style.largeImageText.trim().length > 0) {
activity.largeImageText = trimField(DISCORD_PRESENCE_STYLE.largeImageText.trim()); activity.largeImageText = trimField(style.largeImageText.trim());
} }
if (DISCORD_PRESENCE_STYLE.smallImageKey.trim().length > 0) { if (style.smallImageKey.trim().length > 0) {
activity.smallImageKey = DISCORD_PRESENCE_STYLE.smallImageKey.trim(); activity.smallImageKey = style.smallImageKey.trim();
} }
if (DISCORD_PRESENCE_STYLE.smallImageText.trim().length > 0) { if (style.smallImageText.trim().length > 0) {
activity.smallImageText = trimField(DISCORD_PRESENCE_STYLE.smallImageText.trim()); activity.smallImageText = trimField(style.smallImageText.trim());
} }
if ( if (
DISCORD_PRESENCE_STYLE.buttonLabel.trim().length > 0 && style.buttonLabel.trim().length > 0 &&
/^https?:\/\//.test(DISCORD_PRESENCE_STYLE.buttonUrl.trim()) /^https?:\/\//.test(style.buttonUrl.trim())
) { ) {
activity.buttons = [ activity.buttons = [
{ {
label: trimField(DISCORD_PRESENCE_STYLE.buttonLabel.trim(), 32), label: trimField(style.buttonLabel.trim(), 32),
url: DISCORD_PRESENCE_STYLE.buttonUrl.trim(), url: style.buttonUrl.trim(),
}, },
]; ];
} }

View File

@@ -273,6 +273,7 @@ export interface ResolvedConfig {
}; };
discordPresence: { discordPresence: {
enabled: boolean; enabled: boolean;
presenceStyle: import('./integrations').DiscordPresenceStylePreset;
updateIntervalMs: number; updateIntervalMs: number;
debounceMs: number; debounceMs: number;
}; };

View File

@@ -101,8 +101,11 @@ export interface JellyfinConfig {
transcodeVideoCodec?: string; transcodeVideoCodec?: string;
} }
export type DiscordPresenceStylePreset = 'default' | 'meme' | 'japanese' | 'minimal';
export interface DiscordPresenceConfig { export interface DiscordPresenceConfig {
enabled?: boolean; enabled?: boolean;
presenceStyle?: DiscordPresenceStylePreset;
updateIntervalMs?: number; updateIntervalMs?: number;
debounceMs?: number; debounceMs?: number;
} }