diff --git a/changes/fix-jellyfin-modern-auth.md b/changes/fix-jellyfin-modern-auth.md index f4180fb7..7b64d059 100644 --- a/changes/fix-jellyfin-modern-auth.md +++ b/changes/fix-jellyfin-modern-auth.md @@ -3,3 +3,5 @@ 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. + +- Send the playback stop report only after any in-flight progress report has finished and stop reporting progress the moment playback ends, so the Jellyfin "now playing" bar clears when you close or finish a cast video instead of running on to the end of the episode. diff --git a/src/core/services/jellyfin-remote.test.ts b/src/core/services/jellyfin-remote.test.ts index 69c358cd..e3433976 100644 --- a/src/core/services/jellyfin-remote.test.ts +++ b/src/core/services/jellyfin-remote.test.ts @@ -478,3 +478,38 @@ test('warns once per failing timeline endpoint until it recovers', async () => { assert.equal(await service.reportStopped(state), false); assert.equal(warnings.length, 2); }); + +test('ignores messages from a superseded socket', () => { + const sockets: FakeWebSocket[] = []; + const playPayloads: unknown[] = []; + + const service = new JellyfinRemoteSessionService({ + serverUrl: 'http://jellyfin.local', + accessToken: 'token-stale', + deviceId: 'device-stale', + webSocketFactory: () => { + const socket = new FakeWebSocket(); + sockets.push(socket); + return socket as unknown as any; + }, + fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch, + onPlay: (payload) => { + playPayloads.push(payload); + }, + setTimer: (() => 1 as unknown as ReturnType) as unknown as typeof setTimeout, + clearTimer: (() => undefined) as typeof clearTimeout, + }); + + service.start(); + service.stop(); + service.start(); + sockets[1]!.emit('open'); + assert.equal(sockets.length, 2); + + sockets[0]!.emit('message', JSON.stringify({ MessageType: 'ForceKeepAlive', Data: 10 })); + sockets[0]!.emit('message', JSON.stringify({ MessageType: 'Play', Data: { ItemIds: ['x'] } })); + + assert.deepEqual(sockets[0]!.sent, []); + assert.deepEqual(playPayloads, []); + assert.deepEqual(sockets[1]!.sent, ['{"MessageType":"KeepAlive"}']); +}); diff --git a/src/core/services/jellyfin-remote.ts b/src/core/services/jellyfin-remote.ts index 0d484c6e..649f5a48 100644 --- a/src/core/services/jellyfin-remote.ts +++ b/src/core/services/jellyfin-remote.ts @@ -334,6 +334,7 @@ export class JellyfinRemoteSessionService { }); socket.on('message', (rawData) => { + if (this.socket !== socket || !this.running) return; this.lastInboundAtMs = this.now(); this.handleInboundMessage(socket, rawData); }); diff --git a/src/main/runtime/composers/jellyfin-remote-composer.ts b/src/main/runtime/composers/jellyfin-remote-composer.ts index 000ee004..f16fe964 100644 --- a/src/main/runtime/composers/jellyfin-remote-composer.ts +++ b/src/main/runtime/composers/jellyfin-remote-composer.ts @@ -7,6 +7,7 @@ import { createHandleJellyfinRemoteGeneralCommand, createHandleJellyfinRemotePlay, createHandleJellyfinRemotePlaystate, + createJellyfinRemoteReportTracker, createReportJellyfinRemoteProgressHandler, createReportJellyfinRemoteStoppedHandler, } from '../domains/jellyfin'; @@ -93,12 +94,15 @@ export function composeJellyfinRemoteHandlers( logDebug: options.logDebug, logWarn: options.logWarn, }); - const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler( - buildReportJellyfinRemoteProgressMainDepsHandler(), - ); - const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler( - buildReportJellyfinRemoteStoppedMainDepsHandler(), - ); + const reportTracker = createJellyfinRemoteReportTracker(); + const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler({ + ...buildReportJellyfinRemoteProgressMainDepsHandler(), + reportTracker, + }); + const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler({ + ...buildReportJellyfinRemoteStoppedMainDepsHandler(), + reportTracker, + }); const buildHandleJellyfinRemotePlayMainDepsHandler = createBuildHandleJellyfinRemotePlayMainDepsHandler({ diff --git a/src/main/runtime/jellyfin-remote-playback.test.ts b/src/main/runtime/jellyfin-remote-playback.test.ts index b227f5aa..919250e5 100644 --- a/src/main/runtime/jellyfin-remote-playback.test.ts +++ b/src/main/runtime/jellyfin-remote-playback.test.ts @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { markJellyfinRemotePlaybackLoaded, + createJellyfinRemoteReportTracker, createReportJellyfinRemoteProgressHandler, createReportJellyfinRemoteStoppedHandler, secondsToJellyfinTicks, @@ -528,3 +529,70 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before assert.equal(stopped, false); assert.equal(cleared, false); }); + +test('createReportJellyfinRemoteStoppedHandler clears playback before reporting and waits for in-flight progress', async () => { + const tracker = createJellyfinRemoteReportTracker(); + let playback: { itemId: string; playMethod: 'DirectPlay'; loadedMediaPath: string } | null = { + itemId: 'item-1', + playMethod: 'DirectPlay', + loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream', + }; + const calls: string[] = []; + let releaseProgress: () => void = () => undefined; + const progressGate = new Promise((resolve) => { + releaseProgress = resolve; + }); + const session = { + isConnected: () => true, + reportProgress: async ({ eventName }: { eventName: string }) => { + calls.push(`progress:${eventName}:${playback ? 'active' : 'cleared'}`); + if (calls.length === 1) await progressGate; + return true; + }, + reportStopped: async () => { + calls.push(`stopped:${playback ? 'active' : 'cleared'}`); + return true; + }, + }; + const shared = { + getActivePlayback: () => playback, + clearActivePlayback: () => { + playback = null; + }, + getSession: () => session, + getMpvClient: () => ({ currentTimePos: 42 }), + ticksPerSecond: 10_000_000, + logDebug: () => undefined, + reportTracker: tracker, + }; + const reportProgress = createReportJellyfinRemoteProgressHandler({ + ...shared, + getNow: () => 10_000, + getLastProgressAtMs: () => 0, + setLastProgressAtMs: () => undefined, + progressIntervalMs: 3000, + }); + const reportStopped = createReportJellyfinRemoteStoppedHandler(shared); + + // A periodic tick is mid-request when the stop starts. + const tick = reportProgress(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, ['progress:TimeUpdate:active']); + const stop = reportStopped(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(playback, null); + assert.deepEqual(calls, ['progress:TimeUpdate:active']); + + // A tick fired after the stop began must not report anything. + await reportProgress(true); + assert.deepEqual(calls, ['progress:TimeUpdate:active']); + + releaseProgress(); + await tick; + await stop; + assert.deepEqual(calls, [ + 'progress:TimeUpdate:active', + 'progress:TimeUpdate:cleared', + 'stopped:cleared', + ]); +}); diff --git a/src/main/runtime/jellyfin-remote-playback.ts b/src/main/runtime/jellyfin-remote-playback.ts index 54ebd8fa..3e49e452 100644 --- a/src/main/runtime/jellyfin-remote-playback.ts +++ b/src/main/runtime/jellyfin-remote-playback.ts @@ -134,6 +134,29 @@ function isSeekLikePositionJump( return Math.abs(nextPositionSeconds - previousPositionSeconds) >= thresholdSeconds; } +// Jellyfin re-creates a session's NowPlayingItem from any progress report, so a progress +// tick that lands after the stop report leaves the server showing playback forever. The +// tracker lets the stop handler wait for reports that are already in flight. +export type JellyfinRemoteReportTracker = { + track: (report: Promise) => void; + settled: () => Promise; +}; + +export function createJellyfinRemoteReportTracker(): JellyfinRemoteReportTracker { + const active = new Set>(); + return { + track: (report) => { + active.add(report); + void report.finally(() => active.delete(report)); + }, + settled: async () => { + while (active.size > 0) { + await Promise.allSettled([...active]); + } + }, + }; +} + export type JellyfinRemoteProgressReporterDeps = { getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null; clearActivePlayback: () => void; @@ -145,6 +168,7 @@ export type JellyfinRemoteProgressReporterDeps = { progressIntervalMs: number; ticksPerSecond: number; logDebug: (message: string, error: unknown) => void; + reportTracker?: JellyfinRemoteReportTracker; }; export function createReportJellyfinRemoteProgressHandler( @@ -152,7 +176,7 @@ export function createReportJellyfinRemoteProgressHandler( ) { let lastReportedPositionSeconds: number | null = null; - return async (force = false): Promise => { + const report = async (force: boolean): Promise => { const playback = deps.getActivePlayback(); if (!playback) return; const session = deps.getSession(); @@ -193,6 +217,12 @@ export function createReportJellyfinRemoteProgressHandler( deps.logDebug('Failed to report Jellyfin remote progress', error); } }; + + return async (force = false): Promise => { + const pending = report(force); + deps.reportTracker?.track(pending); + await pending; + }; } export type JellyfinRemoteStoppedReporterDeps = { @@ -204,6 +234,7 @@ export type JellyfinRemoteStoppedReporterDeps = { ticksPerSecond: number; logDebug: (message: string, error: unknown) => void; logWarn?: (message: string) => void; + reportTracker?: JellyfinRemoteReportTracker; }; export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) { @@ -227,6 +258,10 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto deps.clearActivePlayback(); return; } + // Clear before any network call so progress ticks fired during the stop find nothing to + // report, then let reports already in flight finish so none can arrive after the stop. + deps.clearActivePlayback(); + await deps.reportTracker?.settled(); try { const observedPositionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient()); const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds); @@ -262,8 +297,6 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto } } catch (error) { deps.logDebug('Failed to report Jellyfin remote stop', error); - } finally { - deps.clearActivePlayback(); } }; }