Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
fc8da45
adding initial websocket support
anultravioletaurora Dec 11, 2025
06fb4f2
Add event parameters, incorporate api_key parameter
anultravioletaurora Dec 11, 2025
8bde62e
Merge branch 'jellyfin:master' into master
anultravioletaurora Dec 21, 2025
f041aa9
linting
anultravioletaurora Dec 21, 2025
6fd0935
Niels you are so smart
anultravioletaurora Dec 23, 2025
fc73890
send start messages on socket connection
anultravioletaurora Dec 28, 2025
3403664
incorporate a registry where start message handlers can be defined
anultravioletaurora Jan 2, 2026
2aa9366
linting, circular dependency avoidance, cleaner construction
anultravioletaurora Jan 4, 2026
d108c18
linting, tests
anultravioletaurora Jan 4, 2026
5e53638
Merge branch 'master' into master
anultravioletaurora Jan 4, 2026
1a00de9
Merge branch 'master' of github.com:anultravioletaurora/jellyfin-sdk-…
anultravioletaurora Jan 4, 2026
f616a51
fix tests
anultravioletaurora Jan 4, 2026
f882eec
Adds in status change eventing, so consumers can respond to changes i…
anultravioletaurora Jan 4, 2026
97b8eaa
linting and code smells
anultravioletaurora Jan 4, 2026
8e33168
Merge branch 'master' into master
anultravioletaurora Jan 11, 2026
f569604
Add type constraints for OutboundWebSocketMessageTypes
anultravioletaurora Jan 12, 2026
4aef130
Merge branch 'master' of github.com:anultravioletaurora/jellyfin-sdk-…
anultravioletaurora Jan 12, 2026
8f988c5
addressing PR comments
anultravioletaurora Jan 13, 2026
e435146
making api fields private to prevent external mutations
anultravioletaurora Jan 13, 2026
b4c554a
avoid circular dependency during build
anultravioletaurora Jan 13, 2026
b22df56
make websocketservice constructor cleaner
anultravioletaurora Jan 13, 2026
431295a
increasing test coverage
anultravioletaurora Jan 13, 2026
064a9a8
tsdocs
anultravioletaurora Jan 13, 2026
7c7570e
keep socket subscriptions but disconnect socket if access token is empty
anultravioletaurora Jan 14, 2026
f3040a1
proper reconnection handling when an accesstoken or baseUrl is changed
anultravioletaurora Jan 14, 2026
59975a1
only initSocket during an update if we have subscriptions
anultravioletaurora Jan 14, 2026
b2c91df
linting
anultravioletaurora Jan 14, 2026
3b23e70
instantiate websocketservice
anultravioletaurora Jan 15, 2026
b550070
Add GPL headers, update changelog
anultravioletaurora Jan 20, 2026
752204e
update tsdocs
anultravioletaurora Jan 20, 2026
046048e
Merge branch 'master' into master
anultravioletaurora Jan 20, 2026
5c9c43f
pr feedback
anultravioletaurora Jan 22, 2026
f86d1d4
move ws url builder to util
anultravioletaurora Jan 22, 2026
60c4d73
Merge branch 'master' into master
anultravioletaurora Jan 22, 2026
e4d53a4
Removes hardcoded socket subscription intervals and allows for interv…
anultravioletaurora Jan 22, 2026
735cfd1
fix linter errors
anultravioletaurora Jan 22, 2026
cf7a4b3
Use PeriodicListenerInterval in more places for consistency, changelo…
anultravioletaurora Jan 22, 2026
f8a71c7
add exponential backoff to reconnection of the websocket after a disc…
anultravioletaurora Jan 22, 2026
7ffa4e4
PR feedback
anultravioletaurora Jan 25, 2026
95bf230
update websocket getter function
anultravioletaurora Jan 25, 2026
37b7f70
linting
anultravioletaurora Jan 25, 2026
10ca1ea
PR feedback
anultravioletaurora Jan 27, 2026
84647ac
fix linting
anultravioletaurora Jan 27, 2026
d05d359
PR feedback
anultravioletaurora Jan 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added

