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:
2026-02-22 14:53:10 -08:00
parent 43a8a37f5b
commit edfe6640ac
52 changed files with 2222 additions and 317 deletions

View File

@@ -1,18 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { NoteUpdateWorkflow } from './note-update-workflow';
type NoteInfo = {
noteId: number;
fields: Record<string, { value: string }>;
};
import {
NoteUpdateWorkflow,
type NoteUpdateWorkflowDeps,
type NoteUpdateWorkflowNoteInfo,
} from './note-update-workflow';
function createWorkflowHarness() {
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const notifications: Array<{ noteId: number; label: string | number }> = [];
const warnings: string[] = [];
const deps = {
const deps: NoteUpdateWorkflowDeps = {
client: {
notesInfo: async (_noteIds: number[]) =>
[
@@ -23,7 +22,7 @@ function createWorkflowHarness() {
Sentence: { value: '' },
},
},
] satisfies NoteInfo[],
] satisfies NoteUpdateWorkflowNoteInfo[],
updateNoteFields: async (noteId: number, fields: Record<string, string>) => {
updates.push({ noteId, fields });
},
@@ -43,7 +42,7 @@ function createWorkflowHarness() {
kikuEnabled: false,
kikuFieldGrouping: 'disabled' as const,
}),
appendKnownWordsFromNoteInfo: (_noteInfo: NoteInfo) => undefined,
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
extractFields: (fields: Record<string, { value: string }>) => {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(fields)) {
@@ -51,17 +50,27 @@ function createWorkflowHarness() {
}
return out;
},
findDuplicateNote: async () => null,
handleFieldGroupingAuto: async () => undefined,
handleFieldGroupingManual: async () => false,
processSentence: (text: string) => text,
resolveConfiguredFieldName: (noteInfo: NoteInfo, preferred?: string) => {
findDuplicateNote: async (_expression, _excludeNoteId, _noteInfo) => null,
handleFieldGroupingAuto: async (
_originalNoteId,
_newNoteId,
_newNoteInfo,
_expression,
) => undefined,
handleFieldGroupingManual: async (
_originalNoteId,
_newNoteId,
_newNoteInfo,
_expression,
) => false,
processSentence: (text: string, _noteFields: Record<string, string>) => text,
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
if (!preferred) return null;
const names = Object.keys(noteInfo.fields);
return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
},
getResolvedSentenceAudioFieldName: () => null,
mergeFieldValue: (_existing: string, next: string) => next,
mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next,
generateAudioFilename: () => 'audio_1.mp3',
generateAudio: async () => null,
generateImageFilename: () => 'image_1.jpg',
@@ -74,7 +83,7 @@ function createWorkflowHarness() {
showOsdNotification: (_text: string) => undefined,
beginUpdateProgress: (_text: string) => undefined,
endUpdateProgress: () => undefined,
logWarn: (message: string) => warnings.push(message),
logWarn: (message: string, ..._args: unknown[]) => warnings.push(message),
logInfo: (_message: string) => undefined,
logError: (_message: string) => undefined,
};
@@ -109,3 +118,56 @@ test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
assert.equal(harness.notifications.length, 0);
assert.equal(harness.warnings.length, 1);
});
test('NoteUpdateWorkflow updates note before auto field grouping merge', async () => {
const harness = createWorkflowHarness();
const callOrder: string[] = [];
let notesInfoCallCount = 0;
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
kikuEnabled: true,
kikuFieldGrouping: 'auto',
});
harness.deps.findDuplicateNote = async () => 99;
harness.deps.client.notesInfo = async () => {
notesInfoCallCount += 1;
if (notesInfoCallCount === 1) {
return [
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
}
return [
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: 'subtitle-text' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
};
harness.deps.client.updateNoteFields = async (noteId, fields) => {
callOrder.push('update');
harness.updates.push({ noteId, fields });
};
harness.deps.handleFieldGroupingAuto = async (
_originalNoteId,
_newNoteId,
newNoteInfo,
_expression,
) => {
callOrder.push('auto');
assert.equal(newNoteInfo.fields.Sentence?.value, 'subtitle-text');
};
await harness.workflow.execute(42);
assert.deepEqual(callOrder, ['update', 'auto']);
assert.equal(harness.updates.length, 1);
});

View File

