mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 05:16:23 -07:00
599 lines
17 KiB
TypeScript
599 lines
17 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
markJellyfinRemotePlaybackLoaded,
|
|
createJellyfinRemoteReportTracker,
|
|
createReportJellyfinRemoteProgressHandler,
|
|
createReportJellyfinRemoteStoppedHandler,
|
|
secondsToJellyfinTicks,
|
|
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
|
} from './jellyfin-remote-playback';
|
|
|
|
test('secondsToJellyfinTicks converts seconds and clamps invalid values', () => {
|
|
assert.equal(secondsToJellyfinTicks(1.25, 10_000_000), 12_500_000);
|
|
assert.equal(secondsToJellyfinTicks(-3, 10_000_000), 0);
|
|
assert.equal(secondsToJellyfinTicks(Number.NaN, 10_000_000), 0);
|
|
});
|
|
|
|
test('shouldAutoLoadSecondarySubTrackForJellyfinPlayback suppresses generic secondary autoload for active Jellyfin media', () => {
|
|
assert.equal(shouldAutoLoadSecondarySubTrackForJellyfinPlayback(null, '/tmp/local.mkv'), true);
|
|
assert.equal(
|
|
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
|
{ itemId: 'item-1', playMethod: 'DirectPlay', loadedMediaPath: null },
|
|
'http://pve-main:8096/Videos/item/stream',
|
|
),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
|
{
|
|
itemId: 'item-1',
|
|
playMethod: 'DirectPlay',
|
|
loadedMediaPath: 'http://pve-main:8096/Videos/item/stream',
|
|
},
|
|
'http://pve-main:8096/Videos/item/stream',
|
|
),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
|
{
|
|
itemId: 'item-1',
|
|
playMethod: 'DirectPlay',
|
|
loadedMediaPath: 'http://pve-main:8096/Videos/item/stream',
|
|
},
|
|
'/tmp/local.mkv',
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('createReportJellyfinRemoteProgressHandler reports playback progress', async () => {
|
|
let lastProgressAtMs = 0;
|
|
const reportPayloads: Array<{ itemId: string; positionTicks: number; isPaused: boolean }> = [];
|
|
|
|
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
|
getActivePlayback: () => ({
|
|
itemId: 'item-1',
|
|
mediaSourceId: undefined,
|
|
playMethod: 'DirectPlay',
|
|
audioStreamIndex: 1,
|
|
subtitleStreamIndex: 2,
|
|
}),
|
|
clearActivePlayback: () => {},
|
|
getSession: () => ({
|
|
isConnected: () => true,
|
|
reportProgress: async (payload) => {
|
|
reportPayloads.push({
|
|
itemId: payload.itemId,
|
|
positionTicks: payload.positionTicks,
|
|
isPaused: payload.isPaused,
|
|
});
|
|
},
|
|
reportStopped: async () => {},
|
|
}),
|
|
getMpvClient: () => ({
|
|
requestProperty: async (name: string) => (name === 'time-pos' ? 2.5 : true),
|
|
}),
|
|
getNow: () => 5000,
|
|
getLastProgressAtMs: () => lastProgressAtMs,
|
|
setLastProgressAtMs: (value) => {
|
|
lastProgressAtMs = value;
|
|
},
|
|
progressIntervalMs: 3000,
|
|
ticksPerSecond: 10_000_000,
|
|
logDebug: () => {},
|
|
});
|
|
|
|
await reportProgress(true);
|
|
|
|
assert.deepEqual(reportPayloads, [
|
|
{
|
|
itemId: 'item-1',
|
|
positionTicks: 25_000_000,
|
|
isPaused: true,
|
|
},
|
|
]);
|
|
assert.equal(lastProgressAtMs, 5000);
|
|
});
|
|
|
|
test('createReportJellyfinRemoteProgressHandler reports while remote websocket is disconnected', async () => {
|
|
const reportPayloads: Array<{ positionTicks: number; isPaused: boolean }> = [];
|
|
|
|
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
|
getActivePlayback: () => ({
|
|
itemId: 'item-1',
|
|
playMethod: 'DirectPlay',
|
|
}),
|
|
clearActivePlayback: () => {},
|
|
getSession: () => ({
|
|
isConnected: () => false,
|
|
reportProgress: async (payload) => {
|
|
reportPayloads.push({
|
|
positionTicks: payload.positionTicks,
|
|
isPaused: payload.isPaused,
|
|
});
|
|
},
|
|
reportStopped: async () => {},
|
|
}),
|
|
getMpvClient: () => ({
|
|
currentTimePos: 42,
|
|
requestProperty: async (name: string) => (name === 'pause' ? false : 42),
|
|
}),
|
|
getNow: () => 5000,
|
|
getLastProgressAtMs: () => 0,
|
|
setLastProgressAtMs: () => {},
|
|
progressIntervalMs: 3000,
|
|
ticksPerSecond: 10_000_000,
|
|
logDebug: () => {},
|
|
});
|
|
|
|
await reportProgress(true);
|
|
|
|
assert.deepEqual(reportPayloads, [{ positionTicks: 420_000_000, isPaused: false }]);
|
|
});
|
|
|
|
test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => {
|
|
const reportPayloads: Array<{ isPaused: boolean }> = [];
|
|
|
|
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
|
getActivePlayback: () => ({
|
|
itemId: 'item-1',
|
|
playMethod: 'DirectPlay',
|
|
}),
|
|
clearActivePlayback: () => {},
|
|
getSession: () => ({
|
|
isConnected: () => true,
|
|
reportProgress: async (payload) => {
|
|
reportPayloads.push({ isPaused: payload.isPaused });
|
|
},
|
|
reportStopped: async () => {},
|
|
}),
|
|
getMpvClient: () => ({
|
|
requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3),
|
|
}),
|
|
getNow: () => 5000,
|
|
getLastProgressAtMs: () => 0,
|
|
setLastProgressAtMs: () => {},
|
|
progressIntervalMs: 3000,
|
|
ticksPerSecond: 10_000_000,
|
|
logDebug: () => {},
|
|
});
|
|
|
|
await reportProgress(true);
|
|
|
|
assert.deepEqual(reportPayloads, [{ isPaused: true }]);
|
|
});
|
|
|
|
test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => {
|
|
let called = false;
|
|
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
|
getActivePlayback: () => ({
|
|
itemId: 'item-1',
|
|
playMethod: 'DirectPlay',
|
|
}),
|
|
clearActivePlayback: () => {},
|
|
getSession: () => ({
|
|
isConnected: () => true,
|
|
reportProgress: async () => {
|
|
called = true;
|
|
},
|
|
reportStopped: async () => {},
|
|
}),
|
|
getMpvClient: () => ({
|
|
requestProperty: async () => 1,
|
|
}),
|
|
getNow: () => 4000,
|
|
getLastProgressAtMs: () => 3500,
|
|
setLastProgressAtMs: () => {},
|
|
progressIntervalMs: 3000,
|
|
ticksPerSecond: 10_000_000,
|
|
logDebug: () => {},
|
|
});
|
|
|
|
await reportProgress(false);
|
|
assert.equal(called, false);
|
|
});
|
|
|
|
test('createReportJellyfinRemoteProgressHandler reports mpv seek jumps during debounce', async () => {
|
|
let now = 5000;
|
|
let lastProgressAtMs = 0;
|
|
let position = 10;
|
|
const reportPayloads: Array<{ positionTicks: number; eventName: string }> = [];
|
|
|
|
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
|
getActivePlayback: () => ({
|
|
itemId: 'item-1',
|
|
playMethod: 'DirectPlay',
|
|
}),
|
|
clearActivePlayback: () => {},
|
|
getSession: () => ({
|
|
isConnected: () => true,
|
|
reportProgress: async (payload) => {
|
|
reportPayloads.push({
|
|
positionTicks: payload.positionTicks,
|
|
eventName: payload.eventName,
|
|
});
|
|
},
|
|
reportStopped: async () => {},
|
|
}),
|
|
getMpvClient: () => ({
|
|
currentTimePos: position,
|
|
requestProperty: async (name: string) => (name === 'pause' ? false : position),
|
|
}),
|
|
getNow: () => now,
|
|
getLastProgressAtMs: () => lastProgressAtMs,
|
|
setLastProgressAtMs: (value) => {
|
|
lastProgressAtMs = value;
|
|
},
|
|
progressIntervalMs: 3000,
|
|
ticksPerSecond: 10_000_000,
|
|
logDebug: () => {},
|
|
});
|
|
|
|
await reportProgress(true);
|
|
now = 5500;
|
|
position = 90;
|
|
await reportProgress(false);
|
|
|
|
assert.deepEqual(reportPayloads, [
|
|
{ positionTicks: 100_000_000, eventName: 'TimeUpdate' },
|
|
{ positionTicks: 900_000_000, eventName: 'TimeUpdate' },
|
|
]);
|
|
assert.equal(lastProgressAtMs, 5500);
|
|
});
|
|
|
|
test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback', async () => {
|
|
let cleared = false;
|
|
let stoppedPayload: {
|
|
itemId: string;
|
|
positionTicks?: number;
|
|
failed?: boolean;
|
|
} | null = null;
|
|
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
|
getActivePlayback: () => ({
|
|
itemId: 'item-2',
|
|
mediaSourceId: undefined,
|
|
playMethod: 'Transcode',
|
|
audioStreamIndex: null,
|
|
subtitleStreamIndex: null,
|
|
}),
|
|
clearActivePlayback: () => {
|
|
cleared = true;
|
|
},
|
|
getSession: () => ({
|
|
isConnected: () => true,
|
|
reportProgress: async () => {},
|
|
reportStopped: async (payload) => {
|
|
stoppedPayload = {
|
|
itemId: payload.itemId,
|
|
positionTicks: payload.positionTicks,
|
|
failed: payload.failed,
|
|
};
|
|
},
|
|
}),
|
|
getMpvClient: () => ({
|
|
currentTimePos: 12.5,
|
|
requestProperty: async () => {
|
|
throw new Error('unloaded');
|
|
},
|
|
}),
|
|
ticksPerSecond: 10_000_000,
|
|
logDebug: () => {},
|
|
});
|
|
|
|
await reportStopped();
|
|
assert.deepEqual(stoppedPayload, {
|
|
itemId: 'item-2',
|
|
positionTicks: 125_000_000,
|
|
failed: false,
|
|
});
|
|
assert.equal(cleared, true);
|
|
});
|
|
|
|
test('createReportJellyfinRemoteStoppedHandler clears aborted playback that never loaded', async () => {
|
|
let cleared = false;
|
|
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
|
getActivePlayback: () => ({
|
|
itemId: 'item-2',
|
|
mediaSourceId: undefined,
|
|
playMethod: 'Transcode',
|
|
audioStreamIndex: null,
|
|
subtitleStreamIndex: null,
|
|
loadedMediaPath: null,
|
|
}),
|
|
clearActivePlayback: () => {
|
|
cleared = true;
|
|
},
|
|
getSession: () => ({
|
|
isConnected: () => true,
|
|
reportProgress: async () => {},
|
|
reportStopped: async () => {
|
|
throw new Error('should not report stopped for unloaded media');
|
|
},
|
|
}),
|
|
getMpvClient: () => null,
|
|
ticksPerSecond: 10_000_000,
|
|
logDebug: () => {},
|
|
});
|
|
|
|
await reportStopped();
|
|
|
|
assert.equal(cleared, true);
|
|
});
|
|
|
|
test('createReportJellyfinRemoteStoppedHandler reports stop while remote websocket is disconnected', async () => {
|
|
let cleared = false;
|
|
let stoppedPayload: {
|
|
itemId: string;
|
|
positionTicks?: number;
|
|
failed?: boolean;
|
|
} | null = null;
|
|
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
|
getActivePlayback: () => ({
|
|
itemId: 'item-2',
|
|
mediaSourceId: undefined,
|
|
playMethod: 'Transcode',
|
|
audioStreamIndex: null,
|
|
subtitleStreamIndex: null,
|
|
loadedMediaPath: 'https://stream.example/video.m3u8',
|
|
}),
|
|
clearActivePlayback: () => {
|
|
cleared = true;
|
|
},
|
|
getSession: () => ({
|
|
isConnected: () => false,
|
|
reportProgress: async () => {},
|
|
reportStopped: async (payload) => {
|
|
stoppedPayload = {
|
|
itemId: payload.itemId,
|
|
positionTicks: payload.positionTicks,
|
|
failed: payload.failed,
|
|
};
|
|
},
|
|
}),
|
|
getMpvClient: () => ({
|
|
currentTimePos: 12.5,
|
|
}),
|
|
ticksPerSecond: 10_000_000,
|
|
logDebug: () => {},
|
|
});
|
|
|
|
await reportStopped();
|
|
|
|
assert.deepEqual(stoppedPayload, {
|
|
itemId: 'item-2',
|
|
positionTicks: 125_000_000,
|
|
failed: false,
|
|
});
|
|
assert.equal(cleared, true);
|
|
});
|
|
|
|
test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv unload reset', async () => {
|
|
let cleared = false;
|
|
const calls: Array<{ event: string; positionTicks?: number }> = [];
|
|
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
|
getActivePlayback: () =>
|
|
({
|
|
itemId: 'item-2',
|
|
mediaSourceId: undefined,
|
|
playMethod: 'DirectPlay',
|
|
audioStreamIndex: null,
|
|
subtitleStreamIndex: null,
|
|
loadedMediaPath: 'https://stream.example/video.m3u8',
|
|
lastKnownPositionSeconds: 72.25,
|
|
}) as never,
|
|
clearActivePlayback: () => {
|
|
cleared = true;
|
|
},
|
|
getSession: () => ({
|
|
isConnected: () => true,
|
|
reportProgress: async (payload) => {
|
|
calls.push({ event: 'progress', positionTicks: payload.positionTicks });
|
|
},
|
|
reportStopped: async (payload) => {
|
|
calls.push({ event: 'stopped', positionTicks: payload.positionTicks });
|
|
},
|
|
}),
|
|
getMpvClient: () => ({
|
|
currentTimePos: 0,
|
|
}),
|
|
ticksPerSecond: 10_000_000,
|
|
logDebug: () => {},
|
|
});
|
|
|
|
await reportStopped();
|
|
|
|
assert.deepEqual(calls, [
|
|
{ event: 'progress', positionTicks: 722_500_000 },
|
|
{ event: 'stopped', positionTicks: 722_500_000 },
|
|
]);
|
|
assert.equal(cleared, true);
|
|
});
|
|
|
|
test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback', async () => {
|
|
let cleared = false;
|
|
let stopped = false;
|
|
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
|
getActivePlayback: () =>
|
|
({
|
|
itemId: 'item-2',
|
|
playMethod: 'Transcode',
|
|
loadedMediaPath: null,
|
|
}) as never,
|
|
clearActivePlayback: () => {
|
|
cleared = true;
|
|
},
|
|
getSession: () => ({
|
|
isConnected: () => true,
|
|
reportProgress: async () => {},
|
|
reportStopped: async () => {
|
|
stopped = true;
|
|
},
|
|
}),
|
|
getMpvClient: () => ({
|
|
currentTimePos: 0,
|
|
}),
|
|
ticksPerSecond: 10_000_000,
|
|
logDebug: () => {},
|
|
});
|
|
|
|
await reportStopped();
|
|
|
|
assert.equal(stopped, false);
|
|
assert.equal(cleared, true);
|
|
});
|
|
|
|
test('createReportJellyfinRemoteProgressHandler caches last nonzero mpv position', async () => {
|
|
let position = 42;
|
|
let lastProgressAtMs = 0;
|
|
const playback = {
|
|
itemId: 'item-1',
|
|
playMethod: 'DirectPlay' as const,
|
|
};
|
|
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
|
getActivePlayback: () => playback,
|
|
clearActivePlayback: () => {},
|
|
getSession: () => ({
|
|
isConnected: () => true,
|
|
reportProgress: async () => {},
|
|
reportStopped: async () => {},
|
|
}),
|
|
getMpvClient: () => ({
|
|
currentTimePos: position,
|
|
requestProperty: async (name: string) => (name === 'pause' ? false : position),
|
|
}),
|
|
getNow: () => 5000,
|
|
getLastProgressAtMs: () => lastProgressAtMs,
|
|
setLastProgressAtMs: (value) => {
|
|
lastProgressAtMs = value;
|
|
},
|
|
progressIntervalMs: 3000,
|
|
ticksPerSecond: 10_000_000,
|
|
logDebug: () => {},
|
|
});
|
|
|
|
await reportProgress(true);
|
|
position = 0;
|
|
await reportProgress(true);
|
|
|
|
assert.equal((playback as { lastKnownPositionSeconds?: number }).lastKnownPositionSeconds, 42);
|
|
});
|
|
|
|
test('markJellyfinRemotePlaybackLoaded preserves the loaded marker on unload paths', () => {
|
|
const playback = {
|
|
itemId: 'item-2',
|
|
playMethod: 'Transcode' as const,
|
|
loadedMediaPath: 'https://stream.example/video.m3u8',
|
|
};
|
|
|
|
markJellyfinRemotePlaybackLoaded(playback, '');
|
|
markJellyfinRemotePlaybackLoaded(playback, ' ');
|
|
assert.equal(playback.loadedMediaPath, 'https://stream.example/video.m3u8');
|
|
|
|
markJellyfinRemotePlaybackLoaded(playback, ' https://stream.example/next.m3u8 ');
|
|
assert.equal(playback.loadedMediaPath, 'https://stream.example/next.m3u8');
|
|
});
|
|
|
|
test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before grace expires', async () => {
|
|
let cleared = false;
|
|
let stopped = false;
|
|
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
|
getActivePlayback: () =>
|
|
({
|
|
itemId: 'item-2',
|
|
playMethod: 'DirectPlay',
|
|
loadedMediaPath: 'https://stream.example/video.m3u8',
|
|
stopReportsAfterMs: 20_000,
|
|
}) as never,
|
|
clearActivePlayback: () => {
|
|
cleared = true;
|
|
},
|
|
getSession: () => ({
|
|
isConnected: () => true,
|
|
reportProgress: async () => {},
|
|
reportStopped: async () => {
|
|
stopped = true;
|
|
},
|
|
}),
|
|
getMpvClient: () => ({
|
|
currentTimePos: 0,
|
|
}),
|
|
getNow: () => 12_000,
|
|
ticksPerSecond: 10_000_000,
|
|
logDebug: () => {},
|
|
});
|
|
|
|
await reportStopped();
|
|
|
|
assert.equal(stopped, false);
|
|
assert.equal(cleared, false);
|
|
});
|
|
|
|
test('createReportJellyfinRemoteStoppedHandler clears playback before reporting and waits for in-flight progress', async () => {
|
|
const tracker = createJellyfinRemoteReportTracker();
|
|
let playback: { itemId: string; playMethod: 'DirectPlay'; loadedMediaPath: string } | null = {
|
|
itemId: 'item-1',
|
|
playMethod: 'DirectPlay',
|
|
loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream',
|
|
};
|
|
const calls: string[] = [];
|
|
let releaseProgress: () => void = () => undefined;
|
|
const progressGate = new Promise<void>((resolve) => {
|
|
releaseProgress = resolve;
|
|
});
|
|
const session = {
|
|
isConnected: () => true,
|
|
reportProgress: async ({ eventName }: { eventName: string }) => {
|
|
calls.push(`progress:${eventName}:${playback ? 'active' : 'cleared'}`);
|
|
if (calls.length === 1) await progressGate;
|
|
return true;
|
|
},
|
|
reportStopped: async () => {
|
|
calls.push(`stopped:${playback ? 'active' : 'cleared'}`);
|
|
return true;
|
|
},
|
|
};
|
|
const shared = {
|
|
getActivePlayback: () => playback,
|
|
clearActivePlayback: () => {
|
|
playback = null;
|
|
},
|
|
getSession: () => session,
|
|
getMpvClient: () => ({ currentTimePos: 42 }),
|
|
ticksPerSecond: 10_000_000,
|
|
logDebug: () => undefined,
|
|
reportTracker: tracker,
|
|
};
|
|
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
|
...shared,
|
|
getNow: () => 10_000,
|
|
getLastProgressAtMs: () => 0,
|
|
setLastProgressAtMs: () => undefined,
|
|
progressIntervalMs: 3000,
|
|
});
|
|
const reportStopped = createReportJellyfinRemoteStoppedHandler(shared);
|
|
|
|
// A periodic tick is mid-request when the stop starts.
|
|
const tick = reportProgress(true);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
|
|
const stop = reportStopped();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.equal(playback, null);
|
|
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
|
|
|
|
// A tick fired after the stop began must not report anything.
|
|
await reportProgress(true);
|
|
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
|
|
|
|
releaseProgress();
|
|
await tick;
|
|
await stop;
|
|
assert.deepEqual(calls, [
|
|
'progress:TimeUpdate:active',
|
|
'progress:TimeUpdate:cleared',
|
|
'stopped:cleared',
|
|
]);
|
|
});
|