import test from 'node:test'; import assert from 'node:assert/strict'; import { markJellyfinRemotePlaybackLoaded, createJellyfinRemoteReportTracker, createReportJellyfinRemoteProgressHandler, createReportJellyfinRemoteStoppedHandler, secondsToJellyfinTicks, shouldAutoLoadSecondarySubTrackForJellyfinPlayback, } from './jellyfin-remote-playback'; import type { ActiveJellyfinRemotePlaybackState, JellyfinPlaybackReporterLike, } from './jellyfin-remote-commands'; type ProgressDeps = Parameters[0]; type StoppedDeps = Parameters[0]; const TICKS_PER_SECOND = 10_000_000; const makeReporter = ( overrides: Partial = {}, ): JellyfinPlaybackReporterLike => ({ reportPlaying: async () => {}, reportProgress: async () => {}, reportStopped: async () => {}, ...overrides, }); const makePlayback = ( overrides: Partial = {}, ): ActiveJellyfinRemotePlaybackState => ({ itemId: 'item-1', playMethod: 'DirectPlay', reporter: makeReporter(), ...overrides, }); function makeProgressDeps(overrides: Partial = {}): ProgressDeps { return { getActivePlayback: () => makePlayback(), clearActivePlayback: () => {}, getMpvClient: () => ({ requestProperty: async () => 1 }), getNow: () => 5000, getLastProgressAtMs: () => 0, setLastProgressAtMs: () => {}, progressIntervalMs: 3000, ticksPerSecond: TICKS_PER_SECOND, logDebug: () => {}, ...overrides, }; } function makeStoppedDeps(overrides: Partial = {}): StoppedDeps { return { getActivePlayback: () => makePlayback({ loadedMediaPath: 'https://stream.example/video.m3u8' }), clearActivePlayback: () => {}, getMpvClient: () => ({ currentTimePos: 0 }), ticksPerSecond: TICKS_PER_SECOND, logDebug: () => {}, ...overrides, }; } 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( makePlayback({ loadedMediaPath: null }), 'http://pve-main:8096/Videos/item/stream', ), false, ); assert.equal( shouldAutoLoadSecondarySubTrackForJellyfinPlayback( makePlayback({ loadedMediaPath: 'http://pve-main:8096/Videos/item/stream' }), 'http://pve-main:8096/Videos/item/stream', ), false, ); assert.equal( shouldAutoLoadSecondarySubTrackForJellyfinPlayback( makePlayback({ 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( makeProgressDeps({ getActivePlayback: () => makePlayback({ reporter: makeReporter({ reportProgress: async (payload) => { reportPayloads.push({ itemId: payload.itemId, positionTicks: payload.positionTicks, isPaused: payload.isPaused, }); }, }), }), getMpvClient: () => ({ requestProperty: async (name: string) => (name === 'time-pos' ? 2.5 : true), }), setLastProgressAtMs: (value) => { lastProgressAtMs = value; }, }), ); await reportProgress(true); assert.deepEqual(reportPayloads, [ { itemId: 'item-1', positionTicks: 25_000_000, isPaused: true, }, ]); assert.equal(lastProgressAtMs, 5000); }); test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => { const reportPayloads: Array<{ isPaused?: boolean }> = []; const reportProgress = createReportJellyfinRemoteProgressHandler( makeProgressDeps({ getActivePlayback: () => makePlayback({ reporter: makeReporter({ reportProgress: async (payload) => { reportPayloads.push({ isPaused: payload.isPaused }); }, }), }), getMpvClient: () => ({ requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3), }), }), ); await reportProgress(true); assert.deepEqual(reportPayloads, [{ isPaused: true }]); }); test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => { let called = false; const reportProgress = createReportJellyfinRemoteProgressHandler( makeProgressDeps({ getActivePlayback: () => makePlayback({ reporter: makeReporter({ reportProgress: async () => { called = true; }, }), }), getNow: () => 4000, getLastProgressAtMs: () => 3500, }), ); 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( makeProgressDeps({ getActivePlayback: () => makePlayback({ reporter: makeReporter({ reportProgress: async (payload) => { reportPayloads.push({ positionTicks: payload.positionTicks, eventName: payload.eventName, }); }, }), }), getMpvClient: () => ({ currentTimePos: position, requestProperty: async (name: string) => (name === 'pause' ? false : position), }), getNow: () => now, getLastProgressAtMs: () => lastProgressAtMs, setLastProgressAtMs: (value) => { lastProgressAtMs = value; }, }), ); 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( makeStoppedDeps({ getActivePlayback: () => makePlayback({ itemId: 'item-2', playMethod: 'Transcode', reporter: makeReporter({ reportStopped: async (payload) => { stoppedPayload = { itemId: payload.itemId, positionTicks: payload.positionTicks, failed: payload.failed, }; }, }), }), clearActivePlayback: () => { cleared = true; }, getMpvClient: () => ({ currentTimePos: 12.5, requestProperty: async () => { throw new Error('unloaded'); }, }), }), ); 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( makeStoppedDeps({ getActivePlayback: () => makePlayback({ loadedMediaPath: null, reporter: makeReporter({ reportStopped: async () => { throw new Error('should not report stopped for unloaded media'); }, }), }), clearActivePlayback: () => { cleared = true; }, getMpvClient: () => null, }), ); await reportStopped(); 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( makeStoppedDeps({ getActivePlayback: () => makePlayback({ loadedMediaPath: 'https://stream.example/video.m3u8', lastKnownPositionSeconds: 72.25, reporter: makeReporter({ reportProgress: async (payload) => { calls.push({ event: 'progress', positionTicks: payload.positionTicks }); }, reportStopped: async (payload) => { calls.push({ event: 'stopped', positionTicks: payload.positionTicks }); }, }), }), clearActivePlayback: () => { cleared = true; }, }), ); await reportStopped(); assert.deepEqual(calls, [ { event: 'progress', positionTicks: 722_500_000 }, { event: 'stopped', positionTicks: 722_500_000 }, ]); assert.equal(cleared, true); }); test('createReportJellyfinRemoteProgressHandler caches last nonzero mpv position', async () => { let position = 42; const playback = makePlayback(); const reportProgress = createReportJellyfinRemoteProgressHandler( makeProgressDeps({ getActivePlayback: () => playback, getMpvClient: () => ({ currentTimePos: position, requestProperty: async (name: string) => (name === 'pause' ? false : position), }), }), ); await reportProgress(true); position = 0; await reportProgress(true); assert.equal(playback.lastKnownPositionSeconds, 42); }); test('markJellyfinRemotePlaybackLoaded preserves the loaded marker on unload paths', () => { const playback = makePlayback({ itemId: 'item-2', playMethod: 'Transcode', 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( makeStoppedDeps({ getActivePlayback: () => makePlayback({ loadedMediaPath: 'https://stream.example/video.m3u8', stopReportsAfterMs: 20_000, reporter: makeReporter({ reportStopped: async () => { stopped = true; }, }), }), clearActivePlayback: () => { cleared = true; }, getNow: () => 12_000, }), ); 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(); const calls: string[] = []; let releaseProgress: () => void = () => undefined; const progressGate = new Promise((resolve) => { releaseProgress = resolve; }); let playback: ActiveJellyfinRemotePlaybackState | null = makePlayback({ loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream', reporter: makeReporter({ reportProgress: async ({ eventName }) => { calls.push(`progress:${eventName}:${playback ? 'active' : 'cleared'}`); if (calls.length === 1) await progressGate; }, reportStopped: async () => { calls.push(`stopped:${playback ? 'active' : 'cleared'}`); }, }), }); const shared = { getActivePlayback: () => playback, clearActivePlayback: () => { playback = null; }, 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', ]); });