Jellyfin and Subsync Fixes (#13)

This commit is contained in:
2026-03-01 16:13:16 -08:00
committed by GitHub
parent 49434bf0cd
commit 7023a3263f
36 changed files with 2001 additions and 60 deletions

View File

@@ -111,6 +111,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: () => {},
},
handleJellyfinPlayCommandMainDeps: {

View File

@@ -24,6 +24,7 @@ test('list handler no-ops when no list command is set', async () => {
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: () => {},
});
@@ -47,6 +48,7 @@ test('list handler logs libraries', async () => {
listJellyfinLibraries: async () => [{ id: 'lib1', name: 'Anime', collectionType: 'tvshows' }],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: (message) => logs.push(message),
});
@@ -67,14 +69,19 @@ test('list handler logs libraries', async () => {
test('list handler resolves items using default library id', async () => {
let usedLibraryId = '';
let usedRecursive: boolean | undefined;
let usedIncludeItemTypes: string | undefined;
const logs: string[] = [];
const handler = createHandleJellyfinListCommands({
listJellyfinLibraries: async () => [],
listJellyfinItems: async (_session, _clientInfo, params) => {
usedLibraryId = params.libraryId;
usedRecursive = params.recursive;
usedIncludeItemTypes = params.includeItemTypes;
return [{ id: 'item1', title: 'Episode 1', type: 'Episode' }];
},
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: (message) => logs.push(message),
});
@@ -86,6 +93,8 @@ test('list handler resolves items using default library id', async () => {
jellyfinLibraryId: '',
jellyfinSearch: 'episode',
jellyfinLimit: 10,
jellyfinRecursive: false,
jellyfinIncludeItemTypes: 'Series,Movie,Folder',
} as never,
session: baseSession,
clientInfo: baseClientInfo,
@@ -96,6 +105,8 @@ test('list handler resolves items using default library id', async () => {
assert.equal(handled, true);
assert.equal(usedLibraryId, 'default-lib');
assert.equal(usedRecursive, false);
assert.equal(usedIncludeItemTypes, 'Series,Movie,Folder');
assert.ok(logs.some((line) => line.includes('Jellyfin item: Episode 1 [item1] (Episode)')));
});
@@ -104,6 +115,7 @@ test('list handler throws when items command has no library id', async () => {
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: () => {},
});
@@ -132,6 +144,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: () => {},
logInfo: (message) => logs.push(message),
});
@@ -157,6 +170,7 @@ test('list handler throws when subtitle command has no item id', async () => {
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: () => {},
});
@@ -174,3 +188,65 @@ test('list handler throws when subtitle command has no item id', async () => {
/Missing --jellyfin-item-id/,
);
});
test('list handler writes preview auth payload to response path', async () => {
const writes: Array<{
path: string;
payload: { serverUrl: string; accessToken: string; userId: string };
}> = [];
const logs: string[] = [];
const handler = createHandleJellyfinListCommands({
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: (responsePath, payload) => {
writes.push({ path: responsePath, payload });
},
logInfo: (message) => logs.push(message),
});
const handled = await handler({
args: {
jellyfinPreviewAuth: true,
jellyfinResponsePath: '/tmp/subminer-preview-auth.json',
} as never,
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: baseConfig,
});
assert.equal(handled, true);
assert.deepEqual(writes, [
{
path: '/tmp/subminer-preview-auth.json',
payload: {
serverUrl: baseSession.serverUrl,
accessToken: baseSession.accessToken,
userId: baseSession.userId,
},
},
]);
assert.deepEqual(logs, ['Jellyfin preview auth written.']);
});
test('list handler throws when preview auth command has no response path', async () => {
const handler = createHandleJellyfinListCommands({
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: () => {},
});
await assert.rejects(
handler({
args: {
jellyfinPreviewAuth: true,
} as never,
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: baseConfig,
}),
/Missing --jellyfin-response-path/,
);
});

View File

@@ -17,6 +17,12 @@ type JellyfinConfig = {
defaultLibraryId: string;
};
type JellyfinPreviewAuthPayload = {
serverUrl: string;
accessToken: string;
userId: string;
};
export function createHandleJellyfinListCommands(deps: {
listJellyfinLibraries: (
session: JellyfinSession,
@@ -25,7 +31,13 @@ export function createHandleJellyfinListCommands(deps: {
listJellyfinItems: (
session: JellyfinSession,
clientInfo: JellyfinClientInfo,
params: { libraryId: string; searchTerm?: string; limit: number },
params: {
libraryId: string;
searchTerm?: string;
limit: number;
recursive?: boolean;
includeItemTypes?: string;
},
) => Promise<Array<{ id: string; title: string; type: string }>>;
listJellyfinSubtitleTracks: (
session: JellyfinSession,
@@ -42,8 +54,9 @@ export function createHandleJellyfinListCommands(deps: {
isForced?: boolean;
isExternal?: boolean;
deliveryUrl?: string | null;
}>
}>
>;
writeJellyfinPreviewAuth: (responsePath: string, payload: JellyfinPreviewAuthPayload) => void;
logInfo: (message: string) => void;
}) {
return async (params: {
@@ -54,6 +67,20 @@ export function createHandleJellyfinListCommands(deps: {
}): Promise<boolean> => {
const { args, session, clientInfo, jellyfinConfig } = params;
if (args.jellyfinPreviewAuth) {
const responsePath = args.jellyfinResponsePath?.trim();
if (!responsePath) {
throw new Error('Missing --jellyfin-response-path for --jellyfin-preview-auth.');
}
deps.writeJellyfinPreviewAuth(responsePath, {
serverUrl: session.serverUrl,
accessToken: session.accessToken,
userId: session.userId,
});
deps.logInfo('Jellyfin preview auth written.');
return true;
}
if (args.jellyfinLibraries) {
const libraries = await deps.listJellyfinLibraries(session, clientInfo);
if (libraries.length === 0) {
@@ -79,6 +106,8 @@ export function createHandleJellyfinListCommands(deps: {
libraryId,
searchTerm: args.jellyfinSearch,
limit: args.jellyfinLimit ?? 100,
recursive: args.jellyfinRecursive,
includeItemTypes: args.jellyfinIncludeItemTypes,
});
if (items.length === 0) {
deps.logInfo('No Jellyfin items found for the selected library/search.');

View File

@@ -31,6 +31,10 @@ test('jellyfin auth commands main deps builder maps callbacks', async () => {
test('jellyfin list commands main deps builder maps callbacks', async () => {
const calls: string[] = [];
const writes: Array<{
responsePath: string;
payload: { serverUrl: string; accessToken: string; userId: string };
}> = [];
const deps = createBuildHandleJellyfinListCommandsMainDepsHandler({
listJellyfinLibraries: async () => {
calls.push('libraries');
@@ -44,14 +48,32 @@ test('jellyfin list commands main deps builder maps callbacks', async () => {
calls.push('subtitles');
return [];
},
writeJellyfinPreviewAuth: (responsePath, payload) => {
writes.push({ responsePath, payload });
},
logInfo: (message) => calls.push(`info:${message}`),
})();
await deps.listJellyfinLibraries({} as never, {} as never);
await deps.listJellyfinItems({} as never, {} as never, { libraryId: '', limit: 1 });
await deps.listJellyfinSubtitleTracks({} as never, {} as never, 'id');
deps.writeJellyfinPreviewAuth('/tmp/jellyfin-preview.json', {
serverUrl: 'https://example.test',
accessToken: 'token',
userId: 'user-id',
});
deps.logInfo('done');
assert.deepEqual(calls, ['libraries', 'items', 'subtitles', 'info:done']);
assert.deepEqual(writes, [
{
responsePath: '/tmp/jellyfin-preview.json',
payload: {
serverUrl: 'https://example.test',
accessToken: 'token',
userId: 'user-id',
},
},
]);
});
test('jellyfin play command main deps builder maps callbacks', async () => {

View File

@@ -32,6 +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),
logInfo: (message: string) => deps.logInfo(message),
});
}