@@ -98,34 +98,13 @@ export class NoteUpdateWorkflow {
}
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
if (
const shouldRunFieldGrouping =
!options?.skipKikuFieldGrouping &&
sentenceCardConfig.kikuEnabled &&
sentenceCardConfig.kikuFieldGrouping !== 'disabled'
) {
const duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo);
if (duplicateNoteId !== null) {
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
await this.deps.handleFieldGroupingAuto(
duplicateNoteId,
noteId,
noteInfo,
expressionText,
);
return;
}
if (sentenceCardConfig.kikuFieldGrouping === 'manual') {
const handled = await this.deps.handleFieldGroupingManual(
duplicateNoteId,
noteId,
noteInfo,
expressionText,
);
if (handled) {
return;
}
}
}
sentenceCardConfig.kikuFieldGrouping !== 'disabled';
let duplicateNoteId: number | null = null;
if (shouldRunFieldGrouping) {
duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo);
}
const updatedFields: Record<string, string> = {};
@@ -219,6 +198,37 @@ export class NoteUpdateWorkflow {
this.deps.logInfo('Updated card fields for:', expressionText);
await this.deps.showNotification(noteId, expressionText);
}
if (shouldRunFieldGrouping && duplicateNoteId !== null) {
let noteInfoForGrouping = noteInfo;
if (updatePerformed) {
const refreshedInfoResult = await this.deps.client.notesInfo([noteId]);
const refreshedInfo = refreshedInfoResult as NoteUpdateWorkflowNoteInfo[];
if (!refreshedInfo || refreshedInfo.length === 0) {
this.deps.logWarn('Card not found after update:', noteId);
return;
}
noteInfoForGrouping = refreshedInfo[0]!;
}
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
await this.deps.handleFieldGroupingAuto(
duplicateNoteId,
noteId,
noteInfoForGrouping,
expressionText,
);
return;
}
if (sentenceCardConfig.kikuFieldGrouping === 'manual') {
await this.deps.handleFieldGroupingManual(
duplicateNoteId,
noteId,
noteInfoForGrouping,
expressionText,
);
}
}
} catch (error) {
if ((error as Error).message.includes('note was not found')) {
this.deps.logWarn('Card was deleted before update:', noteId);

View File

@@ -23,6 +23,8 @@ test('loads defaults when config is missing', () => {
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
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.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
assert.equal(config.subtitleStyle.hoverTokenColor, '#c6a0f6');
@@ -239,6 +241,35 @@ test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', (
);
});
test('parses discordPresence fields and warns for invalid types', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"discordPresence": {
"enabled": true,
"clientId": "123456789012345678",
"detailsTemplate": "Watching {title}",
"stateTemplate": "{status}",
"updateIntervalMs": 3000,
"debounceMs": 250
}
}`,
'utf-8',
);
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);
service.patchRawConfig({ discordPresence: { enabled: 'yes' as never } });
assert.equal(service.getConfig().discordPresence.enabled, DEFAULT_CONFIG.discordPresence.enabled);
assert.ok(service.getWarnings().some((warning) => warning.path === 'discordPresence.enabled'));
});
test('accepts immersion tracking config values', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -1062,6 +1093,7 @@ test('template generator includes known keys', () => {
assert.match(output, /"ankiConnect":/);
assert.match(output, /"logging":/);
assert.match(output, /"websocket":/);
assert.match(output, /"discordPresence":/);
assert.match(output, /"youtubeSubgen":/);
assert.match(output, /"preserveLineBreaks": false/);
assert.match(output, /"nPlusOne"\s*:\s*\{/);

View File

@@ -31,7 +31,8 @@ const {
bind_visible_overlay_to_mpv_sub_visibility,
invisibleOverlay,
} = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, jellyfin, youtubeSubgen } = INTEGRATIONS_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, youtubeSubgen } =
INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
@@ -51,6 +52,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
jimaku,
anilist,
jellyfin,
discordPresence,
youtubeSubgen,
invisibleOverlay,
immersionTracking,

View File

@@ -2,7 +2,7 @@ import { ResolvedConfig } from '../../types';
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig,
'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'youtubeSubgen'
'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'youtubeSubgen'
> = {
ankiConnect: {
enabled: false,
@@ -99,6 +99,20 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
directPlayContainers: ['mkv', 'mp4', 'webm', 'mov', 'flac', 'mp3', 'aac'],
transcodeVideoCodec: 'h264',
},
discordPresence: {
enabled: false,
clientId: '',
detailsTemplate: 'Mining Japanese',
stateTemplate: 'Idle',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: 'study',
smallImageText: 'Sentence Mining',
buttonLabel: '',
buttonUrl: '',
updateIntervalMs: 15_000,
debounceMs: 750,
},
youtubeSubgen: {
mode: 'automatic',
whisperBin: '',

View File

@@ -188,6 +188,78 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.jellyfin.transcodeVideoCodec,
description: 'Preferred transcode video codec when direct play is unavailable.',
},
{
path: 'discordPresence.enabled',
kind: 'boolean',
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',
defaultValue: defaultConfig.discordPresence.updateIntervalMs,
description: 'Minimum interval between presence payload updates.',
},
{
path: 'discordPresence.debounceMs',
kind: 'number',
defaultValue: defaultConfig.discordPresence.debounceMs,
description: 'Debounce delay used to collapse bursty presence updates.',
},
{
path: 'youtubeSubgen.mode',
kind: 'enum',

View File

@@ -124,6 +124,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
],
key: 'jellyfin',
},
{
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.',
],
key: 'discordPresence',
},
];
const IMMERSION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [

View File

@@ -1,5 +1,5 @@
import { ResolveContext } from './context';
import { asBoolean, asString, isObject } from './shared';
import { asBoolean, asNumber, asString, isObject } from './shared';
export function applyIntegrationConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
@@ -87,4 +87,67 @@ export function applyIntegrationConfig(context: ResolveContext): void {
);
}
}
if (isObject(src.discordPresence)) {
const enabled = asBoolean(src.discordPresence.enabled);
if (enabled !== undefined) {
resolved.discordPresence.enabled = enabled;
} else if (src.discordPresence.enabled !== undefined) {
warn(
'discordPresence.enabled',
src.discordPresence.enabled,
resolved.discordPresence.enabled,
'Expected boolean.',
);
}
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));
} else if (src.discordPresence.updateIntervalMs !== undefined) {
warn(
'discordPresence.updateIntervalMs',
src.discordPresence.updateIntervalMs,
resolved.discordPresence.updateIntervalMs,
'Expected number.',
);
}
const debounceMs = asNumber(src.discordPresence.debounceMs);
if (debounceMs !== undefined) {
resolved.discordPresence.debounceMs = Math.max(0, Math.floor(debounceMs));
} else if (src.discordPresence.debounceMs !== undefined) {
warn(
'discordPresence.debounceMs',
src.discordPresence.debounceMs,
resolved.discordPresence.debounceMs,
'Expected number.',
);
}
}
}

View File

@@ -17,7 +17,7 @@ test('jellyfin directPlayContainers are normalized', () => {
test('jellyfin legacy auth keys are ignored by resolver', () => {
const { context } = createResolveContext({
jellyfin: ({ accessToken: 'legacy-token', userId: 'legacy-user' } as unknown) as never,
jellyfin: { accessToken: 'legacy-token', userId: 'legacy-user' } as unknown as never,
});
applyIntegrationConfig(context);
@@ -25,3 +25,61 @@ test('jellyfin legacy auth keys are ignored by resolver', () => {
assert.equal('accessToken' in (context.resolved.jellyfin as Record<string, unknown>), false);
assert.equal('userId' in (context.resolved.jellyfin as Record<string, unknown>), false);
});
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,
},
});
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);
});
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,
},
});
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.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

@@ -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);
});

View 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;
},
};
}

View File

@@ -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]);
});

View File

@@ -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,

View File

@@ -109,3 +109,4 @@ export {
setOverlayDebugVisualizationEnabledRuntime,
} from './overlay-manager';
export { createConfigHotReloadRuntime, classifyConfigHotReloadDiff } from './config-hot-reload';
export { createDiscordPresenceService, buildDiscordPresenceActivity } from './discord-presence';

View File

@@ -29,12 +29,13 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'),
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
cleanup();
assert.equal(calls.length, 20);
assert.equal(calls.length, 21);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-jellyfin-remote');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
});

View File

@@ -19,6 +19,7 @@ export function createOnWillQuitCleanupHandler(deps: {
destroyJellyfinSetupWindow: () => void;
clearJellyfinSetupWindow: () => void;
stopJellyfinRemoteSession: () => void;
stopDiscordPresenceService: () => void;
}) {
return (): void => {
deps.destroyTray();
@@ -41,6 +42,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.destroyJellyfinSetupWindow();
deps.clearJellyfinSetupWindow();
deps.stopJellyfinRemoteSession();
deps.stopDiscordPresenceService();
};
}

View File

@@ -47,6 +47,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
@@ -60,6 +61,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('destroy-immersion'));
assert.ok(calls.includes('clear-immersion-ref'));
assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('stop-discord-presence'));
assert.equal(reconnectTimer, null);
assert.equal(immersionTracker, null);
});
@@ -92,6 +94,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
stopJellyfinRemoteSession: () => {},
stopDiscordPresenceService: () => {},
});
const cleanup = createOnWillQuitCleanupHandler(depsFactory());

View File

@@ -45,6 +45,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearJellyfinSetupWindow: () => void;
stopJellyfinRemoteSession: () => void;
stopDiscordPresenceService: () => void;
}) {
return () => ({
destroyTray: () => deps.destroyTray(),
@@ -96,5 +97,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
},
clearJellyfinSetupWindow: () => deps.clearJellyfinSetupWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
});
}

View File

@@ -4,6 +4,7 @@ export * from './app-ready-composer';
export * from './contracts';
export * from './ipc-runtime-composer';
export * from './jellyfin-remote-composer';
export * from './jellyfin-runtime-composer';
export * from './mpv-runtime-composer';
export * from './shortcuts-runtime-composer';
export * from './startup-lifecycle-composer';

View File

@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeJellyfinRuntimeHandlers } from './jellyfin-runtime-composer';
test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers', () => {
let activePlayback: unknown = null;
let lastProgressAtMs = 0;
const composed = composeJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => ({ jellyfin: { enabled: false, serverUrl: '' } }) as never,
loadStoredSession: () => null,
getEnv: () => undefined,
},
getJellyfinClientInfoMainDeps: {
getResolvedJellyfinConfig: () => ({}) as never,
getDefaultJellyfinConfig: () => ({
clientName: 'SubMiner',
clientVersion: 'test',
deviceId: 'dev',
}),
},
waitForMpvConnectedMainDeps: {
getMpvClient: () => null,
now: () => Date.now(),
sleep: async () => {},
},
launchMpvIdleForJellyfinPlaybackMainDeps: {
getSocketPath: () => '/tmp/test-mpv.sock',
platform: 'linux',
execPath: process.execPath,
defaultMpvLogPath: '/tmp/test-mpv.log',
defaultMpvArgs: [],
removeSocketPath: () => {},
spawnMpv: () => ({ unref: () => {} }) as never,
logWarn: () => {},
logInfo: () => {},
},
ensureMpvConnectedForJellyfinPlaybackMainDeps: {
getMpvClient: () => null,
setMpvClient: () => {},
createMpvClient: () => ({}) as never,
getAutoLaunchInFlight: () => null,
setAutoLaunchInFlight: () => {},
connectTimeoutMs: 10,
autoLaunchTimeoutMs: 10,
},
preloadJellyfinExternalSubtitlesMainDeps: {
listJellyfinSubtitleTracks: async () => [],
getMpvClient: () => null,
sendMpvCommand: () => {},
wait: async () => {},
logDebug: () => {},
},
playJellyfinItemInMpvMainDeps: {
getMpvClient: () => null,
resolvePlaybackPlan: async () => ({
mode: 'direct',
url: 'https://example.test/video.m3u8',
title: 'Episode 1',
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
sendMpvCommand: () => {},
armQuitOnDisconnect: () => {},
schedule: () => undefined,
convertTicksToSeconds: () => 0,
setActivePlayback: (value) => {
activePlayback = value;
},
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
reportPlaying: () => {},
showMpvOsd: () => {},
},
remoteComposerOptions: {
getConfiguredSession: () => null,
logWarn: () => {},
getMpvClient: () => null,
sendMpvCommand: () => {},
jellyfinTicksToSeconds: () => 0,
getActivePlayback: () => activePlayback as never,
clearActivePlayback: () => {
activePlayback = null;
},
getSession: () => null,
getNow: () => Date.now(),
getLastProgressAtMs: () => lastProgressAtMs,
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
},
handleJellyfinAuthCommandsMainDeps: {
patchRawConfig: () => {},
authenticateWithPassword: async () => ({
serverUrl: 'https://example.test',
username: 'user',
accessToken: 'token',
userId: 'id',
}),
saveStoredSession: () => {},
clearStoredSession: () => {},
logInfo: () => {},
},
handleJellyfinListCommandsMainDeps: {
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
logInfo: () => {},
},
handleJellyfinPlayCommandMainDeps: {
logWarn: () => {},
},
handleJellyfinRemoteAnnounceCommandMainDeps: {
getRemoteSession: () => null,
logInfo: () => {},
logWarn: () => {},
},
startJellyfinRemoteSessionMainDeps: {
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: () =>
({
start: async () => {},
}) as never,
defaultDeviceId: 'dev',
defaultClientName: 'SubMiner',
defaultClientVersion: 'test',
logInfo: () => {},
logWarn: () => {},
},
stopJellyfinRemoteSessionMainDeps: {
getCurrentSession: () => null,
setCurrentSession: () => {},
clearActivePlayback: () => {
activePlayback = null;
},
},
runJellyfinCommandMainDeps: {
defaultServerUrl: 'https://example.test',
},
maybeFocusExistingJellyfinSetupWindowMainDeps: {
getSetupWindow: () => null,
},
openJellyfinSetupWindowMainDeps: {
createSetupWindow: () =>
({
focus: () => {},
webContents: { on: () => {} },
loadURL: () => {},
on: () => {},
isDestroyed: () => false,
close: () => {},
}) as never,
buildSetupFormHtml: (defaultServer, defaultUser) =>
`<html>${defaultServer}${defaultUser}</html>`,
parseSubmissionUrl: () => null,
authenticateWithPassword: async () => ({
serverUrl: 'https://example.test',
username: 'user',
accessToken: 'token',
userId: 'id',
}),
saveStoredSession: () => {},
patchJellyfinConfig: () => {},
logInfo: () => {},
logError: () => {},
showMpvOsd: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {},
encodeURIComponent,
},
});
assert.equal(typeof composed.getResolvedJellyfinConfig, 'function');
assert.equal(typeof composed.getJellyfinClientInfo, 'function');
assert.equal(typeof composed.reportJellyfinRemoteProgress, 'function');
assert.equal(typeof composed.reportJellyfinRemoteStopped, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlay, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
assert.equal(typeof composed.playJellyfinItemInMpv, 'function');
assert.equal(typeof composed.startJellyfinRemoteSession, 'function');
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
assert.equal(typeof composed.runJellyfinCommand, 'function');
assert.equal(typeof composed.openJellyfinSetupWindow, 'function');
});

View File

@@ -0,0 +1,290 @@
import {
buildJellyfinSetupFormHtml,
createEnsureMpvConnectedForJellyfinPlaybackHandler,
createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler,
createBuildGetJellyfinClientInfoMainDepsHandler,
createBuildGetResolvedJellyfinConfigMainDepsHandler,
createBuildHandleJellyfinAuthCommandsMainDepsHandler,
createBuildHandleJellyfinListCommandsMainDepsHandler,
createBuildHandleJellyfinPlayCommandMainDepsHandler,
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler,
createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler,
createBuildOpenJellyfinSetupWindowMainDepsHandler,
createBuildPlayJellyfinItemInMpvMainDepsHandler,
createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler,
createBuildRunJellyfinCommandMainDepsHandler,
createBuildStartJellyfinRemoteSessionMainDepsHandler,
createBuildStopJellyfinRemoteSessionMainDepsHandler,
createBuildWaitForMpvConnectedMainDepsHandler,
createGetJellyfinClientInfoHandler,
createGetResolvedJellyfinConfigHandler,
createHandleJellyfinAuthCommands,
createHandleJellyfinListCommands,
createHandleJellyfinPlayCommand,
createHandleJellyfinRemoteAnnounceCommand,
createLaunchMpvIdleForJellyfinPlaybackHandler,
createOpenJellyfinSetupWindowHandler,
createPlayJellyfinItemInMpvHandler,
createPreloadJellyfinExternalSubtitlesHandler,
createRunJellyfinCommandHandler,
createStartJellyfinRemoteSessionHandler,
createStopJellyfinRemoteSessionHandler,
createWaitForMpvConnectedHandler,
createMaybeFocusExistingJellyfinSetupWindowHandler,
parseJellyfinSetupSubmissionUrl,
} from '../domains/jellyfin';
import {
composeJellyfinRemoteHandlers,
type JellyfinRemoteComposerOptions,
} from './jellyfin-remote-composer';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type EnsureMpvConnectedMainDeps = Parameters<
typeof createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler
>[0];
type PlayJellyfinItemMainDeps = Parameters<
typeof createBuildPlayJellyfinItemInMpvMainDepsHandler
>[0];
type HandlePlayCommandMainDeps = Parameters<
typeof createBuildHandleJellyfinPlayCommandMainDepsHandler
>[0];
type HandleRemoteAnnounceMainDeps = Parameters<
typeof createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler
>[0];
type StartRemoteSessionMainDeps = Parameters<
typeof createBuildStartJellyfinRemoteSessionMainDepsHandler
>[0];
type RunJellyfinCommandMainDeps = Parameters<
typeof createBuildRunJellyfinCommandMainDepsHandler
>[0];
type OpenJellyfinSetupWindowMainDeps = Parameters<
typeof createBuildOpenJellyfinSetupWindowMainDepsHandler
>[0];
export type JellyfinRuntimeComposerOptions = ComposerInputs<{
getResolvedJellyfinConfigMainDeps: Parameters<
typeof createBuildGetResolvedJellyfinConfigMainDepsHandler
>[0];
getJellyfinClientInfoMainDeps: Parameters<
typeof createBuildGetJellyfinClientInfoMainDepsHandler
>[0];
waitForMpvConnectedMainDeps: Parameters<typeof createBuildWaitForMpvConnectedMainDepsHandler>[0];
launchMpvIdleForJellyfinPlaybackMainDeps: Parameters<
typeof createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler
>[0];
ensureMpvConnectedForJellyfinPlaybackMainDeps: Omit<
EnsureMpvConnectedMainDeps,
'waitForMpvConnected' | 'launchMpvIdleForJellyfinPlayback'
>;
preloadJellyfinExternalSubtitlesMainDeps: Parameters<
typeof createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler
>[0];
playJellyfinItemInMpvMainDeps: Omit<
PlayJellyfinItemMainDeps,
'ensureMpvConnectedForPlayback' | 'preloadExternalSubtitles'
>;
remoteComposerOptions: Omit<
JellyfinRemoteComposerOptions,
'getClientInfo' | 'getJellyfinConfig' | 'playJellyfinItem'
>;
handleJellyfinAuthCommandsMainDeps: Parameters<
typeof createBuildHandleJellyfinAuthCommandsMainDepsHandler
>[0];
handleJellyfinListCommandsMainDeps: Parameters<
typeof createBuildHandleJellyfinListCommandsMainDepsHandler
>[0];
handleJellyfinPlayCommandMainDeps: Omit<HandlePlayCommandMainDeps, 'playJellyfinItemInMpv'>;
handleJellyfinRemoteAnnounceCommandMainDeps: Omit<
HandleRemoteAnnounceMainDeps,
'startJellyfinRemoteSession'
>;
startJellyfinRemoteSessionMainDeps: Omit<
StartRemoteSessionMainDeps,
'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand'
>;
stopJellyfinRemoteSessionMainDeps: Parameters<
typeof createBuildStopJellyfinRemoteSessionMainDepsHandler
>[0];
runJellyfinCommandMainDeps: Omit<
RunJellyfinCommandMainDeps,
| 'getJellyfinConfig'
| 'getJellyfinClientInfo'
| 'handleAuthCommands'
| 'handleRemoteAnnounceCommand'
| 'handleListCommands'
| 'handlePlayCommand'
>;
maybeFocusExistingJellyfinSetupWindowMainDeps: Parameters<
typeof createMaybeFocusExistingJellyfinSetupWindowHandler
>[0];
openJellyfinSetupWindowMainDeps: Omit<
OpenJellyfinSetupWindowMainDeps,
'maybeFocusExistingSetupWindow' | 'getResolvedJellyfinConfig' | 'getJellyfinClientInfo'
>;
}>;
export type JellyfinRuntimeComposerResult = ComposerOutputs<{
getResolvedJellyfinConfig: ReturnType<typeof createGetResolvedJellyfinConfigHandler>;
getJellyfinClientInfo: ReturnType<typeof createGetJellyfinClientInfoHandler>;
reportJellyfinRemoteProgress: ReturnType<
typeof composeJellyfinRemoteHandlers
>['reportJellyfinRemoteProgress'];
reportJellyfinRemoteStopped: ReturnType<
typeof composeJellyfinRemoteHandlers
>['reportJellyfinRemoteStopped'];
handleJellyfinRemotePlay: ReturnType<
typeof composeJellyfinRemoteHandlers
>['handleJellyfinRemotePlay'];
handleJellyfinRemotePlaystate: ReturnType<
typeof composeJellyfinRemoteHandlers
>['handleJellyfinRemotePlaystate'];
handleJellyfinRemoteGeneralCommand: ReturnType<
typeof composeJellyfinRemoteHandlers
>['handleJellyfinRemoteGeneralCommand'];
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>;
openJellyfinSetupWindow: ReturnType<typeof createOpenJellyfinSetupWindowHandler>;
}>;
export function composeJellyfinRuntimeHandlers(
options: JellyfinRuntimeComposerOptions,
): JellyfinRuntimeComposerResult {
const getResolvedJellyfinConfig = createGetResolvedJellyfinConfigHandler(
createBuildGetResolvedJellyfinConfigMainDepsHandler(
options.getResolvedJellyfinConfigMainDeps,
)(),
);
const getJellyfinClientInfo = createGetJellyfinClientInfoHandler(
createBuildGetJellyfinClientInfoMainDepsHandler(options.getJellyfinClientInfoMainDeps)(),
);
const waitForMpvConnected = createWaitForMpvConnectedHandler(
createBuildWaitForMpvConnectedMainDepsHandler(options.waitForMpvConnectedMainDeps)(),
);
const launchMpvIdleForJellyfinPlayback = createLaunchMpvIdleForJellyfinPlaybackHandler(
createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
options.launchMpvIdleForJellyfinPlaybackMainDeps,
)(),
);
const ensureMpvConnectedForJellyfinPlayback = createEnsureMpvConnectedForJellyfinPlaybackHandler(
createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler({
...options.ensureMpvConnectedForJellyfinPlaybackMainDeps,
waitForMpvConnected: (timeoutMs) => waitForMpvConnected(timeoutMs),
launchMpvIdleForJellyfinPlayback: () => launchMpvIdleForJellyfinPlayback(),
})(),
);
const preloadJellyfinExternalSubtitles = createPreloadJellyfinExternalSubtitlesHandler(
createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
options.preloadJellyfinExternalSubtitlesMainDeps,
)(),
);
const playJellyfinItemInMpv = createPlayJellyfinItemInMpvHandler(
createBuildPlayJellyfinItemInMpvMainDepsHandler({
...options.playJellyfinItemInMpvMainDeps,
ensureMpvConnectedForPlayback: () => ensureMpvConnectedForJellyfinPlayback(),
preloadExternalSubtitles: (params) => {
void preloadJellyfinExternalSubtitles(params);
},
})(),
);
const {
reportJellyfinRemoteProgress,
reportJellyfinRemoteStopped,
handleJellyfinRemotePlay,
handleJellyfinRemotePlaystate,
handleJellyfinRemoteGeneralCommand,
} = composeJellyfinRemoteHandlers({
...options.remoteComposerOptions,
getClientInfo: () => getJellyfinClientInfo(),
getJellyfinConfig: () => getResolvedJellyfinConfig(),
playJellyfinItem: (params) =>
playJellyfinItemInMpv(params as Parameters<typeof playJellyfinItemInMpv>[0]),
});
const handleJellyfinAuthCommands = createHandleJellyfinAuthCommands(
createBuildHandleJellyfinAuthCommandsMainDepsHandler(
options.handleJellyfinAuthCommandsMainDeps,
)(),
);
const handleJellyfinListCommands = createHandleJellyfinListCommands(
createBuildHandleJellyfinListCommandsMainDepsHandler(
options.handleJellyfinListCommandsMainDeps,
)(),
);
const handleJellyfinPlayCommand = createHandleJellyfinPlayCommand(
createBuildHandleJellyfinPlayCommandMainDepsHandler({
...options.handleJellyfinPlayCommandMainDeps,
playJellyfinItemInMpv: (params) =>
playJellyfinItemInMpv(params as Parameters<typeof playJellyfinItemInMpv>[0]),
})(),
);
let startJellyfinRemoteSession!: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
const handleJellyfinRemoteAnnounceCommand = createHandleJellyfinRemoteAnnounceCommand(
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
...options.handleJellyfinRemoteAnnounceCommandMainDeps,
startJellyfinRemoteSession: () => startJellyfinRemoteSession(),
})(),
);
startJellyfinRemoteSession = createStartJellyfinRemoteSessionHandler(
createBuildStartJellyfinRemoteSessionMainDepsHandler({
...options.startJellyfinRemoteSessionMainDeps,
getJellyfinConfig: () => getResolvedJellyfinConfig(),
handlePlay: (payload) => handleJellyfinRemotePlay(payload),
handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload),
handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload),
})(),
);
const stopJellyfinRemoteSession = createStopJellyfinRemoteSessionHandler(
createBuildStopJellyfinRemoteSessionMainDepsHandler(
options.stopJellyfinRemoteSessionMainDeps,
)(),
);
const runJellyfinCommand = createRunJellyfinCommandHandler(
createBuildRunJellyfinCommandMainDepsHandler({
...options.runJellyfinCommandMainDeps,
getJellyfinConfig: () => getResolvedJellyfinConfig(),
getJellyfinClientInfo: (jellyfinConfig) => getJellyfinClientInfo(jellyfinConfig),
handleAuthCommands: (params) => handleJellyfinAuthCommands(params),
handleRemoteAnnounceCommand: (args) => handleJellyfinRemoteAnnounceCommand(args),
handleListCommands: (params) => handleJellyfinListCommands(params),
handlePlayCommand: (params) => handleJellyfinPlayCommand(params),
})(),
);
const maybeFocusExistingJellyfinSetupWindow = createMaybeFocusExistingJellyfinSetupWindowHandler(
options.maybeFocusExistingJellyfinSetupWindowMainDeps,
);
const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler(
createBuildOpenJellyfinSetupWindowMainDepsHandler({
...options.openJellyfinSetupWindowMainDeps,
maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow,
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getJellyfinClientInfo: () => getJellyfinClientInfo(),
})(),
);
return {
getResolvedJellyfinConfig,
getJellyfinClientInfo,
reportJellyfinRemoteProgress,
reportJellyfinRemoteStopped,
handleJellyfinRemotePlay,
handleJellyfinRemotePlaystate,
handleJellyfinRemoteGeneralCommand,
playJellyfinItemInMpv,
startJellyfinRemoteSession,
stopJellyfinRemoteSession,
runJellyfinCommand,
openJellyfinSetupWindow,
};
}
export { buildJellyfinSetupFormHtml, parseJellyfinSetupSubmissionUrl };

View File

@@ -61,6 +61,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
@@ -71,6 +72,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
updateCurrentMediaPath: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},

View File

@@ -35,6 +35,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
stopJellyfinRemoteSession: async () => {},
stopDiscordPresenceService: () => {},
},
shouldRestoreWindowsOnActivateMainDeps: {
isOverlayRuntimeInitialized: () => false,

View File

@@ -10,6 +10,7 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
const calls: string[] = [];
const handler = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
hasInitialJellyfinPlayArg: () => true,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => true,
@@ -22,7 +23,7 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
});
handler({ connected: false });
assert.deepEqual(calls, ['report-stop', 'schedule', 'quit']);
assert.deepEqual(calls, ['presence-refresh', 'report-stop', 'schedule', 'quit']);
});
test('mpv subtitle timing handler ignores blank subtitle lines', () => {

View File

@@ -17,6 +17,7 @@ type MpvEventClient = {
export function createHandleMpvConnectionChangeHandler(deps: {
reportJellyfinRemoteStopped: () => void;
refreshDiscordPresence: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean;
@@ -25,6 +26,7 @@ export function createHandleMpvConnectionChangeHandler(deps: {
quitApp: () => void;
}) {
return ({ connected }: { connected: boolean }): void => {
deps.refreshDiscordPresence();
if (connected) return;
deps.reportJellyfinRemoteStopped();
if (!deps.hasInitialJellyfinPlayArg()) return;

View File

@@ -18,10 +18,11 @@ test('subtitle change handler updates state, broadcasts, and forwards', () => {
setCurrentSubText: (text) => calls.push(`set:${text}`),
broadcastSubtitle: (payload) => calls.push(`broadcast:${payload.text}`),
onSubtitleChange: (text) => calls.push(`process:${text}`),
refreshDiscordPresence: () => calls.push('presence'),
});
handler({ text: 'line' });
assert.deepEqual(calls, ['set:line', 'broadcast:line', 'process:line']);
assert.deepEqual(calls, ['set:line', 'broadcast:line', 'process:line', 'presence']);
});
test('subtitle ass change handler updates state and broadcasts', () => {
@@ -55,6 +56,7 @@ test('media path change handler reports stop for empty path and probes media key
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
refreshDiscordPresence: () => calls.push('presence'),
});
handler({ path: '' });
@@ -65,6 +67,7 @@ test('media path change handler reports stop for empty path and probes media key
'probe:show:1',
'guess:show:1',
'sync',
'presence',
]);
});
@@ -75,10 +78,17 @@ test('media title change handler clears guess state and syncs immersion', () =>
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
notifyImmersionTitleUpdate: (title) => calls.push(`notify:${title}`),
syncImmersionMediaState: () => calls.push('sync'),
refreshDiscordPresence: () => calls.push('presence'),
});
handler({ title: 'Episode 1' });
assert.deepEqual(calls, ['title:Episode 1', 'reset-guess', 'notify:Episode 1', 'sync']);
assert.deepEqual(calls, [
'title:Episode 1',
'reset-guess',
'notify:Episode 1',
'sync',
'presence',
]);
});
test('time-pos and pause handlers report progress with correct urgency', () => {
@@ -86,15 +96,24 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
const timeHandler = createHandleMpvTimePosChangeHandler({
recordPlaybackPosition: (time) => calls.push(`time:${time}`),
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
refreshDiscordPresence: () => calls.push('presence'),
});
const pauseHandler = createHandleMpvPauseChangeHandler({
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
refreshDiscordPresence: () => calls.push('presence'),
});
timeHandler({ time: 12.5 });
pauseHandler({ paused: true });
assert.deepEqual(calls, ['time:12.5', 'progress:normal', 'pause:yes', 'progress:force']);
assert.deepEqual(calls, [
'time:12.5',
'progress:normal',
'presence',
'pause:yes',
'progress:force',
'presence',
]);
});
test('subtitle metrics change handler forwards patch payload', () => {

View File

@@ -2,11 +2,13 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
setCurrentSubText: (text: string) => void;
broadcastSubtitle: (payload: { text: string; tokens: null }) => void;
onSubtitleChange: (text: string) => void;
refreshDiscordPresence: () => void;
}) {
return ({ text }: { text: string }): void => {
deps.setCurrentSubText(text);
deps.broadcastSubtitle({ text, tokens: null });
deps.onSubtitleChange(text);
deps.refreshDiscordPresence();
};
}
@@ -36,6 +38,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
maybeProbeAnilistDuration: (mediaKey: string) => void;
ensureAnilistMediaGuess: (mediaKey: string) => void;
syncImmersionMediaState: () => void;
refreshDiscordPresence: () => void;
}) {
return ({ path }: { path: string }): void => {
deps.updateCurrentMediaPath(path);
@@ -49,6 +52,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
deps.ensureAnilistMediaGuess(mediaKey);
}
deps.syncImmersionMediaState();
deps.refreshDiscordPresence();
};
}
@@ -57,32 +61,38 @@ export function createHandleMpvMediaTitleChangeHandler(deps: {
resetAnilistMediaGuessState: () => void;
notifyImmersionTitleUpdate: (title: string) => void;
syncImmersionMediaState: () => void;
refreshDiscordPresence: () => void;
}) {
return ({ title }: { title: string }): void => {
deps.updateCurrentMediaTitle(title);
deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate(title);
deps.syncImmersionMediaState();
deps.refreshDiscordPresence();
};
}
export function createHandleMpvTimePosChangeHandler(deps: {
recordPlaybackPosition: (time: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
refreshDiscordPresence: () => void;
}) {
return ({ time }: { time: number }): void => {
deps.recordPlaybackPosition(time);
deps.reportJellyfinRemoteProgress(false);
deps.refreshDiscordPresence();
};
}
export function createHandleMpvPauseChangeHandler(deps: {
recordPauseState: (paused: boolean) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
refreshDiscordPresence: () => void;
}) {
return ({ paused }: { paused: boolean }): void => {
deps.recordPauseState(paused);
deps.reportJellyfinRemoteProgress(true);
deps.refreshDiscordPresence();
};
}

View File

@@ -28,6 +28,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
refreshDiscordPresence: () => calls.push('presence-refresh'),
setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`),
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
@@ -73,4 +74,5 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
assert.ok(calls.includes('notify-title:Episode 1'));
assert.ok(calls.includes('progress:normal'));
assert.ok(calls.includes('progress:force'));
assert.ok(calls.includes('presence-refresh'));
});

