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
+2
View File
@@ -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. - 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. - 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.
+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(await service.reportStopped(state), false);
assert.equal(warnings.length, 2); 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) => { socket.on('message', (rawData) => {
if (this.socket !== socket || !this.running) return;
this.lastInboundAtMs = this.now(); this.lastInboundAtMs = this.now();
this.handleInboundMessage(socket, rawData); this.handleInboundMessage(socket, rawData);
}); });
@@ -7,6 +7,7 @@ import {
createHandleJellyfinRemoteGeneralCommand, createHandleJellyfinRemoteGeneralCommand,
createHandleJellyfinRemotePlay, createHandleJellyfinRemotePlay,
createHandleJellyfinRemotePlaystate, createHandleJellyfinRemotePlaystate,
createJellyfinRemoteReportTracker,
createReportJellyfinRemoteProgressHandler, createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler, createReportJellyfinRemoteStoppedHandler,
} from '../domains/jellyfin'; } from '../domains/jellyfin';
@@ -93,12 +94,15 @@ export function composeJellyfinRemoteHandlers(
logDebug: options.logDebug, logDebug: options.logDebug,
logWarn: options.logWarn, logWarn: options.logWarn,
}); });
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler( const reportTracker = createJellyfinRemoteReportTracker();
buildReportJellyfinRemoteProgressMainDepsHandler(), const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler({
); ...buildReportJellyfinRemoteProgressMainDepsHandler(),
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler( reportTracker,
buildReportJellyfinRemoteStoppedMainDepsHandler(), });
); const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler({
...buildReportJellyfinRemoteStoppedMainDepsHandler(),
reportTracker,
});
const buildHandleJellyfinRemotePlayMainDepsHandler = const buildHandleJellyfinRemotePlayMainDepsHandler =
createBuildHandleJellyfinRemotePlayMainDepsHandler({ createBuildHandleJellyfinRemotePlayMainDepsHandler({
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
markJellyfinRemotePlaybackLoaded, markJellyfinRemotePlaybackLoaded,
createJellyfinRemoteReportTracker,
createReportJellyfinRemoteProgressHandler, createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler, createReportJellyfinRemoteStoppedHandler,
secondsToJellyfinTicks, secondsToJellyfinTicks,
@@ -528,3 +529,70 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before
assert.equal(stopped, false); assert.equal(stopped, false);
assert.equal(cleared, 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; 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 = { export type JellyfinRemoteProgressReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null; getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void; clearActivePlayback: () => void;
@@ -145,6 +168,7 @@ export type JellyfinRemoteProgressReporterDeps = {
progressIntervalMs: number; progressIntervalMs: number;
ticksPerSecond: number; ticksPerSecond: number;
logDebug: (message: string, error: unknown) => void; logDebug: (message: string, error: unknown) => void;
reportTracker?: JellyfinRemoteReportTracker;
}; };
export function createReportJellyfinRemoteProgressHandler( export function createReportJellyfinRemoteProgressHandler(
@@ -152,7 +176,7 @@ export function createReportJellyfinRemoteProgressHandler(
) { ) {
let lastReportedPositionSeconds: number | null = null; let lastReportedPositionSeconds: number | null = null;
return async (force = false): Promise<void> => { const report = async (force: boolean): Promise<void> => {
const playback = deps.getActivePlayback(); const playback = deps.getActivePlayback();
if (!playback) return; if (!playback) return;
const session = deps.getSession(); const session = deps.getSession();
@@ -193,6 +217,12 @@ export function createReportJellyfinRemoteProgressHandler(
deps.logDebug('Failed to report Jellyfin remote progress', error); 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 = { export type JellyfinRemoteStoppedReporterDeps = {
@@ -204,6 +234,7 @@ export type JellyfinRemoteStoppedReporterDeps = {
ticksPerSecond: number; ticksPerSecond: number;
logDebug: (message: string, error: unknown) => void; logDebug: (message: string, error: unknown) => void;
logWarn?: (message: string) => void; logWarn?: (message: string) => void;
reportTracker?: JellyfinRemoteReportTracker;
}; };
export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) { export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) {
@@ -227,6 +258,10 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
deps.clearActivePlayback(); deps.clearActivePlayback();
return; 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 { try {
const observedPositionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient()); const observedPositionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds); const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
@@ -262,8 +297,6 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
} }
} catch (error) { } catch (error) {
deps.logDebug('Failed to report Jellyfin remote stop', error); deps.logDebug('Failed to report Jellyfin remote stop', error);
} finally {
deps.clearActivePlayback();
} }
}; };
} }