fix(jellyfin): keep remote websocket alive

- Respond to keep-alive requests and reconnect when the socket goes silent
- Warn when playback reports fail
This commit is contained in:
2026-09-21 01:34:53 -07:00
parent f6855ba459
commit 0efdc5db15
7 changed files with 237 additions and 4 deletions
+122
View File
@@ -4,6 +4,17 @@ import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './je
class FakeWebSocket {
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
sent: string[] = [];
terminated = false;
send(data: string): void {
this.sent.push(data);
}
terminate(): void {
this.terminated = true;
this.emit('close');
}
on(event: string, listener: (...args: unknown[]) => void): this {
if (!this.listeners[event]) {
@@ -356,3 +367,114 @@ test('advertiseNow validates server registration using Sessions endpoint', async
assert.equal(ok, true);
assert.ok(calls.some((url) => url.endsWith('/Sessions')));
});
test('answers ForceKeepAlive with KeepAlive messages on the advertised cadence', () => {
const sockets: FakeWebSocket[] = [];
const timers: Array<{ handler: () => void; delay: number }> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-ka',
deviceId: 'device-ka',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
setTimer: ((handler: () => void, delay?: number) => {
timers.push({ handler, delay: Number(delay) });
return timers.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimer: (() => undefined) as typeof clearTimeout,
});
service.start();
sockets[0]!.emit('open');
assert.deepEqual(sockets[0]!.sent, ['{"MessageType":"KeepAlive"}']);
assert.equal(timers[0]!.delay, 30_000);
sockets[0]!.emit('message', JSON.stringify({ MessageType: 'ForceKeepAlive', Data: 20 }));
assert.equal(sockets[0]!.sent.length, 2);
assert.equal(timers.at(-1)!.delay, 10_000);
timers.at(-1)!.handler();
assert.equal(sockets[0]!.sent.length, 3);
});
test('reconnects when the server stops answering keep-alives', () => {
let now = 1_000_000;
const sockets: FakeWebSocket[] = [];
const timers: Array<() => void> = [];
const warnings: string[] = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-lost',
deviceId: 'device-lost',
webSocketFactory: () => {
const socket = new FakeWebSocket();
sockets.push(socket);
return socket as unknown as any;
},
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
getNow: () => now,
logWarn: (message) => {
warnings.push(message);
},
reconnectBaseDelayMs: 100,
setTimer: ((handler: () => void) => {
timers.push(handler);
return timers.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimer: (() => undefined) as typeof clearTimeout,
});
service.start();
sockets[0]!.emit('open');
// Two silent ticks are still within the 90s tolerance; the third marks the socket lost.
now += 30_000;
timers.shift()!();
now += 30_000;
timers.shift()!();
assert.equal(sockets[0]!.sent.length, 3);
assert.equal(sockets[0]!.terminated, false);
now += 30_000;
timers.shift()!();
assert.equal(sockets[0]!.terminated, true);
assert.equal(service.isConnected(), false);
assert.equal(warnings.length, 1);
timers.shift()!();
assert.equal(sockets.length, 2);
});
test('warns once per failing timeline endpoint until it recovers', async () => {
const warnings: string[] = [];
let status = 400;
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-warn',
deviceId: 'device-warn',
webSocketFactory: () => new FakeWebSocket() as unknown as any,
fetchImpl: (async () => new Response(null, { status })) as typeof fetch,
logWarn: (message) => {
warnings.push(message);
},
});
const state = { itemId: 'item-1', positionTicks: 10, playMethod: 'DirectPlay' };
assert.equal(await service.reportStopped(state), false);
assert.equal(await service.reportStopped(state), false);
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /Sessions\/Playing\/Stopped/);
status = 200;
assert.equal(await service.reportStopped(state), true);
status = 500;
assert.equal(await service.reportStopped(state), false);
assert.equal(warnings.length, 2);
});
+103 -3
View File
@@ -45,9 +45,22 @@ interface JellyfinRemoteSocket {
on(event: 'close', listener: () => void): this;
on(event: 'error', listener: (error: Error) => void): this;
on(event: 'message', listener: (data: unknown) => void): this;
send(data: string): void;
terminate?(): void;
close(): void;
}
// Jellyfin advertises its keep-alive timeout in the ForceKeepAlive message (60s by default),
// drops sockets that stay silent past it, and since 12.0 also detaches the session's remote
// controller when that happens. The drop never reaches the client as a close frame, so the
// client has to keep sending KeepAlive and treat missing replies as a dead connection.
const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 60_000;
const KEEP_ALIVE_LOST_FACTOR = 1.5;
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
(timer as unknown as { unref?: () => void }).unref?.();
}
type JellyfinRemoteSocketHeaders = Record<string, string>;
export interface JellyfinRemoteSessionServiceOptions {
@@ -77,6 +90,9 @@ export interface JellyfinRemoteSessionServiceOptions {
deviceName?: string;
onConnected?: () => void;
onDisconnected?: () => void;
logWarn?: (message: string, details?: unknown) => void;
keepAliveTimeoutMs?: number;
getNow?: () => number;
}
function normalizeServerUrl(serverUrl: string): string {
@@ -196,6 +212,12 @@ export class JellyfinRemoteSessionService {
private readonly authHeader: string;
private readonly onConnected?: () => void;
private readonly onDisconnected?: () => void;
private readonly logWarn?: (message: string, details?: unknown) => void;
private readonly now: () => number;
private keepAliveTimeoutMs: number;
private keepAliveTimer: ReturnType<typeof setTimeout> | null = null;
private lastInboundAtMs = 0;
private readonly failedRequestPaths = new Set<string>();
private readonly reconnectBaseDelayMs: number;
private readonly reconnectMaxDelayMs: number;
@@ -233,6 +255,12 @@ export class JellyfinRemoteSessionService {
});
this.onConnected = options.onConnected;
this.onDisconnected = options.onDisconnected;
this.logWarn = options.logWarn;
this.now = options.getNow ?? Date.now;
this.keepAliveTimeoutMs = Math.max(
1000,
options.keepAliveTimeoutMs ?? DEFAULT_KEEP_ALIVE_TIMEOUT_MS,
);
this.reconnectBaseDelayMs = Math.max(100, options.reconnectBaseDelayMs ?? 500);
this.reconnectMaxDelayMs = Math.max(
this.reconnectBaseDelayMs,
@@ -250,6 +278,7 @@ export class JellyfinRemoteSessionService {
public stop(): void {
this.running = false;
this.connected = false;
this.stopKeepAlive();
if (this.reconnectTimer) {
this.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
@@ -298,12 +327,15 @@ export class JellyfinRemoteSessionService {
if (this.socket !== socket || !this.running) return;
this.connected = true;
this.reconnectAttempt = 0;
this.lastInboundAtMs = this.now();
this.startKeepAlive(socket, this.keepAliveTimeoutMs);
this.onConnected?.();
void this.postCapabilities();
});
socket.on('message', (rawData) => {
this.handleInboundMessage(rawData);
this.lastInboundAtMs = this.now();
this.handleInboundMessage(socket, rawData);
});
const handleDisconnect = () => {
@@ -311,6 +343,7 @@ export class JellyfinRemoteSessionService {
disconnected = true;
if (this.socket === socket) {
this.socket = null;
this.stopKeepAlive();
}
this.connected = false;
this.onDisconnected?.();
@@ -323,6 +356,51 @@ export class JellyfinRemoteSessionService {
socket.on('error', handleDisconnect);
}
private startKeepAlive(socket: JellyfinRemoteSocket, timeoutMs: number): void {
this.stopKeepAlive();
this.keepAliveTimeoutMs = timeoutMs;
this.sendKeepAlive(socket);
this.scheduleKeepAliveTick(socket);
}
private scheduleKeepAliveTick(socket: JellyfinRemoteSocket): void {
const intervalMs = Math.max(1000, Math.floor(this.keepAliveTimeoutMs / 2));
const timer = this.setTimer(() => {
this.keepAliveTimer = null;
if (this.socket !== socket || !this.running) return;
const silentForMs = this.now() - this.lastInboundAtMs;
if (silentForMs >= this.keepAliveTimeoutMs * KEEP_ALIVE_LOST_FACTOR) {
this.logWarn?.('Jellyfin remote websocket stopped answering keep-alives; reconnecting.');
// Dropping the socket raises 'close', which schedules the reconnect.
if (socket.terminate) {
socket.terminate();
} else {
socket.close();
}
return;
}
this.sendKeepAlive(socket);
this.scheduleKeepAliveTick(socket);
}, intervalMs);
unrefTimer(timer);
this.keepAliveTimer = timer;
}
private stopKeepAlive(): void {
if (this.keepAliveTimer) {
this.clearTimer(this.keepAliveTimer);
this.keepAliveTimer = null;
}
}
private sendKeepAlive(socket: JellyfinRemoteSocket): void {
try {
socket.send(JSON.stringify({ MessageType: 'KeepAlive' }));
} catch (error) {
this.logWarn?.('Failed to send Jellyfin remote keep-alive.', error);
}
}
private scheduleReconnect(): void {
const delay = Math.min(
this.reconnectMaxDelayMs,
@@ -397,16 +475,38 @@ export class JellyfinRemoteSessionService {
},
body: JSON.stringify(payload),
});
this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`);
return response.ok;
} catch {
} catch (error) {
this.noteRequestOutcome(path, error);
return false;
}
}
private handleInboundMessage(rawData: unknown): void {
// Warn once per path while it keeps failing so a rejected stop report is visible in the
// log without a warning per progress tick.
private noteRequestOutcome(path: string, failure: unknown): void {
if (failure === null) {
this.failedRequestPaths.delete(path);
return;
}
if (this.failedRequestPaths.has(path)) return;
this.failedRequestPaths.add(path);
this.logWarn?.(`Jellyfin remote request failed: POST ${path}`, failure);
}
private handleInboundMessage(socket: JellyfinRemoteSocket, rawData: unknown): void {
const message = parseInboundMessage(rawData);
if (!message) return;
const messageType = message.MessageType;
if (messageType === 'ForceKeepAlive') {
const seconds = Number(message.Data);
const timeoutMs =
Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : this.keepAliveTimeoutMs;
this.startKeepAlive(socket, timeoutMs);
return;
}
if (messageType === 'KeepAlive') return;
const payload = parseMessageData(message.Data);
if (messageType === 'Play') {
this.onPlay?.(payload);
@@ -91,6 +91,7 @@ export function composeJellyfinRemoteHandlers(
getNow: options.getNow,
ticksPerSecond: options.ticksPerSecond,
logDebug: options.logDebug,
logWarn: options.logWarn,
});
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
buildReportJellyfinRemoteProgressMainDepsHandler(),
@@ -75,5 +75,6 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
ticksPerSecond: deps.ticksPerSecond,
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
...(deps.logWarn ? { logWarn: (message: string) => deps.logWarn?.(message) } : {}),
});
}
+7 -1
View File
@@ -203,6 +203,7 @@ export type JellyfinRemoteStoppedReporterDeps = {
getNow?: () => number;
ticksPerSecond: number;
logDebug: (message: string, error: unknown) => void;
logWarn?: (message: string) => void;
};
export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) {
@@ -244,7 +245,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
} catch (error) {
deps.logDebug('Failed to report Jellyfin remote final progress', error);
}
await session.reportStopped({
const reported = await session.reportStopped({
itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId,
positionTicks,
@@ -254,6 +255,11 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
subtitleStreamIndex: playback.subtitleStreamIndex,
eventName: 'stop',
});
if (reported === false) {
deps.logWarn?.(
`Jellyfin did not accept the playback stop report for item ${playback.itemId}; the server may keep showing it as playing.`,
);
}
} catch (error) {
deps.logDebug('Failed to report Jellyfin remote stop', error);
} finally {
@@ -38,6 +38,7 @@ type JellyfinRemoteServiceOptions = {
};
onConnected: () => void;
onDisconnected: () => void;
logWarn?: (message: string, details?: unknown) => void;
onPlay: (payload: JellyfinRemoteEventPayload) => void;
onPlaystate: (payload: JellyfinRemoteEventPayload) => void;
onGeneralCommand: (payload: JellyfinRemoteEventPayload) => void;
@@ -110,6 +111,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
onDisconnected: () => {
deps.logWarn('Jellyfin remote websocket disconnected; retrying.');
},
logWarn: (message, details) => deps.logWarn(message, details),
onPlay: (payload) => {
void deps.handlePlay(payload).catch((error) => {
deps.logWarn('Failed handling Jellyfin remote Play event', error);