mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -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:
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user