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