mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 05:16:23 -07:00
fix(jellyfin): support modern authentication (#264)
This commit is contained in:
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user