mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
feat(core): add Discord presence service and extract Jellyfin runtime composition
Introduce Discord presence runtime support and continue composition-root decomposition by moving Jellyfin wiring into dedicated composer modules. This keeps main runtime orchestration thinner while preserving behavior and test coverage across config, runtime, and docs updates.
This commit is contained in:
119
src/core/services/discord-presence.test.ts
Normal file
119
src/core/services/discord-presence.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildDiscordPresenceActivity,
|
||||
createDiscordPresenceService,
|
||||
type DiscordActivityPayload,
|
||||
type DiscordPresenceSnapshot,
|
||||
} from './discord-presence';
|
||||
|
||||
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;
|
||||
|
||||
const baseSnapshot: DiscordPresenceSnapshot = {
|
||||
mediaTitle: 'Sousou no Frieren E01',
|
||||
mediaPath: '/media/Frieren/E01.mkv',
|
||||
subtitleText: '旅立ち',
|
||||
paused: false,
|
||||
connected: true,
|
||||
sessionStartedAtMs: 1_700_000_000_000,
|
||||
};
|
||||
|
||||
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.largeImageKey, 'subminer-logo');
|
||||
assert.equal(payload.smallImageKey, 'study');
|
||||
assert.equal(payload.buttons?.[0]?.label, 'GitHub');
|
||||
assert.equal(payload.startTimestamp, 1_700_000_000);
|
||||
});
|
||||
|
||||
test('buildDiscordPresenceActivity falls back to idle when disconnected', () => {
|
||||
const payload = buildDiscordPresenceActivity(baseConfig, {
|
||||
...baseSnapshot,
|
||||
connected: false,
|
||||
mediaPath: null,
|
||||
});
|
||||
assert.equal(payload.state, 'Idle');
|
||||
});
|
||||
|
||||
test('service deduplicates identical activity updates and throttles interval', async () => {
|
||||
const sent: DiscordActivityPayload[] = [];
|
||||
const timers = new Map<number, () => void>();
|
||||
let timerId = 0;
|
||||
let nowMs = 100_000;
|
||||
|
||||
const service = createDiscordPresenceService({
|
||||
config: baseConfig,
|
||||
createClient: () => ({
|
||||
login: async () => {},
|
||||
setActivity: async (activity) => {
|
||||
sent.push(activity);
|
||||
},
|
||||
clearActivity: async () => {},
|
||||
destroy: () => {},
|
||||
}),
|
||||
now: () => nowMs,
|
||||
setTimeoutFn: (callback) => {
|
||||
const id = ++timerId;
|
||||
timers.set(id, callback);
|
||||
return id as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearTimeoutFn: (id) => {
|
||||
timers.delete(id as unknown as number);
|
||||
},
|
||||
});
|
||||
|
||||
await service.start();
|
||||
service.publish(baseSnapshot);
|
||||
timers.get(1)?.();
|
||||
await Promise.resolve();
|
||||
assert.equal(sent.length, 1);
|
||||
|
||||
service.publish(baseSnapshot);
|
||||
timers.get(2)?.();
|
||||
await Promise.resolve();
|
||||
assert.equal(sent.length, 1);
|
||||
|
||||
nowMs += 10_001;
|
||||
service.publish({ ...baseSnapshot, paused: true });
|
||||
timers.get(3)?.();
|
||||
await Promise.resolve();
|
||||
assert.equal(sent.length, 2);
|
||||
assert.equal(sent[1]?.state, 'Paused');
|
||||
});
|
||||
|
||||
test('service handles login failure and stop without throwing', async () => {
|
||||
let destroyed = false;
|
||||
const service = createDiscordPresenceService({
|
||||
config: baseConfig,
|
||||
createClient: () => ({
|
||||
login: async () => {
|
||||
throw new Error('discord not running');
|
||||
},
|
||||
setActivity: async () => {},
|
||||
clearActivity: async () => {},
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.doesNotReject(async () => service.start());
|
||||
await assert.doesNotReject(async () => service.stop());
|
||||
assert.equal(destroyed, false);
|
||||
});
|
||||
208
src/core/services/discord-presence.ts
Normal file
208
src/core/services/discord-presence.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
|
||||
export interface DiscordPresenceSnapshot {
|
||||
mediaTitle: string | null;
|
||||
mediaPath: string | null;
|
||||
subtitleText: string;
|
||||
paused: boolean | null;
|
||||
connected: boolean;
|
||||
sessionStartedAtMs: number;
|
||||
}
|
||||
|
||||
type DiscordPresenceConfig = ResolvedConfig['discordPresence'];
|
||||
|
||||
export interface DiscordActivityPayload {
|
||||
details: string;
|
||||
state: string;
|
||||
startTimestamp: number;
|
||||
largeImageKey?: string;
|
||||
largeImageText?: string;
|
||||
smallImageKey?: string;
|
||||
smallImageText?: string;
|
||||
buttons?: Array<{ label: string; url: string }>;
|
||||
}
|
||||
|
||||
type DiscordClient = {
|
||||
login: () => Promise<void>;
|
||||
setActivity: (activity: DiscordActivityPayload) => Promise<void>;
|
||||
clearActivity: () => Promise<void>;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
type TimeoutLike = ReturnType<typeof setTimeout>;
|
||||
|
||||
function trimField(value: string, maxLength = 128): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
return `${value.slice(0, Math.max(0, maxLength - 1))}…`;
|
||||
}
|
||||
|
||||
function sanitizeText(value: string | null | undefined, fallback: string): string {
|
||||
const text = value?.trim();
|
||||
if (!text) return fallback;
|
||||
return text;
|
||||
}
|
||||
|
||||
function basename(filePath: string | null): string {
|
||||
if (!filePath) return '';
|
||||
const parts = filePath.split(/[\\/]/);
|
||||
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';
|
||||
}
|
||||
|
||||
export function buildDiscordPresenceActivity(
|
||||
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 activity: DiscordActivityPayload = {
|
||||
details,
|
||||
state,
|
||||
startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000),
|
||||
};
|
||||
|
||||
if (config.largeImageKey.trim().length > 0) {
|
||||
activity.largeImageKey = config.largeImageKey.trim();
|
||||
}
|
||||
if (config.largeImageText.trim().length > 0) {
|
||||
activity.largeImageText = trimField(config.largeImageText.trim());
|
||||
}
|
||||
if (config.smallImageKey.trim().length > 0) {
|
||||
activity.smallImageKey = config.smallImageKey.trim();
|
||||
}
|
||||
if (config.smallImageText.trim().length > 0) {
|
||||
activity.smallImageText = trimField(config.smallImageText.trim());
|
||||
}
|
||||
if (config.buttonLabel.trim().length > 0 && /^https?:\/\//.test(config.buttonUrl.trim())) {
|
||||
activity.buttons = [
|
||||
{ label: trimField(config.buttonLabel.trim(), 32), url: config.buttonUrl.trim() },
|
||||
];
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
export function createDiscordPresenceService(deps: {
|
||||
config: DiscordPresenceConfig;
|
||||
createClient: (clientId: string) => DiscordClient;
|
||||
now?: () => number;
|
||||
setTimeoutFn?: (callback: () => void, delayMs: number) => TimeoutLike;
|
||||
clearTimeoutFn?: (timer: TimeoutLike) => void;
|
||||
logDebug?: (message: string, meta?: unknown) => void;
|
||||
}) {
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const setTimeoutFn = deps.setTimeoutFn ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
||||
const clearTimeoutFn = deps.clearTimeoutFn ?? ((timer) => clearTimeout(timer));
|
||||
const logDebug = deps.logDebug ?? (() => {});
|
||||
|
||||
let client: DiscordClient | null = null;
|
||||
let pendingSnapshot: DiscordPresenceSnapshot | null = null;
|
||||
let debounceTimer: TimeoutLike | null = null;
|
||||
let intervalTimer: TimeoutLike | null = null;
|
||||
let lastActivityKey = '';
|
||||
let lastSentAtMs = 0;
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
if (!client || !pendingSnapshot) return;
|
||||
const elapsed = now() - lastSentAtMs;
|
||||
if (elapsed < deps.config.updateIntervalMs) {
|
||||
const delay = Math.max(0, deps.config.updateIntervalMs - elapsed);
|
||||
if (intervalTimer) clearTimeoutFn(intervalTimer);
|
||||
intervalTimer = setTimeoutFn(() => {
|
||||
void flush();
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildDiscordPresenceActivity(deps.config, pendingSnapshot);
|
||||
const activityKey = JSON.stringify(payload);
|
||||
if (activityKey === lastActivityKey) return;
|
||||
|
||||
try {
|
||||
await client.setActivity(payload);
|
||||
lastSentAtMs = now();
|
||||
lastActivityKey = activityKey;
|
||||
} catch (error) {
|
||||
logDebug('[discord-presence] failed to set activity', error);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(snapshot: DiscordPresenceSnapshot): void {
|
||||
pendingSnapshot = snapshot;
|
||||
if (debounceTimer) {
|
||||
clearTimeoutFn(debounceTimer);
|
||||
}
|
||||
debounceTimer = setTimeoutFn(() => {
|
||||
debounceTimer = null;
|
||||
void flush();
|
||||
}, deps.config.debounceMs);
|
||||
}
|
||||
|
||||
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);
|
||||
await client.login();
|
||||
} catch (error) {
|
||||
client = null;
|
||||
logDebug('[discord-presence] login failed', error);
|
||||
}
|
||||
},
|
||||
publish(snapshot: DiscordPresenceSnapshot): void {
|
||||
if (!client) return;
|
||||
scheduleFlush(snapshot);
|
||||
},
|
||||
async stop(): Promise<void> {
|
||||
if (debounceTimer) {
|
||||
clearTimeoutFn(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
if (intervalTimer) {
|
||||
clearTimeoutFn(intervalTimer);
|
||||
intervalTimer = null;
|
||||
}
|
||||
pendingSnapshot = null;
|
||||
lastActivityKey = '';
|
||||
lastSentAtMs = 0;
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.clearActivity();
|
||||
} catch (error) {
|
||||
logDebug('[discord-presence] clear activity failed', error);
|
||||
}
|
||||
client.destroy();
|
||||
client = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -48,7 +48,7 @@ test('createFieldGroupingOverlayRuntime callback cancels when send fails', async
|
||||
setVisibleOverlayVisible: () => {},
|
||||
setInvisibleOverlayVisible: () => {},
|
||||
getResolver: () => resolver,
|
||||
setResolver: (next) => {
|
||||
setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => {
|
||||
resolver = next;
|
||||
},
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(),
|
||||
@@ -78,3 +78,64 @@ test('createFieldGroupingOverlayRuntime callback cancels when send fails', async
|
||||
assert.equal(result.keepNoteId, 0);
|
||||
assert.equal(result.deleteNoteId, 0);
|
||||
});
|
||||
|
||||
test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay after resolver settles', async () => {
|
||||
let resolver: unknown = null;
|
||||
let visible = false;
|
||||
const visibilityTransitions: boolean[] = [];
|
||||
|
||||
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => visible,
|
||||
getInvisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: (nextVisible) => {
|
||||
visible = nextVisible;
|
||||
visibilityTransitions.push(nextVisible);
|
||||
},
|
||||
setInvisibleOverlayVisible: () => {},
|
||||
getResolver: () => resolver as ((choice: KikuFieldGroupingChoice) => void) | null,
|
||||
setResolver: (nextResolver: ((choice: KikuFieldGroupingChoice) => void) | null) => {
|
||||
resolver = nextResolver;
|
||||
},
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(),
|
||||
sendToVisibleOverlay: () => true,
|
||||
});
|
||||
|
||||
const callback = runtime.createFieldGroupingCallback();
|
||||
const pendingChoice = callback({
|
||||
original: {
|
||||
noteId: 1,
|
||||
expression: 'a',
|
||||
sentencePreview: 'a',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: true,
|
||||
},
|
||||
duplicate: {
|
||||
noteId: 2,
|
||||
expression: 'b',
|
||||
sentencePreview: 'b',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: false,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(visible, true);
|
||||
assert.ok(resolver);
|
||||
|
||||
if (typeof resolver !== 'function') {
|
||||
throw new Error('expected field grouping resolver to be assigned');
|
||||
}
|
||||
|
||||
(resolver as (choice: KikuFieldGroupingChoice) => void)({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: true,
|
||||
cancelled: false,
|
||||
});
|
||||
await pendingChoice;
|
||||
|
||||
assert.equal(visible, false);
|
||||
assert.deepEqual(visibilityTransitions, [true, false]);
|
||||
});
|
||||
|
||||
@@ -42,7 +42,12 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
runtimeOptions?: { restoreOnModalClose?: T },
|
||||
): boolean => {
|
||||
if (options.sendToVisibleOverlay) {
|
||||
return options.sendToVisibleOverlay(channel, payload, runtimeOptions);
|
||||
const wasVisible = options.getVisibleOverlayVisible();
|
||||
const sent = options.sendToVisibleOverlay(channel, payload, runtimeOptions);
|
||||
if (sent && !wasVisible && !options.getVisibleOverlayVisible()) {
|
||||
options.setVisibleOverlayVisible(true);
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
return sendToVisibleOverlayRuntime({
|
||||
mainWindow: options.getMainWindow() as never,
|
||||
|
||||
@@ -109,3 +109,4 @@ export {
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
} from './overlay-manager';
|
||||
export { createConfigHotReloadRuntime, classifyConfigHotReloadDiff } from './config-hot-reload';
|
||||
export { createDiscordPresenceService, buildDiscordPresenceActivity } from './discord-presence';
|
||||
|
||||
Reference in New Issue
Block a user