fix(jellyfin): stop stale playback reports after playback ends

- Wait for in-flight progress reports before sending the stop report
- Ignore messages from superseded Jellyfin sockets
This commit is contained in:
2026-09-21 09:01:24 -07:00
parent 0efdc5db15
commit f7e444ee2c
6 changed files with 152 additions and 9 deletions
+35
View File
@@ -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<typeof setTimeout>) 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"}']);
});
+1
View File
@@ -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);
});
@@ -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({
@@ -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<void>((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',
]);
});
+36 -3
View File
@@ -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>) => void;
settled: () => Promise<void>;
};
export function createJellyfinRemoteReportTracker(): JellyfinRemoteReportTracker {
const active = new Set<Promise<void>>();
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<void> => {
const report = async (force: boolean): Promise<void> => {
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<void> => {
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();
}
};
}