Files
jellyseerr/server/api/externalapi.ts
Gauthier bd4da6d5fc feat(jellyfinapi): switch to API tokens instead of auth tokens (#868)
* feat(jellyfinapi): create Jellyfin API key from admin user

* fix(jellyfinapi): add migration script for Jellyfin API key

* feat(jellyfinapi): use Jellyfin API key instead of admin auth token

* fix(jellyfinapi): fix api key migration

* feat(jellyfinapi): add API key field to Jellyfin settings

* fix: move the API key field in the Jellyfin settings
2024-08-13 19:01:45 +05:00

333 lines
8.2 KiB
TypeScript

import type { RateLimitOptions } from '@server/utils/rateLimit';
import rateLimit from '@server/utils/rateLimit';
import type NodeCache from 'node-cache';
// 5 minute default TTL (in seconds)
const DEFAULT_TTL = 300;
// 10 seconds default rolling buffer (in ms)
const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
rateLimit?: RateLimitOptions;
}
class ExternalAPI {
protected fetch: typeof fetch;
protected params: Record<string, string>;
protected defaultHeaders: { [key: string]: string };
private baseUrl: string;
private cache?: NodeCache;
constructor(
baseUrl: string,
params: Record<string, string> = {},
options: ExternalAPIOptions = {}
) {
if (options.rateLimit) {
this.fetch = rateLimit(fetch, options.rateLimit);
} else {
this.fetch = fetch;
}
this.baseUrl = baseUrl;
this.params = params;
this.defaultHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
};
this.cache = options.nodeCache;
}
protected async get<T>(
endpoint: string,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
if (this.cache) {
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}
return data;
}
protected async post<T>(
endpoint: string,
data?: Record<string, unknown>,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: { ...this.params, ...params },
data,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'POST',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
body: data ? JSON.stringify(data) : undefined,
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const resData = await this.getDataFromResponse(response);
if (this.cache) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}
return resData;
}
protected async put<T>(
endpoint: string,
data: Record<string, unknown>,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: { ...this.params, ...params },
data,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'PUT',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
body: JSON.stringify(data),
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const resData = await this.getDataFromResponse(response);
if (this.cache) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}
return resData;
}
protected async delete<T>(
endpoint: string,
params?: Record<string, string>,
config?: RequestInit
): Promise<T> {
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'DELETE',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
return data;
}
protected async getRolling<T>(
endpoint: string,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit,
overwriteBaseUrl?: string
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
const keyTtl = this.cache?.getTtl(cacheKey) ?? 0;
// If the item has passed our rolling check, fetch again in background
if (
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
Date.now() - DEFAULT_ROLLING_BUFFER
) {
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
}).then(async (response) => {
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${
text ? ': ' + text : ''
}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
});
}
return cachedItem;
}
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
const response = await this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
if (this.cache) {
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}
return data;
}
private formatUrl(
endpoint: string,
params?: Record<string, string>,
overwriteBaseUrl?: string
): string {
const baseUrl = overwriteBaseUrl || this.baseUrl;
const href =
baseUrl +
(baseUrl.endsWith('/') ? '' : '/') +
(endpoint.startsWith('/') ? endpoint.slice(1) : endpoint);
const searchParams = new URLSearchParams({
...this.params,
...params,
});
return (
href +
(searchParams.toString().length
? '?' + searchParams.toString()
: searchParams.toString())
);
}
private serializeCacheKey(
endpoint: string,
params?: Record<string, unknown>
) {
if (!params) {
return `${this.baseUrl}${endpoint}`;
}
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
}
private async getDataFromResponse(response: Response) {
const contentType = response.headers.get('Content-Type');
if (contentType?.includes('application/json')) {
return await response.json();
} else if (
contentType?.includes('application/xml') ||
contentType?.includes('text/html') ||
contentType?.includes('text/plain')
) {
return await response.text();
} else {
try {
return await response.json();
} catch {
try {
return await response.blob();
} catch {
return null;
}
}
}
}
}
export default ExternalAPI;