fix(jellyfin): support modern authentication (#264)

This commit is contained in:
2026-09-21 20:40:57 -07:00
committed by GitHub
parent 1508863dbb
commit 42d1384c68
22 changed files with 581 additions and 66 deletions
@@ -7,6 +7,7 @@ import {
createHandleJellyfinRemoteGeneralCommand,
createHandleJellyfinRemotePlay,
createHandleJellyfinRemotePlaystate,
createJellyfinRemoteReportTracker,
createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler,
} from '../domains/jellyfin';
@@ -91,13 +92,17 @@ export function composeJellyfinRemoteHandlers(
getNow: options.getNow,
ticksPerSecond: options.ticksPerSecond,
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({
@@ -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) } : {}),
});
}
@@ -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',
]);
});
+43 -4
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 = {
@@ -203,6 +233,8 @@ export type JellyfinRemoteStoppedReporterDeps = {
getNow?: () => number;
ticksPerSecond: number;
logDebug: (message: string, error: unknown) => void;
logWarn?: (message: string) => void;
reportTracker?: JellyfinRemoteReportTracker;
};
export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) {
@@ -226,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);
@@ -244,7 +280,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,10 +290,13 @@ 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 {
deps.clearActivePlayback();
}
};
}
@@ -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);