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:
2026-02-22 17:25:55 -08:00
parent edfe6640ac
commit f1dc418e2d
17 changed files with 238 additions and 346 deletions

View File

@@ -24,7 +24,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.jellyfin.autoAnnounce, false);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
assert.equal(config.discordPresence.enabled, false);
assert.equal(config.discordPresence.updateIntervalMs, 15_000);
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
assert.equal(config.subtitleStyle.hoverTokenColor, '#c6a0f6');
@@ -248,9 +248,6 @@ test('parses discordPresence fields and warns for invalid types', () => {
`{
"discordPresence": {
"enabled": true,
"clientId": "123456789012345678",
"detailsTemplate": "Watching {title}",
"stateTemplate": "{status}",
"updateIntervalMs": 3000,
"debounceMs": 250
}
@@ -261,7 +258,6 @@ test('parses discordPresence fields and warns for invalid types', () => {
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.discordPresence.enabled, true);
assert.equal(config.discordPresence.clientId, '123456789012345678');
assert.equal(config.discordPresence.updateIntervalMs, 3000);
assert.equal(config.discordPresence.debounceMs, 250);

View File

@@ -101,16 +101,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
},
discordPresence: {
enabled: false,
clientId: '',
detailsTemplate: 'Mining Japanese',
stateTemplate: 'Idle',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: 'study',
smallImageText: 'Sentence Mining',
buttonLabel: '',
buttonUrl: '',
updateIntervalMs: 15_000,
updateIntervalMs: 3_000,
debounceMs: 750,
},
youtubeSubgen: {

View File

@@ -194,60 +194,6 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.discordPresence.enabled,
description: 'Enable optional Discord Rich Presence updates.',
},
{
path: 'discordPresence.clientId',
kind: 'string',
defaultValue: defaultConfig.discordPresence.clientId,
description: 'Discord application client ID used for Rich Presence.',
},
{
path: 'discordPresence.detailsTemplate',
kind: 'string',
defaultValue: defaultConfig.discordPresence.detailsTemplate,
description: 'Details line template for the activity card.',
},
{
path: 'discordPresence.stateTemplate',
kind: 'string',
defaultValue: defaultConfig.discordPresence.stateTemplate,
description: 'State line template for the activity card.',
},
{
path: 'discordPresence.largeImageKey',
kind: 'string',
defaultValue: defaultConfig.discordPresence.largeImageKey,
description: 'Discord asset key for the large activity image.',
},
{
path: 'discordPresence.largeImageText',
kind: 'string',
defaultValue: defaultConfig.discordPresence.largeImageText,
description: 'Hover text for the large activity image.',
},
{
path: 'discordPresence.smallImageKey',
kind: 'string',
defaultValue: defaultConfig.discordPresence.smallImageKey,
description: 'Discord asset key for the small activity image.',
},
{
path: 'discordPresence.smallImageText',
kind: 'string',
defaultValue: defaultConfig.discordPresence.smallImageText,
description: 'Hover text for the small activity image.',
},
{
path: 'discordPresence.buttonLabel',
kind: 'string',
defaultValue: defaultConfig.discordPresence.buttonLabel,
description: 'Optional button label shown on the Discord activity card.',
},
{
path: 'discordPresence.buttonUrl',
kind: 'string',
defaultValue: defaultConfig.discordPresence.buttonUrl,
description: 'Optional button URL shown on the Discord activity card.',
},
{
path: 'discordPresence.updateIntervalMs',
kind: 'number',

View File

@@ -128,7 +128,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'Discord Rich Presence',
description: [
'Optional Discord Rich Presence activity card updates for current playback/study session.',
'Requires a Discord application client ID and uploaded asset keys.',
'Uses official SubMiner Discord app assets for polished card visuals.',
],
key: 'discordPresence',
},

View File

@@ -101,31 +101,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
);
}
const stringKeys = [
'clientId',
'detailsTemplate',
'stateTemplate',
'largeImageKey',
'largeImageText',
'smallImageKey',
'smallImageText',
'buttonLabel',
'buttonUrl',
] as const;
for (const key of stringKeys) {
const value = asString(src.discordPresence[key]);
if (value !== undefined) {
resolved.discordPresence[key] = value;
} else if (src.discordPresence[key] !== undefined) {
warn(
`discordPresence.${key}`,
src.discordPresence[key],
resolved.discordPresence[key],
'Expected string.',
);
}
}
const updateIntervalMs = asNumber(src.discordPresence.updateIntervalMs);
if (updateIntervalMs !== undefined) {
resolved.discordPresence.updateIntervalMs = Math.max(1_000, Math.floor(updateIntervalMs));

View File

@@ -30,15 +30,6 @@ test('discordPresence fields are parsed and clamped', () => {
const { context } = createResolveContext({
discordPresence: {
enabled: true,
clientId: '123456789',
detailsTemplate: 'Watching {title}',
stateTemplate: 'Paused',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner Runtime',
smallImageKey: 'pause',
smallImageText: 'Paused',
buttonLabel: 'Open Repo',
buttonUrl: 'https://github.com/sudacode/SubMiner',
updateIntervalMs: 500,
debounceMs: -100,
},
@@ -47,15 +38,6 @@ test('discordPresence fields are parsed and clamped', () => {
applyIntegrationConfig(context);
assert.equal(context.resolved.discordPresence.enabled, true);
assert.equal(context.resolved.discordPresence.clientId, '123456789');
assert.equal(context.resolved.discordPresence.detailsTemplate, 'Watching {title}');
assert.equal(context.resolved.discordPresence.stateTemplate, 'Paused');
assert.equal(context.resolved.discordPresence.largeImageKey, 'subminer-logo');
assert.equal(context.resolved.discordPresence.largeImageText, 'SubMiner Runtime');
assert.equal(context.resolved.discordPresence.smallImageKey, 'pause');
assert.equal(context.resolved.discordPresence.smallImageText, 'Paused');
assert.equal(context.resolved.discordPresence.buttonLabel, 'Open Repo');
assert.equal(context.resolved.discordPresence.buttonUrl, 'https://github.com/sudacode/SubMiner');
assert.equal(context.resolved.discordPresence.updateIntervalMs, 1000);
assert.equal(context.resolved.discordPresence.debounceMs, 0);
});
@@ -64,7 +46,6 @@ test('discordPresence invalid values warn and keep defaults', () => {
const { context, warnings } = createResolveContext({
discordPresence: {
enabled: 'true' as never,
clientId: 123 as never,
updateIntervalMs: 'fast' as never,
debounceMs: null as never,
},
@@ -73,13 +54,11 @@ test('discordPresence invalid values warn and keep defaults', () => {
applyIntegrationConfig(context);
assert.equal(context.resolved.discordPresence.enabled, false);
assert.equal(context.resolved.discordPresence.clientId, '');
assert.equal(context.resolved.discordPresence.updateIntervalMs, 15_000);
assert.equal(context.resolved.discordPresence.updateIntervalMs, 3_000);
assert.equal(context.resolved.discordPresence.debounceMs, 750);
const warnedPaths = warnings.map((warning) => warning.path);
assert.ok(warnedPaths.includes('discordPresence.enabled'));
assert.ok(warnedPaths.includes('discordPresence.clientId'));
assert.ok(warnedPaths.includes('discordPresence.updateIntervalMs'));
assert.ok(warnedPaths.includes('discordPresence.debounceMs'));
});

View File

@@ -10,15 +10,6 @@ import {
const baseConfig = {
enabled: true,
clientId: '1234',
detailsTemplate: 'Watching {title}',
stateTemplate: '{status}',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: 'study',
smallImageText: 'Sentence Mining',
buttonLabel: 'GitHub',
buttonUrl: 'https://github.com/sudacode/SubMiner',
updateIntervalMs: 10_000,
debounceMs: 200,
} as const;
@@ -27,6 +18,8 @@ const baseSnapshot: DiscordPresenceSnapshot = {
mediaTitle: 'Sousou no Frieren E01',
mediaPath: '/media/Frieren/E01.mkv',
subtitleText: '旅立ち',
currentTimeSec: 95,
mediaDurationSec: 1450,
paused: false,
connected: true,
sessionStartedAtMs: 1_700_000_000_000,
@@ -34,11 +27,11 @@ const baseSnapshot: DiscordPresenceSnapshot = {
test('buildDiscordPresenceActivity maps polished payload fields', () => {
const payload = buildDiscordPresenceActivity(baseConfig, baseSnapshot);
assert.equal(payload.details, 'Watching Sousou no Frieren E01');
assert.equal(payload.state, 'Watching');
assert.equal(payload.details, 'Sousou no Frieren E01');
assert.equal(payload.state, 'Playing 01:35 / 24:10');
assert.equal(payload.largeImageKey, 'subminer-logo');
assert.equal(payload.smallImageKey, 'study');
assert.equal(payload.buttons?.[0]?.label, 'GitHub');
assert.equal(payload.buttons, undefined);
assert.equal(payload.startTimestamp, 1_700_000_000);
});
@@ -49,9 +42,10 @@ test('buildDiscordPresenceActivity falls back to idle when disconnected', () =>
mediaPath: null,
});
assert.equal(payload.state, 'Idle');
assert.equal(payload.details, 'Mining and crafting (Anki cards)');
});
test('service deduplicates identical activity updates and throttles interval', async () => {
test('service deduplicates identical updates and sends changed timeline', async () => {
const sent: DiscordActivityPayload[] = [];
const timers = new Map<number, () => void>();
let timerId = 0;
@@ -90,11 +84,11 @@ test('service deduplicates identical activity updates and throttles interval', a
assert.equal(sent.length, 1);
nowMs += 10_001;
service.publish({ ...baseSnapshot, paused: true });
service.publish({ ...baseSnapshot, paused: true, currentTimeSec: 100 });
timers.get(3)?.();
await Promise.resolve();
assert.equal(sent.length, 2);
assert.equal(sent[1]?.state, 'Paused');
assert.equal(sent[1]?.state, 'Paused 01:40 / 24:10');
});
test('service handles login failure and stop without throwing', async () => {

View File

@@ -4,6 +4,8 @@ export interface DiscordPresenceSnapshot {
mediaTitle: string | null;
mediaPath: string | null;
subtitleText: string;
currentTimeSec?: number | null;
mediaDurationSec?: number | null;
paused: boolean | null;
connected: boolean;
sessionStartedAtMs: number;
@@ -31,6 +33,16 @@ type DiscordClient = {
type TimeoutLike = ReturnType<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 {
if (value.length <= maxLength) return value;
return `${value.slice(0, Math.max(0, maxLength - 1))}`;
@@ -48,37 +60,39 @@ function basename(filePath: string | null): string {
return parts[parts.length - 1] ?? '';
}
function interpolate(template: string, values: Record<string, string>): string {
return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key: string) => values[key] ?? '');
}
function buildStatus(snapshot: DiscordPresenceSnapshot): string {
if (!snapshot.connected || !snapshot.mediaPath) return 'Idle';
if (snapshot.paused) return 'Paused';
return 'Watching';
return 'Playing';
}
function formatClock(totalSeconds: number | null | undefined): string {
if (!Number.isFinite(totalSeconds) || (totalSeconds ?? -1) < 0) return '--:--';
const rounded = Math.floor(totalSeconds as number);
const hours = Math.floor(rounded / 3600);
const minutes = Math.floor((rounded % 3600) / 60);
const seconds = rounded % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
export function buildDiscordPresenceActivity(
config: DiscordPresenceConfig,
_config: DiscordPresenceConfig,
snapshot: DiscordPresenceSnapshot,
): DiscordActivityPayload {
const status = buildStatus(snapshot);
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
const subtitle = sanitizeText(snapshot.subtitleText, '');
const file = sanitizeText(basename(snapshot.mediaPath), '');
const values = {
title,
file,
subtitle,
status,
};
const details = trimField(
interpolate(config.detailsTemplate, values).trim() || `Watching ${title}`,
);
const state = trimField(
interpolate(config.stateTemplate, values).trim() || `${status} with SubMiner`,
);
const details =
snapshot.connected && snapshot.mediaPath
? trimField(title)
: DISCORD_PRESENCE_STYLE.fallbackDetails;
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
const state =
snapshot.connected && snapshot.mediaPath
? trimField(`${status} ${timeline}`)
: trimField(status);
const activity: DiscordActivityPayload = {
details,
@@ -86,21 +100,27 @@ export function buildDiscordPresenceActivity(
startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000),
};
if (config.largeImageKey.trim().length > 0) {
activity.largeImageKey = config.largeImageKey.trim();
if (DISCORD_PRESENCE_STYLE.largeImageKey.trim().length > 0) {
activity.largeImageKey = DISCORD_PRESENCE_STYLE.largeImageKey.trim();
}
if (config.largeImageText.trim().length > 0) {
activity.largeImageText = trimField(config.largeImageText.trim());
if (DISCORD_PRESENCE_STYLE.largeImageText.trim().length > 0) {
activity.largeImageText = trimField(DISCORD_PRESENCE_STYLE.largeImageText.trim());
}
if (config.smallImageKey.trim().length > 0) {
activity.smallImageKey = config.smallImageKey.trim();
if (DISCORD_PRESENCE_STYLE.smallImageKey.trim().length > 0) {
activity.smallImageKey = DISCORD_PRESENCE_STYLE.smallImageKey.trim();
}
if (config.smallImageText.trim().length > 0) {
activity.smallImageText = trimField(config.smallImageText.trim());
if (DISCORD_PRESENCE_STYLE.smallImageText.trim().length > 0) {
activity.smallImageText = trimField(DISCORD_PRESENCE_STYLE.smallImageText.trim());
}
if (config.buttonLabel.trim().length > 0 && /^https?:\/\//.test(config.buttonUrl.trim())) {
if (
DISCORD_PRESENCE_STYLE.buttonLabel.trim().length > 0 &&
/^https?:\/\//.test(DISCORD_PRESENCE_STYLE.buttonUrl.trim())
) {
activity.buttons = [
{ label: trimField(config.buttonLabel.trim(), 32), url: config.buttonUrl.trim() },
{
label: trimField(DISCORD_PRESENCE_STYLE.buttonLabel.trim(), 32),
url: DISCORD_PRESENCE_STYLE.buttonUrl.trim(),
},
];
}
@@ -109,7 +129,7 @@ export function buildDiscordPresenceActivity(
export function createDiscordPresenceService(deps: {
config: DiscordPresenceConfig;
createClient: (clientId: string) => DiscordClient;
createClient: () => DiscordClient;
now?: () => number;
setTimeoutFn?: (callback: () => void, delayMs: number) => TimeoutLike;
clearTimeoutFn?: (timer: TimeoutLike) => void;
@@ -166,13 +186,8 @@ export function createDiscordPresenceService(deps: {
return {
async start(): Promise<void> {
if (!deps.config.enabled) return;
const clientId = deps.config.clientId.trim();
if (!clientId) {
logDebug('[discord-presence] enabled but clientId missing; skipping start');
return;
}
try {
client = deps.createClient(clientId);
client = deps.createClient();
await client.login();
} catch (error) {
client = null;

View File

@@ -427,6 +427,7 @@ let jellyfinPlayQuitOnDisconnectArmed = false;
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
const MPV_JELLYFIN_DEFAULT_ARGS = [
@@ -585,19 +586,38 @@ const appState = createAppState({
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
});
const discordPresenceSessionStartedAtMs = Date.now();
let discordPresenceMediaDurationSec: number | null = null;
function refreshDiscordPresenceMediaDuration(): void {
const client = appState.mpvClient;
if (!client || !client.connected) return;
void client
.requestProperty('duration')
.then((value) => {
const numeric = Number(value);
discordPresenceMediaDurationSec = Number.isFinite(numeric) && numeric > 0 ? numeric : null;
})
.catch(() => {
discordPresenceMediaDurationSec = null;
});
}
function publishDiscordPresence(): void {
refreshDiscordPresenceMediaDuration();
appState.discordPresenceService?.publish({
mediaTitle: appState.currentMediaTitle,
mediaPath: appState.currentMediaPath,
subtitleText: appState.currentSubText,
currentTimeSec: appState.mpvClient?.currentTimePos ?? null,
mediaDurationSec:
discordPresenceMediaDurationSec ?? anilistMediaGuessRuntimeState.mediaDurationSec,
paused: appState.playbackPaused,
connected: Boolean(appState.mpvClient?.connected),
sessionStartedAtMs: discordPresenceSessionStartedAtMs,
});
}
function createDiscordRpcClient(clientId: string) {
function createDiscordRpcClient() {
const discordRpc = require('discord-rpc') as {
Client: new (opts: { transport: 'ipc' }) => {
login: (opts: { clientId: string }) => Promise<void>;
@@ -609,7 +629,7 @@ function createDiscordRpcClient(clientId: string) {
const client = new discordRpc.Client({ transport: 'ipc' });
return {
login: () => client.login({ clientId }),
login: () => client.login({ clientId: DISCORD_PRESENCE_APP_ID }),
setActivity: (activity: unknown) =>
client.setActivity(activity as unknown as Record<string, unknown>),
clearActivity: () => client.clearActivity(),
@@ -620,7 +640,7 @@ function createDiscordRpcClient(clientId: string) {
async function initializeDiscordPresenceService(): Promise<void> {
appState.discordPresenceService = createDiscordPresenceService({
config: getResolvedConfig().discordPresence,
createClient: (clientId) => createDiscordRpcClient(clientId),
createClient: () => createDiscordRpcClient(),
logDebug: (message, meta) => logger.debug(message, meta),
});
await appState.discordPresenceService.start();

View File

@@ -360,15 +360,6 @@ export interface JellyfinConfig {
export interface DiscordPresenceConfig {
enabled?: boolean;
clientId?: string;
detailsTemplate?: string;
stateTemplate?: string;
largeImageKey?: string;
largeImageText?: string;
smallImageKey?: string;
smallImageText?: string;
buttonLabel?: string;
buttonUrl?: string;
updateIntervalMs?: number;
debounceMs?: number;
}
@@ -546,15 +537,6 @@ export interface ResolvedConfig {
};
discordPresence: {
enabled: boolean;
clientId: string;
detailsTemplate: string;
stateTemplate: string;
largeImageKey: string;
largeImageText: string;
smallImageKey: string;
smallImageText: string;
buttonLabel: string;
buttonUrl: string;
updateIntervalMs: number;
debounceMs: number;
};