fix(jellyfin): preserve discover resume position on remote play

This commit is contained in:
2026-03-01 23:28:03 -08:00
parent 79f37f3986
commit 3c66ea6b30
4 changed files with 91 additions and 3 deletions

View File

@@ -107,3 +107,43 @@ test('playback handler drives mpv commands and playback state', async () => {
assert.equal(reportPayloads.length, 1); assert.equal(reportPayloads.length, 1);
assert.equal(reportPayloads[0]?.eventName, 'start'); assert.equal(reportPayloads[0]?.eventName, 'start');
}); });
test('playback handler applies start override to stream url for remote resume', async () => {
const commands: Array<Array<string | number>> = [];
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']);
});

View File

@@ -16,6 +16,21 @@ type ActivePlaybackState = {
playMethod: 'DirectPlay' | 'Transcode'; 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: { export function createPlayJellyfinItemInMpvHandler(deps: {
ensureMpvConnectedForPlayback: () => Promise<boolean>; ensureMpvConnectedForPlayback: () => Promise<boolean>;
getMpvClient: () => MpvRuntimeClientLike | null; getMpvClient: () => MpvRuntimeClientLike | null;
@@ -78,7 +93,8 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
deps.applyJellyfinMpvDefaults(mpvClient); deps.applyJellyfinMpvDefaults(mpvClient);
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']); 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) { if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect(); deps.armQuitOnDisconnect();
} }

View File

@@ -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 }]); 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 () => { test('createHandleJellyfinRemotePlay logs and skips payload without item id', async () => {
const warnings: string[] = []; const warnings: string[] = [];
const handlePlay = createHandleJellyfinRemotePlay({ const handlePlay = createHandleJellyfinRemotePlay({

View File

@@ -27,8 +27,12 @@ type JellyfinConfigLike = {
}; };
function asInteger(value: unknown): number | undefined { function asInteger(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isInteger(value)) return undefined; if (typeof value === 'number' && Number.isSafeInteger(value)) return 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 { export function getConfiguredJellyfinSession(config: JellyfinConfigLike): JellyfinSession | null {