feat(jellyfin): store access token in encrypted local store

This commit is contained in:
2026-02-20 03:26:37 -08:00
parent a4532a5fa0
commit 46a2ac5dc7
22 changed files with 306 additions and 13 deletions

View File

@@ -6,6 +6,8 @@ test('jellyfin auth handler processes logout', async () => {
const calls: string[] = [];
const handleAuth = createHandleJellyfinAuthCommands({
patchRawConfig: () => calls.push('patch'),
saveStoredToken: () => calls.push('save'),
clearStoredToken: () => calls.push('clear'),
authenticateWithPassword: async () => {
throw new Error('should not authenticate');
},
@@ -34,13 +36,15 @@ test('jellyfin auth handler processes logout', async () => {
});
assert.equal(handled, true);
assert.equal(calls[0], 'patch');
assert.deepEqual(calls.slice(0, 2), ['clear', 'patch']);
});
test('jellyfin auth handler processes login', async () => {
const calls: string[] = [];
const handleAuth = createHandleJellyfinAuthCommands({
patchRawConfig: () => calls.push('patch'),
saveStoredToken: () => calls.push('save'),
clearStoredToken: () => calls.push('clear'),
authenticateWithPassword: async () => ({
serverUrl: 'http://localhost',
username: 'user',
@@ -72,6 +76,7 @@ test('jellyfin auth handler processes login', async () => {
});
assert.equal(handled, true);
assert.ok(calls.includes('save'));
assert.ok(calls.includes('patch'));
assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded')));
});
@@ -79,6 +84,8 @@ test('jellyfin auth handler processes login', async () => {
test('jellyfin auth handler no-ops when no auth command', async () => {
const handleAuth = createHandleJellyfinAuthCommands({
patchRawConfig: () => {},
saveStoredToken: () => {},
clearStoredToken: () => {},
authenticateWithPassword: async () => ({
serverUrl: '',
username: '',

View File

@@ -39,6 +39,8 @@ export function createHandleJellyfinAuthCommands(deps: {
password: string,
clientInfo: JellyfinClientInfo,
) => Promise<JellyfinSession>;
saveStoredToken: (token: string) => void;
clearStoredToken: () => void;
logInfo: (message: string) => void;
}) {
return async (params: {
@@ -48,6 +50,7 @@ export function createHandleJellyfinAuthCommands(deps: {
clientInfo: JellyfinClientInfo;
}): Promise<boolean> => {
if (params.args.jellyfinLogout) {
deps.clearStoredToken();
deps.patchRawConfig({
jellyfin: {
accessToken: '',
@@ -70,12 +73,13 @@ export function createHandleJellyfinAuthCommands(deps: {
password,
params.clientInfo,
);
deps.saveStoredToken(session.accessToken);
deps.patchRawConfig({
jellyfin: {
enabled: true,
serverUrl: session.serverUrl,
username: session.username,
accessToken: session.accessToken,
accessToken: '',
userId: session.userId,
deviceId: params.clientInfo.deviceId,
clientName: params.clientInfo.clientName,

View File

@@ -12,6 +12,8 @@ test('jellyfin auth commands main deps builder maps callbacks', async () => {
const deps = createBuildHandleJellyfinAuthCommandsMainDepsHandler({
patchRawConfig: () => calls.push('patch'),
authenticateWithPassword: async () => ({}) as never,
saveStoredToken: () => calls.push('save'),
clearStoredToken: () => calls.push('clear'),
logInfo: (message) => calls.push(`info:${message}`),
})();
@@ -21,8 +23,10 @@ test('jellyfin auth commands main deps builder maps callbacks', async () => {
clientName: '',
clientVersion: '',
});
deps.saveStoredToken('token');
deps.clearStoredToken();
deps.logInfo('ok');
assert.deepEqual(calls, ['patch', 'info:ok']);
assert.deepEqual(calls, ['patch', 'save', 'clear', 'info:ok']);
});
test('jellyfin list commands main deps builder maps callbacks', async () => {

View File

@@ -25,6 +25,8 @@ export function createBuildHandleJellyfinAuthCommandsMainDepsHandler(
patchRawConfig: (patch) => deps.patchRawConfig(patch),
authenticateWithPassword: (serverUrl, username, password, clientInfo) =>
deps.authenticateWithPassword(serverUrl, username, password, clientInfo),
saveStoredToken: (token) => deps.saveStoredToken(token),
clearStoredToken: () => deps.clearStoredToken(),
logInfo: (message: string) => deps.logInfo(message),
});
}

View File

@@ -9,8 +9,10 @@ test('get resolved jellyfin config main deps builder maps callbacks', () => {
const resolved = { jellyfin: { url: 'https://example.com' } };
const deps = createBuildGetResolvedJellyfinConfigMainDepsHandler({
getResolvedConfig: () => resolved as never,
loadStoredToken: () => 'stored-token',
})();
assert.equal(deps.getResolvedConfig(), resolved);
assert.equal(deps.loadStoredToken(), 'stored-token');
});
test('get jellyfin client info main deps builder maps callbacks', () => {

View File

@@ -11,6 +11,7 @@ export function createBuildGetResolvedJellyfinConfigMainDepsHandler(
) {
return (): GetResolvedJellyfinConfigMainDeps => ({
getResolvedConfig: () => deps.getResolvedConfig(),
loadStoredToken: () => deps.loadStoredToken(),
});
}

View File

@@ -9,11 +9,32 @@ test('get resolved jellyfin config returns jellyfin section from resolved config
const jellyfin = { url: 'https://jellyfin.local' } as never;
const getConfig = createGetResolvedJellyfinConfigHandler({
getResolvedConfig: () => ({ jellyfin } as never),
loadStoredToken: () => null,
});
assert.equal(getConfig(), jellyfin);
});
test('get resolved jellyfin config falls back to stored token when config token is blank', () => {
const getConfig = createGetResolvedJellyfinConfigHandler({
getResolvedConfig: () =>
({
jellyfin: {
serverUrl: 'http://localhost:8096',
accessToken: ' ',
userId: 'uid-1',
},
}) as never,
loadStoredToken: () => 'stored-token',
});
assert.deepEqual(getConfig(), {
serverUrl: 'http://localhost:8096',
accessToken: 'stored-token',
userId: 'uid-1',
});
});
test('jellyfin client info resolves defaults when fields are missing', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' } as never),

View File

@@ -1,7 +1,25 @@
export function createGetResolvedJellyfinConfigHandler(deps: {
getResolvedConfig: () => { jellyfin: unknown };
loadStoredToken: () => string | null | undefined;
}) {
return () => deps.getResolvedConfig().jellyfin as never;
return () => {
const jellyfin = deps.getResolvedConfig().jellyfin as {
accessToken?: string;
[key: string]: unknown;
};
const configToken = jellyfin.accessToken?.trim() ?? '';
if (configToken.length > 0) {
return jellyfin as never;
}
const storedToken = deps.loadStoredToken()?.trim() ?? '';
if (storedToken.length === 0) {
return jellyfin as never;
}
return {
...jellyfin,
accessToken: storedToken,
} as never;
};
}
export function createGetJellyfinClientInfoHandler(deps: {

View File

@@ -17,6 +17,7 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
userId: 'uid',
}),
getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'dev' }),
saveStoredToken: () => calls.push('save'),
patchJellyfinConfig: () => calls.push('patch'),
logInfo: (message) => calls.push(`info:${message}`),
logError: (message) => calls.push(`error:${message}`),
@@ -43,6 +44,7 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
accessToken: 'token',
userId: 'uid',
});
deps.saveStoredToken('token');
deps.patchJellyfinConfig({
serverUrl: 'http://127.0.0.1:8096',
username: 'alice',
@@ -55,5 +57,5 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
deps.clearSetupWindow();
deps.setSetupWindow({} as never);
assert.equal(deps.encodeURIComponent('a b'), 'a%20b');
assert.deepEqual(calls, ['patch', 'info:ok', 'error:bad', 'osd:toast', 'clear', 'set-window']);
assert.deepEqual(calls, ['save', 'patch', 'info:ok', 'error:bad', 'osd:toast', 'clear', 'set-window']);
});

View File

@@ -15,6 +15,7 @@ export function createBuildOpenJellyfinSetupWindowMainDepsHandler(
authenticateWithPassword: (server: string, username: string, password: string, clientInfo) =>
deps.authenticateWithPassword(server, username, password, clientInfo),
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
saveStoredToken: (token: string) => deps.saveStoredToken(token),
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
logInfo: (message: string) => deps.logInfo(message),
logError: (message: string, error: unknown) => deps.logError(message, error),

View File

@@ -49,6 +49,7 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
userId: 'uid',
}),
getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }),
saveStoredToken: () => calls.push('save'),
patchJellyfinConfig: () => calls.push('patch'),
logInfo: () => calls.push('info'),
logError: () => calls.push('error'),
@@ -60,7 +61,7 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=a&password=b',
);
assert.equal(handled, true);
assert.deepEqual(calls, ['patch', 'info', 'osd:Jellyfin login success', 'close']);
assert.deepEqual(calls, ['save', 'patch', 'info', 'osd:Jellyfin login success', 'close']);
});
test('createHandleJellyfinSetupSubmissionHandler reports failure to OSD', async () => {
@@ -71,6 +72,7 @@ test('createHandleJellyfinSetupSubmissionHandler reports failure to OSD', async
throw new Error('bad credentials');
},
getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }),
saveStoredToken: () => calls.push('save'),
patchJellyfinConfig: () => calls.push('patch'),
logInfo: () => calls.push('info'),
logError: () => calls.push('error'),
@@ -164,6 +166,7 @@ test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is
throw new Error('should not auth');
},
getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }),
saveStoredToken: () => {},
patchJellyfinConfig: () => {},
logInfo: () => {},
logError: () => {},
@@ -216,6 +219,7 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
userId: 'uid',
}),
getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }),
saveStoredToken: () => calls.push('save'),
patchJellyfinConfig: () => calls.push('patch'),
logInfo: () => calls.push('info'),
logError: () => calls.push('error'),
@@ -245,6 +249,7 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
await Promise.resolve();
assert.equal(prevented, true);
assert.ok(calls.includes('save'));
assert.ok(calls.includes('patch'));
assert.ok(calls.includes('osd:Jellyfin login success'));
assert.ok(calls.includes('close'));

View File

@@ -117,6 +117,7 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
clientInfo: JellyfinClientInfo,
) => Promise<JellyfinSession>;
getJellyfinClientInfo: () => JellyfinClientInfo;
saveStoredToken: (token: string) => void;
patchJellyfinConfig: (session: JellyfinSession) => void;
logInfo: (message: string) => void;
logError: (message: string, error: unknown) => void;
@@ -136,6 +137,7 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
submission.password,
deps.getJellyfinClientInfo(),
);
deps.saveStoredToken(session.accessToken);
deps.patchJellyfinConfig(session);
deps.logInfo(`Jellyfin setup saved for ${session.username}.`);
deps.showMpvOsd('Jellyfin login success');
@@ -195,6 +197,7 @@ export function createOpenJellyfinSetupWindowHandler<TWindow extends JellyfinSet
clientInfo: JellyfinClientInfo,
) => Promise<JellyfinSession>;
getJellyfinClientInfo: () => JellyfinClientInfo;
saveStoredToken: (token: string) => void;
patchJellyfinConfig: (session: JellyfinSession) => void;
logInfo: (message: string) => void;
logError: (message: string, error: unknown) => void;
@@ -218,6 +221,7 @@ export function createOpenJellyfinSetupWindowHandler<TWindow extends JellyfinSet
authenticateWithPassword: (server, username, password, clientInfo) =>
deps.authenticateWithPassword(server, username, password, clientInfo),
getJellyfinClientInfo: () => deps.getJellyfinClientInfo(),
saveStoredToken: (token) => deps.saveStoredToken(token),
patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session),
logInfo: (message) => deps.logInfo(message),
logError: (message, error) => deps.logError(message, error),