From 3c66ea6b304179fadb2e2bf15b01a4f5d77958c3 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 1 Mar 2026 23:28:03 -0800 Subject: [PATCH] fix(jellyfin): preserve discover resume position on remote play --- .../runtime/jellyfin-playback-launch.test.ts | 40 +++++++++++++++++++ src/main/runtime/jellyfin-playback-launch.ts | 18 ++++++++- .../runtime/jellyfin-remote-commands.test.ts | 28 +++++++++++++ src/main/runtime/jellyfin-remote-commands.ts | 8 +++- 4 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/jellyfin-playback-launch.test.ts b/src/main/runtime/jellyfin-playback-launch.test.ts index 5aa8fe4..17dfc78 100644 --- a/src/main/runtime/jellyfin-playback-launch.test.ts +++ b/src/main/runtime/jellyfin-playback-launch.test.ts @@ -107,3 +107,43 @@ test('playback handler drives mpv commands and playback state', async () => { assert.equal(reportPayloads.length, 1); assert.equal(reportPayloads[0]?.eventName, 'start'); }); + +test('playback handler applies start override to stream url for remote resume', async () => { + const commands: Array> = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true, send: () => {} }), + resolvePlaybackPlan: async () => ({ + url: 'https://stream.example/video.m3u8?api_key=token', + mode: 'transcode', + title: 'Episode 2', + startTimeTicks: 0, + audioStreamIndex: null, + subtitleStreamIndex: null, + }), + applyJellyfinMpvDefaults: () => {}, + sendMpvCommand: (command) => commands.push(command), + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: () => {}, + showMpvOsd: () => {}, + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-2', + startTimeTicksOverride: 55_000_000, + }); + + assert.equal(commands[1]?.[0], 'loadfile'); + const loadedUrl = String(commands[1]?.[1] ?? ''); + const parsed = new URL(loadedUrl); + assert.equal(parsed.searchParams.get('StartTimeTicks'), '55000000'); + assert.deepEqual(commands[4], ['seek', 5.5, 'absolute+exact']); +}); diff --git a/src/main/runtime/jellyfin-playback-launch.ts b/src/main/runtime/jellyfin-playback-launch.ts index 915106d..ed681bb 100644 --- a/src/main/runtime/jellyfin-playback-launch.ts +++ b/src/main/runtime/jellyfin-playback-launch.ts @@ -16,6 +16,21 @@ type ActivePlaybackState = { playMethod: 'DirectPlay' | 'Transcode'; }; +function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?: number): string { + if (typeof startTimeTicksOverride !== 'number') return url; + try { + const resolved = new URL(url); + if (startTimeTicksOverride > 0) { + resolved.searchParams.set('StartTimeTicks', String(Math.max(0, startTimeTicksOverride))); + } else { + resolved.searchParams.delete('StartTimeTicks'); + } + return resolved.toString(); + } catch { + return url; + } +} + export function createPlayJellyfinItemInMpvHandler(deps: { ensureMpvConnectedForPlayback: () => Promise; getMpvClient: () => MpvRuntimeClientLike | null; @@ -78,7 +93,8 @@ export function createPlayJellyfinItemInMpvHandler(deps: { deps.applyJellyfinMpvDefaults(mpvClient); deps.sendMpvCommand(['set_property', 'sub-auto', 'no']); - deps.sendMpvCommand(['loadfile', plan.url, 'replace']); + const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride); + deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']); if (params.setQuitOnDisconnectArm !== false) { deps.armQuitOnDisconnect(); } diff --git a/src/main/runtime/jellyfin-remote-commands.test.ts b/src/main/runtime/jellyfin-remote-commands.test.ts index b3586db..1cffeef 100644 --- a/src/main/runtime/jellyfin-remote-commands.test.ts +++ b/src/main/runtime/jellyfin-remote-commands.test.ts @@ -52,6 +52,34 @@ test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', a assert.deepEqual(calls, [{ itemId: 'item-1', audio: 3, subtitle: 7, start: 1000 }]); }); +test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async () => { + const calls: Array<{ itemId: string; start?: number }> = []; + const handlePlay = createHandleJellyfinRemotePlay({ + getConfiguredSession: () => ({ + serverUrl: 'https://jellyfin.local', + accessToken: 'token', + userId: 'user', + username: 'name', + }), + getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }), + getJellyfinConfig: () => ({ enabled: true }), + playJellyfinItem: async (params) => { + calls.push({ + itemId: params.itemId, + start: params.startTimeTicksOverride, + }); + }, + logWarn: () => {}, + }); + + await handlePlay({ + ItemIds: ['item-2'], + StartPositionTicks: '12345', + }); + + assert.deepEqual(calls, [{ itemId: 'item-2', start: 12345 }]); +}); + test('createHandleJellyfinRemotePlay logs and skips payload without item id', async () => { const warnings: string[] = []; const handlePlay = createHandleJellyfinRemotePlay({ diff --git a/src/main/runtime/jellyfin-remote-commands.ts b/src/main/runtime/jellyfin-remote-commands.ts index ec6f777..1737391 100644 --- a/src/main/runtime/jellyfin-remote-commands.ts +++ b/src/main/runtime/jellyfin-remote-commands.ts @@ -27,8 +27,12 @@ type JellyfinConfigLike = { }; function asInteger(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isInteger(value)) return undefined; - return value; + if (typeof value === 'number' && Number.isSafeInteger(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value.trim()); + if (Number.isSafeInteger(parsed)) return parsed; + } + return undefined; } export function getConfiguredJellyfinSession(config: JellyfinConfigLike): JellyfinSession | null {