View File

@@ -35,6 +35,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
setCurrentSubText: (text: string) => void;
broadcastSubtitle: (payload: { text: string; tokens: null }) => void;
onSubtitleChange: (text: string) => void;
refreshDiscordPresence: () => void;
setCurrentSubAssText: (text: string) => void;
broadcastSubtitleAss: (text: string) => void;
@@ -61,6 +62,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
return (mpvClient: MpvEventClient): void => {
const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
@@ -80,6 +82,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
setCurrentSubText: (text) => deps.setCurrentSubText(text),
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
onSubtitleChange: (text) => deps.onSubtitleChange(text),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
});
const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({
setCurrentSubAssText: (text) => deps.setCurrentSubAssText(text),
@@ -96,22 +99,26 @@ export function createBindMpvMainEventHandlersHandler(deps: {
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey),
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
});
const handleMpvMediaTitleChange = createHandleMpvMediaTitleChangeHandler({
updateCurrentMediaTitle: (title) => deps.updateCurrentMediaTitle(title),
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
notifyImmersionTitleUpdate: (title) => deps.notifyImmersionTitleUpdate(title),
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
});
const handleMpvTimePosChange = createHandleMpvTimePosChangeHandler({
recordPlaybackPosition: (time) => deps.recordPlaybackPosition(time),
reportJellyfinRemoteProgress: (forceImmediate) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
});
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
recordPauseState: (paused) => deps.recordPauseState(paused),
reportJellyfinRemoteProgress: (forceImmediate) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
});
const handleMpvSubtitleMetricsChange = createHandleMpvSubtitleMetricsChangeHandler({
updateSubtitleRenderMetrics: (patch) => deps.updateSubtitleRenderMetrics(patch),

View File

@@ -19,6 +19,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
},
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
};
@@ -35,7 +36,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
calls.push('anilist-post-watch');
},
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
broadcastToOverlayWindows: (channel, payload) => calls.push(`broadcast:${channel}:${String(payload)}`),
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
getCurrentAnilistMediaKey: () => 'media-key',
@@ -47,6 +49,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`),
updateSubtitleRenderMetrics: () => calls.push('metrics'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
})();
assert.equal(deps.hasInitialJellyfinPlayArg(), true);
@@ -64,6 +67,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.setCurrentSubText('sub');
deps.broadcastSubtitle({ text: 'sub', tokens: null });
deps.onSubtitleChange('sub');
deps.refreshDiscordPresence();
deps.setCurrentSubAssText('ass');
deps.broadcastSubtitleAss('ass');
deps.broadcastSecondarySubtitle('sec');
@@ -84,9 +88,11 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.equal(appState.currentSubText, 'sub');
assert.equal(appState.currentSubAssText, 'ass');
assert.equal(appState.playbackPaused, true);
assert.equal(appState.previousSecondarySubVisibility, true);
assert.ok(calls.includes('remote-stopped'));
assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('presence-refresh'));
});

View File

@@ -14,6 +14,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
} | null;
currentSubText: string;
currentSubAssText: string;
playbackPaused: boolean | null;
previousSecondarySubVisibility: boolean | null;
};
getQuitOnDisconnectArmed: () => boolean;
@@ -34,6 +35,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
resetAnilistMediaGuessState: () => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
refreshDiscordPresence: () => void;
}) {
return () => ({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
@@ -57,15 +59,18 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
broadcastSubtitle: (payload: { text: string; tokens: null }) =>
deps.broadcastToOverlayWindows('subtitle:set', payload),
onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
setCurrentSubAssText: (text: string) => {
deps.appState.currentSubAssText = text;
},
broadcastSubtitleAss: (text: string) => deps.broadcastToOverlayWindows('subtitle-ass:set', text),
broadcastSubtitleAss: (text: string) =>
deps.broadcastToOverlayWindows('subtitle-ass:set', text),
broadcastSecondarySubtitle: (text: string) =>
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey: string | null) => deps.resetAnilistMediaTracking(mediaKey),
resetAnilistMediaTracking: (mediaKey: string | null) =>
deps.resetAnilistMediaTracking(mediaKey),
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
@@ -73,10 +78,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
notifyImmersionTitleUpdate: (title: string) =>
deps.appState.immersionTracker?.handleMediaTitleUpdate?.(title),
recordPlaybackPosition: (time: number) => deps.appState.immersionTracker?.recordPlaybackPosition?.(time),
recordPlaybackPosition: (time: number) =>
deps.appState.immersionTracker?.recordPlaybackPosition?.(time),
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
recordPauseState: (paused: boolean) => deps.appState.immersionTracker?.recordPauseState?.(paused),
recordPauseState: (paused: boolean) => {
deps.appState.playbackPaused = paused;
deps.appState.immersionTracker?.recordPauseState?.(paused);
},
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
deps.updateSubtitleRenderMetrics(patch),
setPreviousSecondarySubVisibility: (visible: boolean) => {

View File

@@ -16,6 +16,7 @@ import type { AnkiIntegration } from '../anki-integration';
import type { ImmersionTrackerService } from '../core/services/immersion-tracker-service';
import type { MpvIpcClient } from '../core/services/mpv';
import type { JellyfinRemoteSessionService } from '../core/services/jellyfin-remote';
import type { createDiscordPresenceService } from '../core/services/discord-presence';
import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from '../core/services/mpv-render-metrics';
import type { RuntimeOptionsManager } from '../runtime-options';
import type { MecabTokenizer } from '../mecab-tokenizer';
@@ -150,6 +151,7 @@ export interface AppState {
yomitanParserInitPromise: Promise<boolean> | null;
mpvClient: MpvIpcClient | null;
jellyfinRemoteSession: JellyfinRemoteSessionService | null;
discordPresenceService: ReturnType<typeof createDiscordPresenceService> | null;
reconnectTimer: ReturnType<typeof setTimeout> | null;
currentSubText: string;
currentSubAssText: string;
@@ -160,6 +162,7 @@ export interface AppState {
subtitlePosition: SubtitlePosition | null;
currentMediaPath: string | null;
currentMediaTitle: string | null;
playbackPaused: boolean | null;
pendingSubtitlePosition: SubtitlePosition | null;
anilistClientSecretState: AnilistSecretResolutionState;
mecabTokenizer: MecabTokenizer | null;
@@ -222,6 +225,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
yomitanParserInitPromise: null,
mpvClient: null,
jellyfinRemoteSession: null,
discordPresenceService: null,
reconnectTimer: null,
currentSubText: '',
currentSubAssText: '',
@@ -232,6 +236,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
subtitlePosition: null,
currentMediaPath: null,
currentMediaTitle: null,
playbackPaused: null,
pendingSubtitlePosition: null,
anilistClientSecretState: createInitialAnilistSecretResolutionState(),
mecabTokenizer: null,

View File

@@ -358,6 +358,21 @@ export interface JellyfinConfig {
transcodeVideoCodec?: string;
}
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;
}
export interface InvisibleOverlayConfig {
startupVisibility?: 'platform-default' | 'visible' | 'hidden';
}
@@ -403,6 +418,7 @@ export interface Config {
jimaku?: JimakuConfig;
anilist?: AnilistConfig;
jellyfin?: JellyfinConfig;
discordPresence?: DiscordPresenceConfig;
invisibleOverlay?: InvisibleOverlayConfig;
youtubeSubgen?: YoutubeSubgenConfig;
immersionTracking?: ImmersionTrackingConfig;
@@ -528,6 +544,20 @@ export interface ResolvedConfig {
directPlayContainers: string[];
transcodeVideoCodec: string;
};
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;
};
invisibleOverlay: Required<InvisibleOverlayConfig>;
youtubeSubgen: YoutubeSubgenConfig & {
mode: YoutubeSubgenMode;