diff --git a/changes/fix-jellyfin-modern-auth.md b/changes/fix-jellyfin-modern-auth.md index 4eb756a0..f4180fb7 100644 --- a/changes/fix-jellyfin-modern-auth.md +++ b/changes/fix-jellyfin-modern-auth.md @@ -2,3 +2,4 @@ type: fixed area: jellyfin - Authenticate Jellyfin playback, subtitle, artwork, and remote-control socket URLs with the `ApiKey` query parameter and stop sending the legacy `X-Emby-Token` and `X-Emby-Authorization` headers, so the integration keeps working on Jellyfin 12 where legacy authorization is disabled by default. +- Keep the cast-target websocket alive by answering Jellyfin keep-alive requests and reconnect when the server stops replying, so "Play on SubMiner" keeps working on Jellyfin 12 instead of silently dying about a minute after connecting. Failed playback progress and stop reports are now logged as warnings. diff --git a/src/core/services/jellyfin-remote.test.ts b/src/core/services/jellyfin-remote.test.ts index 7877cadd..69c358cd 100644 --- a/src/core/services/jellyfin-remote.test.ts +++ b/src/core/services/jellyfin-remote.test.ts @@ -4,6 +4,17 @@ import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './je class FakeWebSocket { private listeners: Record 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; + }) 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; + }) 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); +}); diff --git a/src/core/services/jellyfin-remote.ts b/src/core/services/jellyfin-remote.ts index cd0ce605..0d484c6e 100644 --- a/src/core/services/jellyfin-remote.ts +++ b/src/core/services/jellyfin-remote.ts @@ -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): void { + (timer as unknown as { unref?: () => void }).unref?.(); +} + type JellyfinRemoteSocketHeaders = Record; 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 | null = null; + private lastInboundAtMs = 0; + private readonly failedRequestPaths = new Set(); 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); diff --git a/src/main/runtime/composers/jellyfin-remote-composer.ts b/src/main/runtime/composers/jellyfin-remote-composer.ts index 2de85547..000ee004 100644 --- a/src/main/runtime/composers/jellyfin-remote-composer.ts +++ b/src/main/runtime/composers/jellyfin-remote-composer.ts @@ -91,6 +91,7 @@ export function composeJellyfinRemoteHandlers( getNow: options.getNow, ticksPerSecond: options.ticksPerSecond, logDebug: options.logDebug, + logWarn: options.logWarn, }); const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler( buildReportJellyfinRemoteProgressMainDepsHandler(), diff --git a/src/main/runtime/jellyfin-remote-main-deps.ts b/src/main/runtime/jellyfin-remote-main-deps.ts index d5b4dd20..ed3ca45d 100644 --- a/src/main/runtime/jellyfin-remote-main-deps.ts +++ b/src/main/runtime/jellyfin-remote-main-deps.ts @@ -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) } : {}), }); } diff --git a/src/main/runtime/jellyfin-remote-playback.ts b/src/main/runtime/jellyfin-remote-playback.ts index 8ea0c6c9..54ebd8fa 100644 --- a/src/main/runtime/jellyfin-remote-playback.ts +++ b/src/main/runtime/jellyfin-remote-playback.ts @@ -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 { diff --git a/src/main/runtime/jellyfin-remote-session-lifecycle.ts b/src/main/runtime/jellyfin-remote-session-lifecycle.ts index 233f94af..a7b5759c 100644 --- a/src/main/runtime/jellyfin-remote-session-lifecycle.ts +++ b/src/main/runtime/jellyfin-remote-session-lifecycle.ts @@ -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);