Files
jellyseerr/server/api/jellyfin.ts
RolliePollie18 15356dfe49 fix(jellyfin-scan): reduce jellyfin API calls during recently added scan (#2205)
* fix(jellyfin scanner): reduce jellyfin API calls during recently added scan

Significantly reduce number of API calls, addressing CPU spikes on Jellyfin 10.10+ servers.- Move
getSeasons() call outside the seasons loop (N calls to 1)- Request MediaSources via getEpisodes()
field parameter instead of  individual getItemData() calls per episode (N calls to 1 per season)
Performance improvements (tested on library with 12 TV shows):- Scan duration: 43.7s to 9.1s - Peak
CPU: 277% to 115% - CPU spike duration: 36s to 2s Functionality is unchanged, all availability
statuses identicalbefore and after.

* fix: add getEpisodes overloads to remove unsafe type assertion

* refactor(jellyfin): use generics instead of overloads

---------

Co-authored-by: patrick-acland <patrick.acland@kraken.tech>
2025-12-09 22:20:47 +08:00

472 lines
13 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import ExternalAPI from '@server/api/externalapi';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server';
import availabilitySync from '@server/lib/availabilitySync';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { ApiError } from '@server/types/error';
import { getAppVersion } from '@server/utils/appVersion';
export interface JellyfinUserResponse {
Name: string;
ServerId: string;
ServerName: string;
Id: string;
Configuration: {
GroupedFolders: string[];
};
Policy: {
IsAdministrator: boolean;
};
PrimaryImageTag?: string;
}
export interface JellyfinDevice {
Id: string;
Name: string;
LastUserName: string;
AppName: string;
AppVersion: string;
LastUserId: string;
DateLastActivity: string;
Capabilities: Record<string, unknown>;
}
export interface JellyfinDevicesResponse {
Items: JellyfinDevice[];
TotalRecordCount: number;
StartIndex: number;
}
export interface JellyfinLoginResponse {
User: JellyfinUserResponse;
AccessToken: string;
}
export interface JellyfinUserListResponse {
users: JellyfinUserResponse[];
}
interface JellyfinMediaFolder {
Name: string;
Id: string;
Type: string;
CollectionType: string;
}
export interface JellyfinLibrary {
type: 'show' | 'movie';
key: string;
title: string;
agent: string;
}
export interface JellyfinLibraryItem {
Name: string;
Id: string;
HasSubtitles: boolean;
Type: 'Movie' | 'Episode' | 'Season' | 'Series';
LocationType: 'FileSystem' | 'Offline' | 'Remote' | 'Virtual';
SeriesName?: string;
SeriesId?: string;
SeasonId?: string;
SeasonName?: string;
IndexNumber?: number;
IndexNumberEnd?: number;
ParentIndexNumber?: number;
MediaType: string;
}
export interface JellyfinMediaStream {
Codec: string;
Type: 'Video' | 'Audio' | 'Subtitle';
Height?: number;
Width?: number;
AverageFrameRate?: number;
RealFrameRate?: number;
Language?: string;
DisplayTitle: string;
}
export interface JellyfinMediaSource {
Protocol: string;
Id: string;
Path: string;
Type: string;
VideoType: string;
MediaStreams: JellyfinMediaStream[];
}
export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
ProviderIds: {
Tmdb?: string;
Imdb?: string;
Tvdb?: string;
AniDB?: string;
};
MediaSources?: JellyfinMediaSource[];
Width?: number;
Height?: number;
IsHD?: boolean;
DateCreated?: string;
}
type EpisodeReturn<T> = T extends { includeMediaInfo: true }
? JellyfinLibraryItemExtended[]
: JellyfinLibraryItem[];
export interface JellyfinItemsReponse {
Items: JellyfinLibraryItemExtended[];
TotalRecordCount: number;
StartIndex: number;
}
class JellyfinAPI extends ExternalAPI {
private userId?: string;
private mediaServerType: MediaServerType;
constructor(
jellyfinHost: string,
authToken?: string | null,
deviceId?: string | null
) {
const settings = getSettings();
const safeDeviceId =
deviceId && deviceId.length > 0
? deviceId
: Buffer.from('BOT_seerr').toString('base64');
let authHeaderVal: string;
if (authToken) {
authHeaderVal = `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
} else {
authHeaderVal = `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}"`;
}
super(
jellyfinHost,
{},
{
headers: {
Authorization: authHeaderVal,
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
);
this.mediaServerType = settings.main.mediaServerType;
}
public async login(
Username?: string,
Password?: string,
ClientIP?: string
): Promise<JellyfinLoginResponse> {
const authenticate = async (useHeaders: boolean) => {
const headers =
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
return this.post<JellyfinLoginResponse>(
'/Users/AuthenticateByName',
{
Username,
Pw: Password,
},
{ headers }
);
};
try {
return await authenticate(true);
} catch (e) {
logger.debug('Failed to authenticate with headers', {
label: 'Jellyfin API',
error: e.response?.statusText,
ip: ClientIP,
});
if (!e.response?.status) {
throw new ApiError(404, ApiErrorCode.InvalidUrl);
}
if (e.response?.status === 401) {
throw new ApiError(e.response?.status, ApiErrorCode.InvalidCredentials);
}
}
try {
return await authenticate(false);
} catch (e) {
if (e.response?.status === 401) {
throw new ApiError(e.response?.status, ApiErrorCode.InvalidCredentials);
}
logger.error(
`Something went wrong while authenticating with the Jellyfin server: ${e.message}`,
{
label: 'Jellyfin API',
error: e.response?.status,
ip: ClientIP,
}
);
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
}
}
public setUserId(userId: string): void {
this.userId = userId;
return;
}
public async getSystemInfo(): Promise<any> {
try {
const systemInfoResponse = await this.get<any>('/System/Info');
return systemInfoResponse;
} catch (e) {
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getServerName(): Promise<string> {
try {
const serverResponse = await this.get<JellyfinUserResponse>(
'/System/Info/Public'
);
return serverResponse.ServerName;
} catch (e) {
logger.error(
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
}
}
public async getUsers(): Promise<JellyfinUserListResponse> {
try {
const userReponse = await this.get<JellyfinUserResponse[]>(`/Users`);
return { users: userReponse };
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getUser(): Promise<JellyfinUserResponse> {
try {
const userReponse = await this.get<JellyfinUserResponse>(
`/Users/${this.userId ?? 'Me'}`
);
return userReponse;
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getLibraries(): Promise<JellyfinLibrary[]> {
try {
const mediaFolderResponse = await this.get<any>(`/Library/MediaFolders`);
return this.mapLibraries(mediaFolderResponse.Items);
} catch (mediaFoldersResponseError) {
// fallback to user views to get libraries
// this only and maybe/depending on factors affects LDAP users
try {
const mediaFolderResponse = await this.get<any>(
`/Users/${this.userId ?? 'Me'}/Views`
);
return this.mapLibraries(mediaFolderResponse.Items);
} catch (e) {
logger.error(
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
{
label: 'Jellyfin API',
error: e.response?.status,
}
);
return [];
}
}
}
private mapLibraries(mediaFolders: JellyfinMediaFolder[]): JellyfinLibrary[] {
const excludedTypes = [
'music',
'books',
'musicvideos',
'homevideos',
'boxsets',
];
return mediaFolders
.filter((Item: JellyfinMediaFolder) => {
return (
Item.Type === 'CollectionFolder' &&
!excludedTypes.includes(Item.CollectionType)
);
})
.map((Item: JellyfinMediaFolder) => {
return <JellyfinLibrary>{
key: Item.Id,
title: Item.Name,
type: Item.CollectionType === 'movies' ? 'movie' : 'show',
agent: 'jellyfin',
};
});
}
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
try {
const libraryItemsResponse = await this.get<any>(
`/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
);
return libraryItemsResponse.Items.filter(
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
);
} catch (e) {
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e?.response?.status }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
try {
const endpoint =
this.mediaServerType === MediaServerType.JELLYFIN
? `/Items/Latest`
: `/Users/${this.userId}/Items/Latest`;
const itemResponse = await this.get<any>(
`${endpoint}?Limit=12&ParentId=${id}${
this.mediaServerType === MediaServerType.JELLYFIN
? `&userId=${this.userId ?? 'Me'}`
: ''
}`
);
return itemResponse;
} catch (e) {
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getItemData(
id: string
): Promise<JellyfinLibraryItemExtended | undefined> {
try {
const itemResponse = await this.get<JellyfinItemsReponse>(`/Items`, {
params: {
ids: id,
fields: 'ProviderIds,MediaSources,Width,Height,IsHD,DateCreated',
},
});
return itemResponse.Items?.[0];
} catch (e) {
if (availabilitySync.running) {
if (e.response?.status === 500) {
return undefined;
}
}
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getSeasons(seriesID: string): Promise<JellyfinLibraryItem[]> {
try {
const seasonResponse = await this.get<any>(`/Shows/${seriesID}/Seasons`);
return seasonResponse.Items;
} catch (e) {
logger.error(
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getEpisodes<
T extends { includeMediaInfo?: boolean } | undefined = undefined
>(
seriesID: string,
seasonID: string,
options?: T
): Promise<EpisodeReturn<T>> {
try {
const episodeResponse = await this.get<any>(
`/Shows/${seriesID}/Episodes`,
{
params: {
seasonId: seasonID,
...(options?.includeMediaInfo && { fields: 'MediaSources' }),
},
}
);
return episodeResponse.Items.filter(
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
);
} catch (e) {
logger.error(
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async createApiToken(appName: string): Promise<string> {
try {
await this.post(`/Auth/Keys?App=${appName}`);
const apiKeys = await this.get<any>(`/Auth/Keys`);
return apiKeys.Items.reverse().find(
(item: any) => item.AppName === appName
).AccessToken;
} catch (e) {
logger.error(
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
}
export default JellyfinAPI;