mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user