mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 06:22:42 -08:00
fix(jellyfin): preserve discover resume position on remote play
This commit is contained in:
@@ -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<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']);
|
||||
});
|
||||
|
||||
@@ -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<boolean>;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user