mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
545 lines
14 KiB
TypeScript
545 lines
14 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 QuickConnectInitiateResponse {
|
|
Secret: string;
|
|
Code: string;
|
|
DateAdded: string;
|
|
}
|
|
|
|
export interface QuickConnectStatusResponse {
|
|
Authenticated: boolean;
|
|
Secret: string;
|
|
Code: string;
|
|
DeviceId: string;
|
|
DeviceName: string;
|
|
AppName: string;
|
|
AppVersion: string;
|
|
DateAdded: 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 async initiateQuickConnect(): Promise<QuickConnectInitiateResponse> {
|
|
try {
|
|
const response = await this.post<QuickConnectInitiateResponse>(
|
|
'/QuickConnect/Initiate'
|
|
);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
logger.error(
|
|
`Something went wrong while initiating Quick Connect: ${e.message}`,
|
|
{ label: 'Jellyfin API', error: e.response?.status }
|
|
);
|
|
|
|
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
|
}
|
|
}
|
|
|
|
public async checkQuickConnect(
|
|
secret: string
|
|
): Promise<QuickConnectStatusResponse> {
|
|
try {
|
|
const response = await this.get<QuickConnectStatusResponse>(
|
|
'/QuickConnect/Connect',
|
|
{ params: { secret } }
|
|
);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
logger.error(
|
|
`Something went wrong while getting Quick Connect status: ${e.message}`,
|
|
{ label: 'Jellyfin API', error: e.response?.status }
|
|
);
|
|
|
|
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
|
}
|
|
}
|
|
|
|
public async authenticateQuickConnect(
|
|
secret: string
|
|
): Promise<JellyfinLoginResponse> {
|
|
try {
|
|
const response = await this.post<JellyfinLoginResponse>(
|
|
'/Users/AuthenticateWithQuickConnect',
|
|
{ Secret: secret }
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
logger.error(
|
|
`Something went wrong while authenticating with Quick Connect: ${e.message}`,
|
|
{ label: 'Jellyfin API', error: e.response?.status }
|
|
);
|
|
|
|
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;
|