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.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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user