* `getLibraryApi` now returns an augmented class with a `getDownloadUrl` method ([#967](https://github.com/jellyfin/jellyfin-sdk-typescript/pull/967)).
* The `Api` class now contains a `webSocket` field, of which `OutboundWebSocketMessageType`s can be subscribed to for handling WebSocket messages ([#966](https://github.com/jellyfin/jellyfin-sdk-typescript/issues/966)).

## [0.13.0] - 2025-10-28

Expand Down
7 changes: 6 additions & 1 deletion src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
import axios from 'axios';
import { vi, describe, expect, it, afterEach } from 'vitest';

import { Api, AUTHORIZATION_HEADER } from '..';
import { Api } from '..';
import { SERVER_URL, TEST_CLIENT, TEST_DEVICE } from '../__helpers__/common';
import { AUTHORIZATION_HEADER } from '../constants';
import { getAuthorizationHeader } from '../utils';

vi.mock('axios', async () => {
Expand Down Expand Up @@ -54,6 +55,8 @@ describe('Api', () => {
expect(requestData.data).toEqual(JSON.stringify(USER_CREDENTIALS));

expect(api.accessToken).toBe(TEST_ACCESS_TOKEN);

expect(api.webSocket).toBeDefined();
});

it('should logout and update state', async () => {
Expand All @@ -69,6 +72,8 @@ describe('Api', () => {
expect(requestData.url).toEqual(`${SERVER_URL}/Sessions/Logout`);

expect(api.accessToken).toBe('');

expect(api.webSocket).toBeDefined();
});

it('should return the correct authorization header value', () => {
Expand Down
126 changes: 107 additions & 19 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,45 +6,66 @@
import type { AxiosInstance, AxiosResponse } from 'axios';
import globalInstance from 'axios';

import { AUTHORIZATION_HEADER, AUTHORIZATION_PARAMETER } from './constants';
import { Configuration } from './generated-client/configuration';
import type { AuthenticationResult } from './generated-client/models/authentication-result';
import type { ClientInfo, DeviceInfo } from './models';
import { getAuthorizationHeader } from './utils';
import { getSessionApi } from './utils/api/session-api';
import { getUserApi } from './utils/api/user-api';

/** The authorization header field name. */
export const AUTHORIZATION_HEADER = 'Authorization';

/** The authorization query parameter name. */
export const AUTHORIZATION_PARAMETER = 'ApiKey';
import type { WebSocketSubscriptionIntervals } from './websocket';
import { WebSocketService } from './websocket';
import { WEBSOCKET_URL_PATH } from './websocket/constants';

/** Class representing the Jellyfin API. */
export class Api {
basePath;
clientInfo;
deviceInfo;
accessToken;
axiosInstance;
private _basePath;
private _clientInfo;
private _deviceInfo;
private _accessToken;

readonly axiosInstance;

private _webSocket: WebSocketService | undefined;
private readonly webSocketSubscriptionIntervals?: WebSocketSubscriptionIntervals;

constructor(
basePath: string,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,
accessToken = '',
axiosInstance: AxiosInstance = globalInstance
axiosInstance: AxiosInstance = globalInstance,
webSocketSubscriptionIntervals?: WebSocketSubscriptionIntervals
) {
// Remove trailing slash if present
this.basePath = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
this.clientInfo = clientInfo;
this.deviceInfo = deviceInfo;
this.accessToken = accessToken;
this._basePath = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
this._clientInfo = clientInfo;
this._deviceInfo = deviceInfo;
this._accessToken = accessToken;
this.axiosInstance = axiosInstance;

this.webSocketSubscriptionIntervals = webSocketSubscriptionIntervals;
}

get accessToken(): string {
return this._accessToken;
}

get basePath(): string {
return this._basePath;
}

get clientInfo(): ClientInfo {
return this._clientInfo;
}

get deviceInfo(): DeviceInfo {
return this._deviceInfo;
}

get configuration(): Configuration {
return new Configuration({
basePath: this.basePath,
basePath: this._basePath,
baseOptions: {
headers: {
[AUTHORIZATION_HEADER]: this.authorizationHeader
Expand All @@ -53,6 +74,30 @@ export class Api {
});
}

/**
* Service for managing subscriptions to Outbound WebSocket events.
*
* This service is automatically instantiated when the service is invoked
* for the first time, provided that an access token is present.
*
* If the access token is cleared via the {@link update} method, the WebSocket
* connection will be closed but subscriptions will remain intact.
*
* @see {@link WebSocketService.subscribe}
*/
get webSocket(): WebSocketService {
if (!this._webSocket) {
this._webSocket = new WebSocketService(
this.getUri(WEBSOCKET_URL_PATH, {
[AUTHORIZATION_PARAMETER]: this.accessToken
}),
Comment on lines +91 to +93
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't really work great when there is no access token (yet)

this.webSocketSubscriptionIntervals
);
}

return this._webSocket;
}

/**
* Convenience method for authenticating a user by name.
* @deprecated Use `getUserApi().authenticateUserByName()` instead.
Expand All @@ -74,7 +119,7 @@ export class Api {
*/
getUri(url: string, params?: object) {
return this.axiosInstance.getUri({
baseURL: this.basePath,
baseURL: this._basePath,
url,
params
});
Expand All @@ -88,7 +133,50 @@ export class Api {
return getSessionApi(this).reportSessionEnded();
}

/**
* Updates this {@link Api} instance with new data.
*
* If the access token is cleared, any existing WebSocket connection will be closed.
*
* If the base path or access token changes while a WebSocket connection is active,
* the connection will be reconnected with the new credentials.
*
* @param data The data to update.
*/
update(data: Partial<{
basePath: string;
clientInfo: ClientInfo;
deviceInfo: DeviceInfo;
accessToken: string;
}>): void {
if (data.basePath) {
this._basePath = data.basePath;
}
if (data.clientInfo) {
this._clientInfo = data.clientInfo;
}
if (data.deviceInfo) {
this._deviceInfo = data.deviceInfo;
}
if (data.accessToken !== undefined) {
this._accessToken = data.accessToken;
}

if (data.basePath ||
(data.accessToken && data.accessToken !== '')
) {
this._webSocket?.updateUrl(
this.getUri(WEBSOCKET_URL_PATH, {
[AUTHORIZATION_PARAMETER]: this.accessToken
})
);
} else if (data.accessToken === '') {
// Token was cleared, dispose of WebSocket
this._webSocket?.disconnect();
}
}

get authorizationHeader(): string {
return getAuthorizationHeader(this.clientInfo, this.deviceInfo, this.accessToken);
return getAuthorizationHeader(this._clientInfo, this._deviceInfo, this._accessToken);
}
}
11 changes: 11 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

/** The authorization header field name. */
export const AUTHORIZATION_HEADER = 'Authorization';

/** The authorization query parameter name. */
export const AUTHORIZATION_PARAMETER = 'ApiKey';
11 changes: 10 additions & 1 deletion src/discovery/__tests__/recommended-server-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@ import { ProductNameIssue, SlowResponseIssue, SystemInfoIssue, VersionMissingIss
import { API_VERSION, MINIMUM_VERSION } from '../../versions';
import { RecommendedServerDiscovery } from '../recommended-server-discovery';

vi.mock('axios');
vi.mock('axios', async () => {
const actual = await vi.importActual('axios');
return {
default: {
getUri: actual.getUri,
request: vi.fn(),
defaults: {}
}
};
});

const ADDRESS = 'https://example.com';
const PRODUCT_NAME = 'Jellyfin Server';
Expand Down
6 changes: 4 additions & 2 deletions src/jellyfin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { AxiosInstance } from 'axios';
import { Api } from './api';
import { DiscoveryService } from './discovery/discovery-service';
import type { ClientInfo, DeviceInfo } from './models';
import type { WebSocketSubscriptionIntervals } from './websocket';

/** Supported server version constants */
export * from './versions';
Expand Down Expand Up @@ -36,9 +37,10 @@ export class Jellyfin {
* @param basePath A base path of a server.
* @param accessToken An (optional) access token to use for authentication.
* @param axiosInstance An (optional) Axios instance for the Api to use.
* @param webSocketSubscriptionIntervals An (optional) {@link WebSocketSubscriptionIntervals} object for defining message subscription intervals.
* @returns An Api instance.
*/
createApi(basePath: string, accessToken?: string, axiosInstance?: AxiosInstance): Api {
return new Api(basePath, this.clientInfo, this.deviceInfo, accessToken, axiosInstance);
createApi(basePath: string, accessToken?: string, axiosInstance?: AxiosInstance, webSocketSubscriptionIntervals?: WebSocketSubscriptionIntervals): Api {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outside of the scope for this PR, but I think it would make sense to change this function to take a single object instead. That is easier to work with when options change in future releases.

return new Api(basePath, this.clientInfo, this.deviceInfo, accessToken, axiosInstance, webSocketSubscriptionIntervals);
}
}
3 changes: 2 additions & 1 deletion src/utils/api/__tests__/library-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import { describe, expect, it } from 'vitest';

import { SERVER_URL, TEST_CLIENT, TEST_DEVICE } from '../../../__helpers__/common';
import { Api, AUTHORIZATION_PARAMETER } from '../../../api';
import { Api } from '../../../api';
import { AUTHORIZATION_PARAMETER } from '../../../constants';
import { getLibraryApi } from '../library-api';

describe('LibraryApi', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/utils/api/library-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

import { AUTHORIZATION_PARAMETER, type Api } from '../../api';
import type { Api } from '../../api';
import { AUTHORIZATION_PARAMETER } from '../../constants';
import { LibraryApi, type LibraryApiGetDownloadRequest } from '../../generated-client/api/library-api';

/** An augmented LibraryApi with URL helpers. */
Expand Down
4 changes: 3 additions & 1 deletion src/utils/api/session-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ class AugmentedSessionApi extends SessionApi {
public reportSessionEnded(options?: RawAxiosRequestConfig): Promise<AxiosResponse<void, any, {}>> {
return super.reportSessionEnded(options)
.then(response => {
this.api.accessToken = '';
this.api.update({
accessToken: ''
});
return response;
});
}
Expand Down
4 changes: 2 additions & 2 deletions src/utils/api/user-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class AugmentedUserApi extends UserApi {
): Promise<AxiosResponse<AuthenticationResult, any, {}>> {
return super.authenticateUserByName(requestParameters, options)
.then(response => {
this.api.accessToken = response.data.AccessToken || '';
this.api.update({ accessToken: response.data.AccessToken || '' });
return response;
});
}
Expand All @@ -39,7 +39,7 @@ class AugmentedUserApi extends UserApi {
): Promise<AxiosResponse<AuthenticationResult, any, {}>> {
return super.authenticateWithQuickConnect(requestParameters, options)
.then(response => {
this.api.accessToken = response.data.AccessToken || '';
this.api.update({ accessToken: response.data.AccessToken || '' });
return response;
});
}
Expand Down
35 changes: 34 additions & 1 deletion src/utils/url/__tests__/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { describe, it, expect } from 'vitest';

import { getDefaultPort, HTTP_PORT, HTTPS_PORT, HTTPS_PROTOCOL, HTTP_PROTOCOL, copyUrl, parseUrl, hasProtocolAndPort, isDefaultPort } from '..';
import { getDefaultPort, HTTP_PORT, HTTPS_PORT, HTTPS_PROTOCOL, HTTP_PROTOCOL, copyUrl, parseUrl, hasProtocolAndPort, isDefaultPort, buildWebSocketUrl } from '..';

/**
* Url tests.
Expand Down Expand Up @@ -107,4 +107,37 @@ describe('Url', () => {
}).toThrow();
});
});

describe('buildWebSocketUrl()', () => {
it('should convert http to ws', () => {
const url = buildWebSocketUrl('http://example.com');
expect(url.protocol).toBe('ws:');
expect(url.toString()).toBe('ws://example.com/');
});

it('should convert https to wss', () => {
const url = buildWebSocketUrl('https://example.com');
expect(url.protocol).toBe('wss:');
expect(url.toString()).toBe('wss://example.com/');
});

it('should preserve port numbers', () => {
const url = buildWebSocketUrl('http://example.com:8096');
expect(url.protocol).toBe('ws:');
expect(url.port).toBe('8096');
expect(url.toString()).toBe('ws://example.com:8096/');
});

it('should preserve path and query parameters', () => {
const url = buildWebSocketUrl('https://example.com/socket?accessToken=abc123');
expect(url.protocol).toBe('wss:');
expect(url.pathname).toBe('/socket');
expect(url.search).toBe('?accessToken=abc123');
});

it('should return a URL instance', () => {
const url = buildWebSocketUrl('http://example.com');
expect(url).toBeInstanceOf(URL);
});
});
});
6 changes: 6 additions & 0 deletions src/utils/url/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,9 @@ export function parseUrl(input: string): URL {
normalizeUrl(input)
);
}

export function buildWebSocketUrl(uri: string): URL {
return new URL(
uri.replace(/^http/, 'ws')
);
}
Loading