fix(jellyfin): make terminal play work with a running app and report progress (#288)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-10 17:27:48 -07:00
1 parent 78eca52b7e
commit 78a9b6346f
33 files changed
+934 -924

No files matched your search

@@ -44,7 +44,6 @@ export type JellyfinRemoteComposerOptions = ComposerInputs<{
>[0]['jellyfinTicksToSeconds'];
getActivePlayback: JellyfinRemoteGeneralMainDeps['getActivePlayback'];
clearActivePlayback: JellyfinRemoteProgressMainDeps['clearActivePlayback'];
getSession: JellyfinRemoteProgressMainDeps['getSession'];
getNow: JellyfinRemoteProgressMainDeps['getNow'];
getLastProgressAtMs: Parameters<
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
@@ -74,7 +73,6 @@ export function composeJellyfinRemoteHandlers(
createBuildReportJellyfinRemoteProgressMainDepsHandler({
getActivePlayback: options.getActivePlayback,
clearActivePlayback: options.clearActivePlayback,
getSession: options.getSession,
getMpvClient: options.getMpvClient,
getNow: options.getNow,
getLastProgressAtMs: options.getLastProgressAtMs,
@@ -87,7 +85,6 @@ export function composeJellyfinRemoteHandlers(
createBuildReportJellyfinRemoteStoppedMainDepsHandler({
getActivePlayback: options.getActivePlayback,
clearActivePlayback: options.clearActivePlayback,
getSession: options.getSession,
getMpvClient: options.getMpvClient,
getNow: options.getNow,
ticksPerSecond: options.ticksPerSecond,
+22 -13
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHandleJellyfinListCommands } from './jellyfin-cli-list';
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
const baseSession = {
serverUrl: 'http://localhost',
@@ -24,7 +25,7 @@ test('list handler no-ops when no list command is set', async () => {
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
writeJellyfinResponse: () => {},
logInfo: () => {},
});
@@ -42,13 +43,14 @@ test('list handler no-ops when no list command is set', async () => {
assert.equal(handled, false);
});
test('list handler logs libraries', async () => {
test('list handler logs libraries and writes them to the response path', async () => {
const logs: string[] = [];
const writes: Array<{ path: string; response: JellyfinCliResponse }> = [];
const handler = createHandleJellyfinListCommands({
listJellyfinLibraries: async () => [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
writeJellyfinResponse: (path, response) => writes.push({ path, response }),
logInfo: (message) => logs.push(message),
});
@@ -57,6 +59,7 @@ test('list handler logs libraries', async () => {
jellyfinLibraries: true,
jellyfinItems: false,
jellyfinSubtitles: false,
jellyfinResponsePath: '/tmp/reply.json',
} as never,
session: baseSession,
clientInfo: baseClientInfo,
@@ -65,6 +68,12 @@ test('list handler logs libraries', async () => {
assert.equal(handled, true);
assert.ok(logs.some((line) => line.includes('Jellyfin library: Anime [lib1] (tvshows)')));
assert.deepEqual(writes, [
{
path: '/tmp/reply.json',
response: { libraries: [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }] },
},
]);
});
test('list handler resolves items using default library id', async () => {
@@ -72,6 +81,7 @@ test('list handler resolves items using default library id', async () => {
let usedRecursive: boolean | undefined;
let usedIncludeItemTypes: string | undefined;
const logs: string[] = [];
const writes: JellyfinCliResponse[] = [];
const handler = createHandleJellyfinListCommands({
listJellyfinLibraries: async () => [],
listJellyfinItems: async (_session, _clientInfo, params) => {
@@ -81,7 +91,7 @@ test('list handler resolves items using default library id', async () => {
return [{ id: 'item1', title: 'Episode 1', type: 'Episode' }];
},
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
writeJellyfinResponse: (_path, response) => writes.push(response),
logInfo: (message) => logs.push(message),
});
@@ -95,6 +105,7 @@ test('list handler resolves items using default library id', async () => {
jellyfinLimit: 10,
jellyfinRecursive: false,
jellyfinIncludeItemTypes: 'Series,Movie,Folder',
jellyfinResponsePath: '/tmp/reply.json',
} as never,
session: baseSession,
clientInfo: baseClientInfo,
@@ -108,6 +119,7 @@ test('list handler resolves items using default library id', async () => {
assert.equal(usedRecursive, false);
assert.equal(usedIncludeItemTypes, 'Series,Movie,Folder');
assert.ok(logs.some((line) => line.includes('Jellyfin item: Episode 1 [item1] (Episode)')));
assert.deepEqual(writes, [{ items: [{ id: 'item1', title: 'Episode 1', type: 'Episode' }] }]);
});
test('list handler throws when items command has no library id', async () => {
@@ -115,7 +127,7 @@ test('list handler throws when items command has no library id', async () => {
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
writeJellyfinResponse: () => {},
logInfo: () => {},
});
@@ -144,7 +156,7 @@ test('list handler logs subtitle urls only when requested', async () => {
{ index: 1, deliveryUrl: 'http://localhost/sub1.srt', language: 'eng' },
{ index: 2, language: 'jpn' },
],
writeJellyfinPreviewAuth: () => {},
writeJellyfinResponse: () => {},
logInfo: (message) => logs.push(message),
});
@@ -170,7 +182,7 @@ test('list handler throws when subtitle command has no item id', async () => {
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
writeJellyfinResponse: () => {},
logInfo: () => {},
});
@@ -190,16 +202,13 @@ test('list handler throws when subtitle command has no item id', async () => {
});
test('list handler writes preview auth payload to response path', async () => {
const writes: Array<{
path: string;
payload: { serverUrl: string; accessToken: string; userId: string };
}> = [];
const writes: Array<{ path: string; payload: JellyfinCliResponse }> = [];
const logs: string[] = [];
const handler = createHandleJellyfinListCommands({
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: (responsePath, payload) => {
writeJellyfinResponse: (responsePath, payload) => {
writes.push({ path: responsePath, payload });
},
logInfo: (message) => logs.push(message),
@@ -234,7 +243,7 @@ test('list handler throws when preview auth command has no response path', async
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
writeJellyfinResponse: () => {},
logInfo: () => {},
});
+18 -9
View File
@@ -1,4 +1,5 @@
import type { CliArgs } from '../../cli/args';
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
type JellyfinSession = {
serverUrl: string;
@@ -17,12 +18,6 @@ type JellyfinConfig = {
defaultLibraryId: string;
};
type JellyfinPreviewAuthPayload = {
serverUrl: string;
accessToken: string;
userId: string;
};
export function createHandleJellyfinListCommands(deps: {
listJellyfinLibraries: (
session: JellyfinSession,
@@ -56,7 +51,7 @@ export function createHandleJellyfinListCommands(deps: {
deliveryUrl?: string | null;
}>
>;
writeJellyfinPreviewAuth: (responsePath: string, payload: JellyfinPreviewAuthPayload) => void;
writeJellyfinResponse: (responsePath: string, response: JellyfinCliResponse) => void;
logInfo: (message: string) => void;
}) {
return async (params: {
@@ -66,13 +61,13 @@ export function createHandleJellyfinListCommands(deps: {
jellyfinConfig: JellyfinConfig;
}): Promise<boolean> => {
const { args, session, clientInfo, jellyfinConfig } = params;
const responsePath = args.jellyfinResponsePath?.trim();
if (args.jellyfinPreviewAuth) {
const responsePath = args.jellyfinResponsePath?.trim();
if (!responsePath) {
throw new Error('Missing --jellyfin-response-path for --jellyfin-preview-auth.');
}
deps.writeJellyfinPreviewAuth(responsePath, {
deps.writeJellyfinResponse(responsePath, {
serverUrl: session.serverUrl,
accessToken: session.accessToken,
userId: session.userId,
@@ -83,6 +78,15 @@ export function createHandleJellyfinListCommands(deps: {
if (args.jellyfinLibraries) {
const libraries = await deps.listJellyfinLibraries(session, clientInfo);
if (responsePath) {
deps.writeJellyfinResponse(responsePath, {
libraries: libraries.map((library) => ({
id: library.id,
name: library.name,
collectionType: library.collectionType || library.type || 'unknown',
})),
});
}
if (libraries.length === 0) {
deps.logInfo('No Jellyfin libraries found.');
return true;
@@ -109,6 +113,11 @@ export function createHandleJellyfinListCommands(deps: {
recursive: args.jellyfinRecursive,
includeItemTypes: args.jellyfinIncludeItemTypes,
});
if (responsePath) {
deps.writeJellyfinResponse(responsePath, {
items: items.map((item) => ({ id: item.id, title: item.title, type: item.type })),
});
}
if (items.length === 0) {
deps.logInfo('No Jellyfin items found for the selected library/search.');
return true;
+2 -2
View File
@@ -32,8 +32,8 @@ export function createBuildHandleJellyfinListCommandsMainDepsHandler(
deps.listJellyfinItems(session, clientInfo, params),
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
deps.listJellyfinSubtitleTracks(session, clientInfo, itemId),
writeJellyfinPreviewAuth: (responsePath, payload) =>
deps.writeJellyfinPreviewAuth(responsePath, payload),
writeJellyfinResponse: (responsePath, response) =>
deps.writeJellyfinResponse(responsePath, response),
logInfo: (message: string) => deps.logInfo(message),
});
}
@@ -1,4 +1,5 @@
import type { CliArgs } from '../../cli/args';
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
type JellyfinConfigBase = {
serverUrl?: string;
@@ -37,6 +38,7 @@ export type RunJellyfinCommandMainDeps<TClientInfo, TConfig extends JellyfinConf
clientInfo: TClientInfo;
jellyfinConfig: TConfig;
}) => Promise<boolean>;
writeJellyfinResponse: (responsePath: string, response: JellyfinCliResponse) => void;
};
export function createBuildRunJellyfinCommandMainDepsHandler<
@@ -51,5 +53,7 @@ export function createBuildRunJellyfinCommandMainDepsHandler<
handleRemoteAnnounceCommand: (args: CliArgs) => deps.handleRemoteAnnounceCommand(args),
handleListCommands: (params) => deps.handleListCommands(params),
handlePlayCommand: (params) => deps.handlePlayCommand(params),
writeJellyfinResponse: (responsePath, response) =>
deps.writeJellyfinResponse(responsePath, response),
});
}
@@ -66,6 +66,7 @@ test('run jellyfin command returns after auth branch handles command', async ()
calls.push('list');
return false;
},
writeJellyfinResponse: () => {},
handlePlayCommand: async () => {
calls.push('play');
return false;
@@ -76,7 +77,8 @@ test('run jellyfin command returns after auth branch handles command', async ()
assert.deepEqual(calls, ['auth']);
});
test('run jellyfin command throws when session missing after auth', async () => {
test('run jellyfin command throws when session missing and writes the error reply', async () => {
const writes: Array<{ path: string; response: unknown }> = [];
const run = createRunJellyfinCommandHandler({
getJellyfinConfig: () => ({ serverUrl: '', accessToken: '', userId: '' }),
defaultServerUrl: '',
@@ -84,10 +86,20 @@ test('run jellyfin command throws when session missing after auth', async () =>
handleAuthCommands: async () => false,
handleRemoteAnnounceCommand: async () => false,
handleListCommands: async () => false,
writeJellyfinResponse: (path, response) => writes.push({ path, response }),
handlePlayCommand: async () => false,
});
await assert.rejects(() => run(createArgs()), /Missing Jellyfin session/);
await assert.rejects(
() => run(createArgs({ jellyfinResponsePath: '/tmp/reply.json' })),
/Missing Jellyfin session/,
);
assert.deepEqual(writes, [
{
path: '/tmp/reply.json',
response: { error: 'Missing Jellyfin session. Run --jellyfin-login first.' },
},
]);
});
test('run jellyfin command dispatches remote/list/play in order until handled', async () => {
@@ -116,6 +128,7 @@ test('run jellyfin command dispatches remote/list/play in order until handled',
seenServerUrls.push(session.serverUrl);
return true;
},
writeJellyfinResponse: () => {},
handlePlayCommand: async () => {
calls.push('play');
return false;
+22 -1
View File
@@ -1,4 +1,5 @@
import type { CliArgs } from '../../cli/args';
import type { JellyfinCliResponse } from '../../shared/jellyfin-cli-response';
type JellyfinConfigBase = {
serverUrl?: string;
@@ -40,8 +41,9 @@ export function createRunJellyfinCommandHandler<
clientInfo: TClientInfo;
jellyfinConfig: TConfig;
}) => Promise<boolean>;
writeJellyfinResponse: (responsePath: string, response: JellyfinCliResponse) => void;
}) {
return async (args: CliArgs): Promise<void> => {
const run = async (args: CliArgs): Promise<void> => {
const jellyfinConfig = deps.getJellyfinConfig();
const serverUrl =
args.jellyfinServer?.trim() || jellyfinConfig.serverUrl || deps.defaultServerUrl;
@@ -97,4 +99,23 @@ export function createRunJellyfinCommandHandler<
return;
}
};
// A launcher waiting on --jellyfin-response-path gets the failure reason instead of a timeout.
return async (args: CliArgs): Promise<void> => {
try {
await run(args);
} catch (error) {
const responsePath = args.jellyfinResponsePath?.trim();
if (responsePath) {
try {
deps.writeJellyfinResponse(responsePath, {
error: error instanceof Error ? error.message : String(error),
});
} catch {
// Keep the original failure; the launcher falls back to its timeout.
}
}
throw error;
}
};
}
@@ -18,7 +18,7 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
preloadExternalSubtitles: (params) => deps.preloadExternalSubtitles(params),
setActivePlayback: (state) => deps.setActivePlayback(state),
setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value),
reportPlaying: (payload) => deps.reportPlaying(payload),
createPlaybackReporter: (params) => deps.createPlaybackReporter(params),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { JellyfinPlaybackPlan } from '../../core/services/jellyfin';
import type { JellyfinTimelinePlaybackState } from '../../core/services/jellyfin-playback-reporter';
import { createPlayJellyfinItemInMpvHandler } from './jellyfin-playback-launch';
type Deps = Parameters<typeof createPlayJellyfinItemInMpvHandler>[0];
@@ -46,7 +47,13 @@ function makeHarness(options: { plan?: Partial<JellyfinPlaybackPlan>; deps?: Par
const commands: Array<Array<string | number>> = [];
const events: string[] = [];
const activeStates: Array<Parameters<Deps['setActivePlayback']>[0]> = [];
const reports: Array<Parameters<Deps['reportPlaying']>[0]> = [];
const reports: JellyfinTimelinePlaybackState[] = [];
const reporterParams: Array<Parameters<Deps['createPlaybackReporter']>[0]> = [];
const reporter = {
reportPlaying: async (state: JellyfinTimelinePlaybackState) => void reports.push(state),
reportProgress: async () => {},
reportStopped: async () => {},
};
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
@@ -67,7 +74,10 @@ function makeHarness(options: { plan?: Partial<JellyfinPlaybackPlan>; deps?: Par
events.push(`active:${String(state.loadedMediaPath)}`);
},
setLastProgressAtMs: () => {},
reportPlaying: (payload) => reports.push(payload),
createPlaybackReporter: (params) => {
reporterParams.push(params);
return reporter;
},
showMpvOsd: () => {},
...options.deps,
});
@@ -77,6 +87,8 @@ function makeHarness(options: { plan?: Partial<JellyfinPlaybackPlan>; deps?: Par
events,
activeStates,
reports,
reporter,
reporterParams,
play: (params: Partial<PlayParams> = {}) =>
handler({
session: baseSession,
@@ -141,6 +153,8 @@ test('playback handler disables mpv subtitle selection, then loads media and rep
assert.equal(harness.activeStates.length, 1);
assert.equal(harness.activeStates[0]?.playMethod, 'DirectPlay');
assert.equal(harness.activeStates[0]?.lastKnownPositionSeconds, 1.2);
assert.equal(harness.activeStates[0]?.reporter, harness.reporter);
assert.deepEqual(harness.reporterParams, [{ session: baseSession, clientInfo: baseClientInfo }]);
assert.equal(harness.reports.length, 1);
assert.equal(harness.reports[0]?.eventName, 'start');
assert.equal(harness.reports[0]?.positionTicks, 12_000_000);
+14 -11
View File
@@ -1,6 +1,7 @@
import type { JellyfinAuthSession, JellyfinPlaybackPlan } from '../../core/services/jellyfin';
import type { JellyfinConfig } from '../../types';
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
import type { JellyfinPlaybackReporterLike } from './jellyfin-remote-commands';
type JellyfinClientInfo = {
clientName: string;
@@ -10,6 +11,7 @@ type JellyfinClientInfo = {
type ActivePlaybackState = {
itemId: string;
reporter: JellyfinPlaybackReporterLike;
mediaSourceId: undefined;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
@@ -134,16 +136,12 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
}) => void | Promise<void>;
setActivePlayback: (state: ActivePlaybackState) => void;
setLastProgressAtMs: (value: number) => void;
reportPlaying: (payload: {
itemId: string;
mediaSourceId: undefined;
playMethod: 'DirectPlay' | 'Transcode';
positionTicks?: number;
isPaused?: boolean;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
eventName: 'start';
}) => void;
// Reports go out as the device and account that started this playback, for CLI play and
// casting alike, so Jellyfin keeps the resume point and played state either way.
createPlaybackReporter: (params: {
session: JellyfinAuthSession;
clientInfo: JellyfinClientInfo;
}) => JellyfinPlaybackReporterLike;
showMpvOsd: (text: string) => void;
recordJellyfinPlaybackMetadata?: (
metadata: JellyfinPlaybackStatsMetadata,
@@ -210,8 +208,13 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
itemId: params.itemId,
}),
);
const reporter = deps.createPlaybackReporter({
session: params.session,
clientInfo: params.clientInfo,
});
deps.setActivePlayback({
itemId: params.itemId,
reporter,
mediaSourceId: undefined,
audioStreamIndex: plan.audioStreamIndex,
subtitleStreamIndex: plan.subtitleStreamIndex,
@@ -237,7 +240,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
);
deps.showVisibleOverlay();
deps.reportPlaying({
void reporter.reportPlaying({
itemId: params.itemId,
mediaSourceId: undefined,
playMethod,
@@ -9,6 +9,12 @@ import {
type JellyfinRemotePlayHandlerDeps,
} from './jellyfin-remote-commands';
const noopReporter = {
reportPlaying: async () => {},
reportProgress: async () => {},
reportStopped: async () => {},
};
test('getConfiguredJellyfinSession returns null for incomplete config', () => {
assert.equal(
getConfiguredJellyfinSession({
@@ -122,7 +128,11 @@ test('createHandleJellyfinRemotePlay logs and skips payload without item id', as
test('createHandleJellyfinRemotePlay ignores duplicate play for active item', async () => {
const { handlePlay, played } = makePlayHandler({
getActivePlayback: () => ({ itemId: 'item-1', playMethod: 'DirectPlay' }),
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
reporter: noopReporter,
}),
});
await handlePlay({ ItemIds: ['item-1'] });
@@ -207,6 +217,7 @@ test('createHandleJellyfinRemoteGeneralCommand mutates active playback indices',
const playback: ActiveJellyfinRemotePlaybackState = {
itemId: 'item-1',
playMethod: 'DirectPlay',
reporter: noopReporter,
audioStreamIndex: null,
subtitleStreamIndex: null,
};
@@ -1,5 +1,18 @@
import type { JellyfinTimelinePlaybackState } from '../../core/services/jellyfin-playback-reporter';
type JellyfinTimelineReport = (state: JellyfinTimelinePlaybackState) => Promise<unknown>;
export type JellyfinPlaybackReporterLike = {
reportPlaying: JellyfinTimelineReport;
reportProgress: JellyfinTimelineReport;
reportStopped: JellyfinTimelineReport;
};
export type ActiveJellyfinRemotePlaybackState = {
itemId: string;
// Bound at launch to the session that started playback, so reports flow for CLI play and
// casting alike, independent of the cast-control websocket.
reporter: JellyfinPlaybackReporterLike;
mediaSourceId?: string;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
@@ -53,7 +53,6 @@ export function createBuildReportJellyfinRemoteProgressMainDepsHandler(
return (): JellyfinRemoteProgressReporterDeps => ({
getActivePlayback: () => deps.getActivePlayback(),
clearActivePlayback: () => deps.clearActivePlayback(),
getSession: () => deps.getSession(),
getMpvClient: () => deps.getMpvClient(),
getNow: () => deps.getNow(),
getLastProgressAtMs: () => deps.getLastProgressAtMs(),
@@ -70,7 +69,6 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
return (): JellyfinRemoteStoppedReporterDeps => ({
getActivePlayback: () => deps.getActivePlayback(),
clearActivePlayback: () => deps.clearActivePlayback(),
getSession: () => deps.getSession(),
getMpvClient: () => deps.getMpvClient(),
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
ticksPerSecond: deps.ticksPerSecond,
+103 -167
View File
@@ -8,26 +8,31 @@ import {
secondsToJellyfinTicks,
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
} from './jellyfin-remote-playback';
import type { ActiveJellyfinRemotePlaybackState } from './jellyfin-remote-commands';
import type {
ActiveJellyfinRemotePlaybackState,
JellyfinPlaybackReporterLike,
} from './jellyfin-remote-commands';
type ProgressDeps = Parameters<typeof createReportJellyfinRemoteProgressHandler>[0];
type StoppedDeps = Parameters<typeof createReportJellyfinRemoteStoppedHandler>[0];
type Session = NonNullable<ReturnType<ProgressDeps['getSession']>>;
const TICKS_PER_SECOND = 10_000_000;
const makeReporter = (
overrides: Partial<JellyfinPlaybackReporterLike> = {},
): JellyfinPlaybackReporterLike => ({
reportPlaying: async () => {},
reportProgress: async () => {},
reportStopped: async () => {},
...overrides,
});
const makePlayback = (
overrides: Partial<ActiveJellyfinRemotePlaybackState> = {},
): ActiveJellyfinRemotePlaybackState => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
...overrides,
});
const makeSession = (overrides: Partial<Session> = {}): Session => ({
isConnected: () => true,
reportProgress: async () => {},
reportStopped: async () => {},
reporter: makeReporter(),
...overrides,
});
@@ -35,7 +40,6 @@ function makeProgressDeps(overrides: Partial<ProgressDeps> = {}): ProgressDeps {
return {
getActivePlayback: () => makePlayback(),
clearActivePlayback: () => {},
getSession: () => makeSession(),
getMpvClient: () => ({ requestProperty: async () => 1 }),
getNow: () => 5000,
getLastProgressAtMs: () => 0,
@@ -51,7 +55,6 @@ function makeStoppedDeps(overrides: Partial<StoppedDeps> = {}): StoppedDeps {
return {
getActivePlayback: () => makePlayback({ loadedMediaPath: 'https://stream.example/video.m3u8' }),
clearActivePlayback: () => {},
getSession: () => makeSession(),
getMpvClient: () => ({ currentTimePos: 0 }),
ticksPerSecond: TICKS_PER_SECOND,
logDebug: () => {},
@@ -69,29 +72,21 @@ test('shouldAutoLoadSecondarySubTrackForJellyfinPlayback suppresses generic seco
assert.equal(shouldAutoLoadSecondarySubTrackForJellyfinPlayback(null, '/tmp/local.mkv'), true);
assert.equal(
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
{ itemId: 'item-1', playMethod: 'DirectPlay', loadedMediaPath: null },
makePlayback({ 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',
},
makePlayback({ 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',
},
makePlayback({ loadedMediaPath: 'http://pve-main:8096/Videos/item/stream' }),
'/tmp/local.mkv',
),
true,
@@ -100,19 +95,21 @@ test('shouldAutoLoadSecondarySubTrackForJellyfinPlayback suppresses generic seco
test('createReportJellyfinRemoteProgressHandler reports playback progress', async () => {
let lastProgressAtMs = 0;
const reportPayloads: Array<{ itemId: string; positionTicks: number; isPaused: boolean }> = [];
const reportPayloads: Array<{ itemId: string; positionTicks?: number; isPaused?: boolean }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler(
makeProgressDeps({
getSession: () =>
makeSession({
reportProgress: async (payload) => {
reportPayloads.push({
itemId: payload.itemId,
positionTicks: payload.positionTicks,
isPaused: payload.isPaused,
});
},
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),
@@ -135,43 +132,18 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
assert.equal(lastProgressAtMs, 5000);
});
test('createReportJellyfinRemoteProgressHandler reports while remote websocket is disconnected', async () => {
const reportPayloads: Array<{ positionTicks: number; isPaused: boolean }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler(
makeProgressDeps({
getSession: () =>
makeSession({
isConnected: () => false,
reportProgress: async (payload) => {
reportPayloads.push({
positionTicks: payload.positionTicks,
isPaused: payload.isPaused,
});
},
}),
getMpvClient: () => ({
currentTimePos: 42,
requestProperty: async (name: string) => (name === 'pause' ? false : 42),
}),
}),
);
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 reportPayloads: Array<{ isPaused?: boolean }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler(
makeProgressDeps({
getSession: () =>
makeSession({
reportProgress: async (payload) => {
reportPayloads.push({ isPaused: payload.isPaused });
},
getActivePlayback: () =>
makePlayback({
reporter: makeReporter({
reportProgress: async (payload) => {
reportPayloads.push({ isPaused: payload.isPaused });
},
}),
}),
getMpvClient: () => ({
requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3),
@@ -188,11 +160,13 @@ test('createReportJellyfinRemoteProgressHandler respects debounce interval', asy
let called = false;
const reportProgress = createReportJellyfinRemoteProgressHandler(
makeProgressDeps({
getSession: () =>
makeSession({
reportProgress: async () => {
called = true;
},
getActivePlayback: () =>
makePlayback({
reporter: makeReporter({
reportProgress: async () => {
called = true;
},
}),
}),
getNow: () => 4000,
getLastProgressAtMs: () => 3500,
@@ -207,18 +181,20 @@ test('createReportJellyfinRemoteProgressHandler reports mpv seek jumps during de
let now = 5000;
let lastProgressAtMs = 0;
let position = 10;
const reportPayloads: Array<{ positionTicks: number; eventName: string }> = [];
const reportPayloads: Array<{ positionTicks?: number; eventName?: string }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler(
makeProgressDeps({
getSession: () =>
makeSession({
reportProgress: async (payload) => {
reportPayloads.push({
positionTicks: payload.positionTicks,
eventName: payload.eventName,
});
},
getActivePlayback: () =>
makePlayback({
reporter: makeReporter({
reportProgress: async (payload) => {
reportPayloads.push({
positionTicks: payload.positionTicks,
eventName: payload.eventName,
});
},
}),
}),
getMpvClient: () => ({
currentTimePos: position,
@@ -253,20 +229,23 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
} | null = null;
const reportStopped = createReportJellyfinRemoteStoppedHandler(
makeStoppedDeps({
getActivePlayback: () => makePlayback({ itemId: 'item-2', playMethod: 'Transcode' }),
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;
},
getSession: () =>
makeSession({
reportStopped: async (payload) => {
stoppedPayload = {
itemId: payload.itemId,
positionTicks: payload.positionTicks,
failed: payload.failed,
};
},
}),
getMpvClient: () => ({
currentTimePos: 12.5,
requestProperty: async () => {
@@ -289,16 +268,18 @@ test('createReportJellyfinRemoteStoppedHandler clears aborted playback that neve
let cleared = false;
const reportStopped = createReportJellyfinRemoteStoppedHandler(
makeStoppedDeps({
getActivePlayback: () => makePlayback({ loadedMediaPath: null }),
getActivePlayback: () =>
makePlayback({
loadedMediaPath: null,
reporter: makeReporter({
reportStopped: async () => {
throw new Error('should not report stopped for unloaded media');
},
}),
}),
clearActivePlayback: () => {
cleared = true;
},
getSession: () =>
makeSession({
reportStopped: async () => {
throw new Error('should not report stopped for unloaded media');
},
}),
getMpvClient: () => null,
}),
);
@@ -308,43 +289,6 @@ test('createReportJellyfinRemoteStoppedHandler clears aborted playback that neve
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(
makeStoppedDeps({
clearActivePlayback: () => {
cleared = true;
},
getSession: () =>
makeSession({
isConnected: () => false,
reportStopped: async (payload) => {
stoppedPayload = {
itemId: payload.itemId,
positionTicks: payload.positionTicks,
failed: payload.failed,
};
},
}),
getMpvClient: () => ({ currentTimePos: 12.5 }),
}),
);
await reportStopped();
assert.deepEqual(stoppedPayload, {
itemId: 'item-1',
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 }> = [];
@@ -354,19 +298,18 @@ test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv un
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;
},
getSession: () =>
makeSession({
reportProgress: async (payload) => {
calls.push({ event: 'progress', positionTicks: payload.positionTicks });
},
reportStopped: async (payload) => {
calls.push({ event: 'stopped', positionTicks: payload.positionTicks });
},
}),
}),
);
@@ -423,16 +366,15 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before
makePlayback({
loadedMediaPath: 'https://stream.example/video.m3u8',
stopReportsAfterMs: 20_000,
reporter: makeReporter({
reportStopped: async () => {
stopped = true;
},
}),
}),
clearActivePlayback: () => {
cleared = true;
},
getSession: () =>
makeSession({
reportStopped: async () => {
stopped = true;
},
}),
getNow: () => 12_000,
}),
);
@@ -445,34 +387,28 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before
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;
},
};
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;
},
getSession: () => session,
getMpvClient: () => ({ currentTimePos: 42 }),
ticksPerSecond: 10_000_000,
logDebug: () => undefined,
+3 -38
View File
@@ -1,29 +1,5 @@
import type { ActiveJellyfinRemotePlaybackState } from './jellyfin-remote-commands';
type JellyfinRemoteSessionLike = {
isConnected: () => boolean;
reportProgress: (payload: {
itemId: string;
mediaSourceId?: string;
positionTicks: number;
isPaused: boolean;
playMethod: 'DirectPlay' | 'Transcode';
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
eventName: 'TimeUpdate';
}) => Promise<unknown>;
reportStopped: (payload: {
itemId: string;
mediaSourceId?: string;
positionTicks?: number;
failed?: boolean;
playMethod: 'DirectPlay' | 'Transcode';
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
eventName: 'stop';
}) => Promise<unknown>;
};
type MpvClientLike = {
currentTimePos?: number;
requestProperty?: (name: string) => Promise<unknown>;
@@ -160,7 +136,6 @@ export function createJellyfinRemoteReportTracker(): JellyfinRemoteReportTracker
export type JellyfinRemoteProgressReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void;
getSession: () => JellyfinRemoteSessionLike | null;
getMpvClient: () => MpvClientLike | null;
getNow: () => number;
getLastProgressAtMs: () => number;
@@ -179,9 +154,6 @@ export function createReportJellyfinRemoteProgressHandler(
const report = async (force: boolean): Promise<void> => {
const playback = deps.getActivePlayback();
if (!playback) return;
const session = deps.getSession();
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
if (!session) return;
const now = deps.getNow();
try {
const mpvClient = deps.getMpvClient();
@@ -201,7 +173,7 @@ export function createReportJellyfinRemoteProgressHandler(
return;
}
const paused = await mpvClient?.requestProperty?.('pause');
await session.reportProgress({
await playback.reporter.reportProgress({
itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId,
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
@@ -228,7 +200,6 @@ export function createReportJellyfinRemoteProgressHandler(
export type JellyfinRemoteStoppedReporterDeps = {
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
clearActivePlayback: () => void;
getSession: () => JellyfinRemoteSessionLike | null;
getMpvClient: () => MpvClientLike | null;
getNow?: () => number;
ticksPerSecond: number;
@@ -252,12 +223,6 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
) {
return;
}
const session = deps.getSession();
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
if (!session) {
deps.clearActivePlayback();
return;
}
// Clear before any network call so progress ticks fired during the stop find nothing to
// report, then let reports already in flight finish so none can arrive after the stop.
deps.clearActivePlayback();
@@ -267,7 +232,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
const positionTicks = secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond);
try {
await session.reportProgress({
await playback.reporter.reportProgress({
itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId,
positionTicks,
@@ -280,7 +245,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
} catch (error) {
deps.logDebug('Failed to report Jellyfin remote final progress', error);
}
const reported = await session.reportStopped({
const reported = await playback.reporter.reportStopped({
itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId,
positionTicks,