Files
seerr/server/api/plexapi.ts
fallenbagel 27011d6997 fix: stop masking connection errors as auth or unknown failures
Every Jellyfin API method threw InvalidAuthToken or Unknown on any axios error, including a plain
connection failure, so an unreachable server or a bad token looked the same to callers. Plex
silently reset libraries to empty instead of throwing at all, and the login switch had no case for
it.
This fixes these issues.
2026-07-31 06:33:24 +08:00

247 lines
5.9 KiB
TypeScript

import ExternalAPI from '@server/api/externalapi';
import { ApiErrorCode } from '@server/constants/error';
import type { Library, PlexSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { ApiError } from '@server/types/error';
interface PlexStatusResponse {
MediaContainer: {
machineIdentifier: string;
friendlyName: string;
};
}
export interface PlexLibraryItem {
ratingKey: string;
parentRatingKey?: string;
grandparentRatingKey?: string;
title: string;
guid: string;
parentGuid?: string;
grandparentGuid?: string;
addedAt: number;
updatedAt: number;
Guid?: {
id: string;
}[];
type: 'movie' | 'show' | 'season' | 'episode';
Media: Media[];
}
interface PlexLibraryResponse {
MediaContainer: {
totalSize: number;
Metadata: PlexLibraryItem[];
};
}
export interface PlexLibrary {
type: 'show' | 'movie';
key: string;
title: string;
agent: string;
}
interface PlexLibrariesResponse {
MediaContainer: {
Directory: PlexLibrary[];
};
}
export interface PlexMetadata {
ratingKey: string;
parentRatingKey?: string;
guid: string;
type: 'movie' | 'show' | 'season';
title: string;
Guid: {
id: string;
}[];
Children?: {
size: 12;
Metadata: PlexMetadata[];
};
index: number;
parentIndex?: number;
leafCount: number;
viewedLeafCount: number;
addedAt: number;
updatedAt: number;
Media: Media[];
}
interface Media {
id: number;
duration: number;
bitrate: number;
width: number;
height: number;
aspectRatio: number;
audioChannels: number;
audioCodec: string;
videoCodec: string;
videoResolution: string;
container: string;
videoFrameRate: string;
videoProfile: string;
}
interface PlexMetadataResponse {
MediaContainer: {
Metadata: PlexMetadata[];
};
}
class PlexAPI extends ExternalAPI {
constructor({
plexToken,
plexSettings,
timeout,
}: {
plexToken?: string | null;
plexSettings?: PlexSettings;
timeout?: number;
}) {
const settings = getSettings();
const settingsPlex = plexSettings ?? settings.plex;
const protocol = settingsPlex.useSsl ? 'https' : 'http';
const baseUrl = `${protocol}://${settingsPlex.ip}:${settingsPlex.port}`;
super(
baseUrl,
{},
{
timeout,
headers: {
'X-Plex-Token': plexToken ?? '',
'X-Plex-Client-Identifier': settings.clientId,
'X-Plex-Product': 'Seerr',
'X-Plex-Device-Name': 'Seerr',
'X-Plex-Platform': 'Seerr',
},
}
);
}
public async getStatus(): Promise<PlexStatusResponse> {
return await this.get('/');
}
public async getLibraries(): Promise<PlexLibrary[]> {
const response = await this.get<PlexLibrariesResponse>('/library/sections');
return response.MediaContainer.Directory;
}
public async syncLibraries(): Promise<void> {
const settings = getSettings();
try {
const libraries = await this.getLibraries();
const newLibraries: Library[] = libraries
// Remove libraries that are not movie or show
.filter(
(library) => library.type === 'movie' || library.type === 'show'
)
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
.filter((library) => library.agent !== 'com.plexapp.agents.none')
.map((library) => {
const existing = settings.plex.libraries.find(
(l) => l.id === library.key
);
return {
id: library.key,
name: library.title,
enabled: existing?.enabled ?? false,
type: library.type,
lastScan: existing?.lastScan,
};
});
settings.plex.libraries = newLibraries;
} catch (e) {
logger.error('Failed to fetch Plex libraries', {
label: 'Plex API',
message: e.message,
});
if (!e.response) {
throw new ApiError(502, ApiErrorCode.ConnectionError);
}
throw new ApiError(e.response.status, ApiErrorCode.Unknown);
}
await settings.save();
}
public async getLibraryContents(
id: string,
{ offset = 0, size = 50 }: { offset?: number; size?: number } = {}
): Promise<{ totalSize: number; items: PlexLibraryItem[] }> {
const response = await this.get<PlexLibraryResponse>(
`/library/sections/${id}/all?includeGuids=1`,
{
headers: {
'X-Plex-Container-Start': `${offset}`,
'X-Plex-Container-Size': `${size}`,
},
}
);
return {
totalSize: response.MediaContainer.totalSize,
items: response.MediaContainer.Metadata ?? [],
};
}
public async getMetadata(
key: string,
options: { includeChildren?: boolean } = {}
): Promise<PlexMetadata> {
const response = await this.get<PlexMetadataResponse>(
`/library/metadata/${key}${
options.includeChildren ? '?includeChildren=1' : ''
}`
);
return response.MediaContainer.Metadata[0];
}
public async getChildrenMetadata(key: string): Promise<PlexMetadata[]> {
const response = await this.get<PlexMetadataResponse>(
`/library/metadata/${key}/children`
);
return response.MediaContainer.Metadata;
}
public async getRecentlyAdded(
id: string,
options: { addedAt: number } = {
addedAt: Date.now() - 1000 * 60 * 60,
},
mediaType: 'movie' | 'show'
): Promise<PlexLibraryItem[]> {
const response = await this.get<PlexLibraryResponse>(
`/library/sections/${id}/all?type=${
mediaType === 'show' ? '4' : '1'
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
{
headers: {
'X-Plex-Container-Start': '0',
'X-Plex-Container-Size': '500',
},
}
);
return response.MediaContainer.Metadata;
}
}
export default PlexAPI;