fix(jellyfin): fix remote progress sync, seek reporting, and startup sto

- arm active playback before loadfile with loadedMediaPath: null to suppress premature stop events
- force immediate progress report on seek-like position jumps at the mpv time-pos level
- send positionTicks and failed=false in reportStopped payload
- remove EventName from HTTP timeline payloads (websocket-only field)
- add startup grace window to drop stop events before media finishes loading
This commit is contained in:
2026-05-22 23:01:08 -07:00
parent 27e3d956c9
commit 49a94579b6
16 changed files with 419 additions and 30 deletions
+38
View File
@@ -289,6 +289,44 @@ test('reportProgress posts timeline payload and treats failure as non-fatal', as
assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload);
});
test('timeline payload omits websocket-only event names', () => {
const payload = buildJellyfinTimelinePayload({
itemId: 'movie-2',
positionTicks: 123456,
eventName: 'TimeUpdate',
});
assert.equal('EventName' in payload, false);
});
test('reportStopped posts final position and explicit non-failed state', async () => {
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-stop-payload',
deviceId: 'device-stop-payload',
webSocketFactory: () => new FakeWebSocket() as unknown as any,
fetchImpl: (async (input, init) => {
fetchCalls.push({ input: String(input), init: init ?? {} });
return new Response(null, { status: 200 });
}) as typeof fetch,
});
const ok = await service.reportStopped({
itemId: 'movie-stop',
positionTicks: 7654321,
failed: false,
});
const stoppedCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Stopped'));
assert.equal(ok, true);
assert.ok(stoppedCall);
assert.ok(typeof stoppedCall.init.body === 'string');
const posted = JSON.parse(String(stoppedCall.init.body));
assert.equal(posted.PositionTicks, 7654321);
assert.equal(posted.Failed, false);
});
test('advertiseNow validates server registration using Sessions endpoint', async () => {
const sockets: FakeWebSocket[] = [];
const calls: string[] = [];
+5 -7
View File
@@ -20,6 +20,7 @@ export interface JellyfinTimelinePlaybackState {
subtitleStreamIndex?: number | null;
playlistItemId?: string | null;
eventName?: string;
failed?: boolean;
}
export interface JellyfinTimelinePayload {
@@ -36,7 +37,7 @@ export interface JellyfinTimelinePayload {
AudioStreamIndex?: number | null;
SubtitleStreamIndex?: number | null;
PlaylistItemId?: string | null;
EventName: string;
Failed?: boolean;
}
interface JellyfinRemoteSocket {
@@ -168,7 +169,7 @@ export function buildJellyfinTimelinePayload(
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
PlaylistItemId: state.playlistItemId,
EventName: state.eventName || 'timeupdate',
Failed: state.failed,
};
}
@@ -269,10 +270,7 @@ export class JellyfinRemoteSessionService {
}
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing', {
...buildJellyfinTimelinePayload(state),
EventName: state.eventName || 'start',
});
return this.postTimeline('/Sessions/Playing', buildJellyfinTimelinePayload(state));
}
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
@@ -282,7 +280,7 @@ export class JellyfinRemoteSessionService {
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing/Stopped', {
...buildJellyfinTimelinePayload(state),
EventName: state.eventName || 'stop',
Failed: state.failed === true,
});
}