From a488f850f3d6f0d9896362db873bd86ec1b7040a Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 8 Apr 2025 13:20:10 +0200 Subject: [PATCH] refactor: switch from Fetch API to Axios (#1520) * refactor: switch from Fetch API to Axios * fix: remove unwanted changes * fix: rewrite error handling for Axios and remove IPv4 first setting * style: run prettier * style: run prettier * fix: add back custom proxy agent * fix: add back custom proxy agent * fix: correct rebase issue * fix: resolve review comments --- cypress/config/settings.cypress.json | 1 - docs/troubleshooting.mdx | 32 +- jellyseerr-api.yml | 3 - next.config.js | 1 - package.json | 2 + pnpm-lock.yaml | 52 ++- server/api/animelist.ts | 19 +- server/api/externalapi.ts | 297 +++-------------- server/api/github.ts | 16 +- server/api/jellyfin.ts | 126 ++++--- server/api/plextv.ts | 75 ++--- server/api/pushover.ts | 17 +- server/api/rating/imdbRadarrProxy.ts | 14 +- server/api/rating/rottentomatoes.ts | 5 +- server/api/servarr/base.ts | 36 +- server/api/servarr/radarr.ts | 62 ++-- server/api/servarr/sonarr.ts | 123 ++++--- server/api/tautulli.ts | 83 ++--- server/api/themoviedb/index.ts | 307 ++++++++++-------- server/index.ts | 11 - server/lib/imageproxy.ts | 52 +-- server/lib/notifications/agents/discord.ts | 35 +- server/lib/notifications/agents/gotify.ts | 21 +- server/lib/notifications/agents/lunasea.ts | 36 +- server/lib/notifications/agents/pushbullet.ts | 67 +--- server/lib/notifications/agents/pushover.ts | 111 ++----- server/lib/notifications/agents/slack.ts | 24 +- server/lib/notifications/agents/telegram.ts | 95 ++---- server/lib/notifications/agents/webhook.ts | 34 +- server/lib/settings/index.ts | 2 - server/routes/avatarproxy.ts | 15 +- server/routes/imageproxy.ts | 1 + server/utils/customProxyAgent.ts | 12 +- server/utils/rateLimit.ts | 68 ---- server/utils/restartFlag.ts | 3 +- src/components/Blacklist/index.tsx | 9 +- src/components/BlacklistBlock/index.tsx | 9 +- src/components/BlacklistModal/index.tsx | 9 +- .../Discover/CreateSlider/index.tsx | 90 +++-- .../Discover/DiscoverSliderEdit/index.tsx | 6 +- src/components/Discover/index.tsx | 15 +- .../IssueDetails/IssueComment/index.tsx | 20 +- src/components/IssueDetails/index.tsx | 34 +- .../IssueModal/CreateIssueModal/index.tsx | 25 +- src/components/Layout/UserDropdown/index.tsx | 9 +- src/components/Login/AddEmailModal.tsx | 16 +- src/components/Login/JellyfinLogin.tsx | 25 +- src/components/Login/LocalLogin.tsx | 14 +- src/components/Login/index.tsx | 22 +- src/components/ManageSlideOver/index.tsx | 27 +- src/components/MovieDetails/index.tsx | 113 +++---- src/components/RequestBlock/index.tsx | 11 +- src/components/RequestButton/index.tsx | 15 +- src/components/RequestCard/index.tsx | 27 +- .../RequestList/RequestItem/index.tsx | 35 +- .../RequestModal/CollectionRequestModal.tsx | 18 +- .../RequestModal/MovieRequestModal.tsx | 57 ++-- .../RequestModal/TvRequestModal.tsx | 71 ++-- .../ResetPassword/RequestResetLink.tsx | 17 +- src/components/ResetPassword/index.tsx | 14 +- src/components/Selector/index.tsx | 80 +++-- .../Notifications/NotificationsDiscord.tsx | 58 ++-- .../Notifications/NotificationsEmail.tsx | 84 ++--- .../NotificationsGotify/index.tsx | 50 +-- .../NotificationsLunaSea/index.tsx | 46 +-- .../NotificationsPushbullet/index.tsx | 46 +-- .../NotificationsPushover/index.tsx | 49 +-- .../NotificationsSlack/index.tsx | 42 +-- .../Notifications/NotificationsTelegram.tsx | 58 ++-- .../NotificationsWebPush/index.tsx | 32 +- .../NotificationsWebhook/index.tsx | 51 +-- .../OverrideRule/OverrideRuleModal.tsx | 36 +- .../OverrideRule/OverrideRuleTiles.tsx | 38 +-- src/components/Settings/RadarrModal/index.tsx | 39 +-- src/components/Settings/SettingsJellyfin.tsx | 99 ++---- .../Settings/SettingsJobsCache/index.tsx | 27 +- .../Settings/SettingsMain/index.tsx | 35 +- .../Settings/SettingsNetwork/index.tsx | 78 +---- src/components/Settings/SettingsPlex.tsx | 112 ++----- src/components/Settings/SettingsServices.tsx | 10 +- .../Settings/SettingsUsers/index.tsx | 36 +- src/components/Settings/SonarrModal/index.tsx | 39 +-- src/components/Setup/JellyfinSetup.tsx | 40 +-- src/components/Setup/LoginWithPlex.tsx | 15 +- src/components/Setup/SetupLogin.tsx | 15 +- src/components/Setup/index.tsx | 31 +- src/components/TitleCard/ErrorCard.tsx | 6 +- src/components/TitleCard/index.tsx | 77 ++--- src/components/TvDetails/index.tsx | 127 +++----- src/components/UserList/BulkEditModal.tsx | 15 +- .../UserList/JellyfinImportModal.tsx | 16 +- src/components/UserList/PlexImportModal.tsx | 16 +- src/components/UserList/index.tsx | 30 +- .../UserGeneralSettings/index.tsx | 53 ++- .../LinkJellyfinModal.tsx | 45 +-- .../UserLinkedAccountsSettings/index.tsx | 31 +- .../UserNotificationsDiscord.tsx | 35 +- .../UserNotificationsEmail.tsx | 35 +- .../UserNotificationsPushbullet.tsx | 35 +- .../UserNotificationsPushover.tsx | 35 +- .../UserNotificationsTelegram.tsx | 37 +-- .../UserNotificationsWebPush/index.tsx | 74 ++--- .../UserSettings/UserPasswordChange/index.tsx | 21 +- .../UserSettings/UserPermissions/index.tsx | 18 +- src/i18n/locale/en.json | 2 - src/pages/_app.tsx | 25 +- src/pages/collection/[collectionId]/index.tsx | 7 +- src/pages/movie/[movieId]/index.tsx | 7 +- src/pages/tv/[tvId]/index.tsx | 7 +- src/utils/fetchOverride.ts | 46 --- src/utils/jellyfin.ts | 45 ++- src/utils/plex.ts | 29 +- 112 files changed, 1654 insertions(+), 3032 deletions(-) delete mode 100644 server/utils/rateLimit.ts delete mode 100644 src/utils/fetchOverride.ts diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index c466b2bf7..7bcb53241 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -23,7 +23,6 @@ "mediaServerType": 1, "partialRequestsEnabled": true, "enableSpecialEpisodes": false, - "forceIpv4First": false, "locale": "en" }, "plex": { diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 78b7e073e..d5adf84f6 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -97,37 +97,7 @@ You can try them all and see which one works for your network. -### Option 2: Force IPV4 resolution first - -Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly. - -You can try to force the resolution to use IPV4 first by going to `Settings > Networking > Advanced Networking` and enabling `Force IPv4 Resolution First` setting and restarting. You can also add the environment variable, `FORCE_IPV4_FIRST=true`: - - - - -Add the following to your `docker run` command: -```bash --e "FORCE_IPV4_FIRST=true" -``` - - - - - -Add the following to your `compose.yaml`: -```yaml ---- -services: - jellyseerr: - environment: - - FORCE_IPV4_FIRST=true -``` - - - - -### Option 3: Use Jellyseerr through a proxy +### Option 2: Use Jellyseerr through a proxy If you can't change your DNS servers or force IPV4 resolution, you can use Jellyseerr through a proxy. diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index 6954992d8..b427bcf87 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -191,9 +191,6 @@ components: csrfProtection: type: boolean example: false - forceIpv4First: - type: boolean - example: false trustProxy: type: boolean example: true diff --git a/next.config.js b/next.config.js index 597cba323..deacbda3b 100644 --- a/next.config.js +++ b/next.config.js @@ -4,7 +4,6 @@ module.exports = { env: { commitTag: process.env.COMMIT_TAG || 'local', - forceIpv4First: process.env.FORCE_IPV4_FIRST === 'true' ? 'true' : 'false', }, images: { remotePatterns: [ diff --git a/package.json b/package.json index 143777810..709d2c6d9 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "@types/wink-jaro-distance": "^2.0.2", "@types/ua-parser-js": "^0.7.36", "ace-builds": "1.15.2", + "axios": "1.3.4", + "axios-rate-limit": "1.3.0", "bcrypt": "5.1.0", "bowser": "2.11.0", "connect-typeorm": "1.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86ec16a6e..ac2a8f218 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,12 @@ importers: ace-builds: specifier: 1.15.2 version: 1.15.2 + axios: + specifier: 1.3.4 + version: 1.3.4 + axios-rate-limit: + specifier: 1.3.0 + version: 1.3.0(axios@1.3.4) bcrypt: specifier: 5.1.0 version: 5.1.0(encoding@0.1.13) @@ -3873,6 +3879,14 @@ packages: resolution: {integrity: sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==} engines: {node: '>=4'} + axios-rate-limit@1.3.0: + resolution: {integrity: sha512-cKR5wTbU/CeeyF1xVl5hl6FlYsmzDVqxlN4rGtfO5x7J83UxKDckudsW0yW21/ZJRcO0Qrfm3fUFbhEbWTLayw==} + peerDependencies: + axios: '*' + + axios@1.3.4: + resolution: {integrity: sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==} + axobject-query@3.1.1: resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==} @@ -5385,6 +5399,15 @@ packages: fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -7849,6 +7872,9 @@ packages: proxy-from-env@1.0.0: resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} @@ -14285,6 +14311,18 @@ snapshots: axe-core@4.9.1: {} + axios-rate-limit@1.3.0(axios@1.3.4): + dependencies: + axios: 1.3.4 + + axios@1.3.4: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.2 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@3.1.1: dependencies: deep-equal: 2.2.3 @@ -15647,7 +15685,7 @@ snapshots: es-get-iterator@1.1.3: dependencies: call-bind: 1.0.7 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 has-symbols: 1.0.3 is-arguments: 1.1.1 is-map: 2.0.3 @@ -15756,7 +15794,7 @@ snapshots: debug: 4.3.5 enhanced-resolve: 5.17.0 eslint: 8.35.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.35.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.35.0))(eslint@8.35.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.54.0(eslint@8.35.0)(typescript@4.9.5))(eslint@8.35.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 @@ -15778,7 +15816,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.35.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.35.0))(eslint@8.35.0): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: @@ -16339,6 +16377,8 @@ snapshots: fn.name@1.1.0: {} + follow-redirects@1.15.9: {} + for-each@0.3.3: dependencies: is-callable: 1.2.7 @@ -17113,7 +17153,7 @@ snapshots: is-weakset@2.0.3: dependencies: call-bind: 1.0.7 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 is-windows@1.0.2: {} @@ -19015,6 +19055,8 @@ snapshots: proxy-from-env@1.0.0: {} + proxy-from-env@1.1.0: {} + psl@1.15.0: dependencies: punycode: 2.3.1 @@ -19525,7 +19567,7 @@ snapshots: define-properties: 1.2.1 es-abstract: 1.23.3 es-errors: 1.3.0 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 globalthis: 1.0.4 which-builtin-type: 1.1.3 diff --git a/server/api/animelist.ts b/server/api/animelist.ts index 175f8bf83..7f859eaed 100644 --- a/server/api/animelist.ts +++ b/server/api/animelist.ts @@ -1,8 +1,7 @@ import logger from '@server/logger'; -import fs, { promises as fsp } from 'node:fs'; -import path from 'node:path'; -import { Readable } from 'node:stream'; -import type { ReadableStream } from 'node:stream/web'; +import axios from 'axios'; +import fs, { promises as fsp } from 'fs'; +import path from 'path'; import xml2js from 'xml2js'; const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds @@ -162,18 +161,14 @@ class AnimeListMapping { label: 'Anime-List Sync', }); try { - const response = await fetch(MAPPING_URL); - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.statusText}`); - } + const response = await axios.get(MAPPING_URL, { + responseType: 'stream', + }); await new Promise((resolve, reject) => { const writer = fs.createWriteStream(LOCAL_PATH); writer.on('finish', resolve); writer.on('error', reject); - if (!response.body) return reject(); - Readable.fromWeb(response.body as ReadableStream).pipe( - writer - ); + response.data.pipe(writer); }); } catch (e) { throw new Error(`Failed to download Anime-List mapping: ${e.message}`); diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 85612808b..82e107182 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -1,7 +1,6 @@ -import { MediaServerType } from '@server/constants/server'; -import { getSettings } from '@server/lib/settings'; -import type { RateLimitOptions } from '@server/utils/rateLimit'; -import rateLimit from '@server/utils/rateLimit'; +import type { AxiosInstance, AxiosRequestConfig } from 'axios'; +import axios from 'axios'; +import rateLimit from 'axios-rate-limit'; import type NodeCache from 'node-cache'; // 5 minute default TTL (in seconds) @@ -13,109 +12,75 @@ const DEFAULT_ROLLING_BUFFER = 10000; interface ExternalAPIOptions { nodeCache?: NodeCache; headers?: Record; - rateLimit?: RateLimitOptions; + rateLimit?: { + maxRPS: number; + maxRequests: number; + }; } class ExternalAPI { - protected fetch: typeof fetch; - protected params: Record; - protected defaultHeaders: { [key: string]: string }; + protected axios: AxiosInstance; private baseUrl: string; private cache?: NodeCache; constructor( baseUrl: string, - params: Record = {}, + params: Record, options: ExternalAPIOptions = {} ) { + this.axios = axios.create({ + baseURL: baseUrl, + params, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...options.headers, + }, + }); + if (options.rateLimit) { - this.fetch = rateLimit(fetch, options.rateLimit); - } else { - this.fetch = fetch; - } - - const url = new URL(baseUrl); - - const settings = getSettings(); - - this.defaultHeaders = { - 'Content-Type': 'application/json', - Accept: 'application/json', - ...((url.username || url.password) && { - Authorization: `Basic ${Buffer.from( - `${url.username}:${url.password}` - ).toString('base64')}`, - }), - ...(settings.main.mediaServerType === MediaServerType.EMBY && { - 'Accept-Encoding': 'gzip', - }), - ...options.headers, - }; - - if (url.username || url.password) { - url.username = ''; - url.password = ''; - baseUrl = url.toString(); + this.axios = rateLimit(this.axios, { + maxRequests: options.rateLimit.maxRequests, + maxRPS: options.rateLimit.maxRPS, + }); } this.baseUrl = baseUrl; - this.params = params; this.cache = options.nodeCache; } protected async get( endpoint: string, - params?: Record, - ttl?: number, - config?: RequestInit + config?: AxiosRequestConfig, + ttl?: number ): Promise { - const headers = { ...this.defaultHeaders, ...config?.headers }; const cacheKey = this.serializeCacheKey(endpoint, { - ...this.params, - ...params, - headers, + ...config?.params, + headers: config?.headers, }); - const cachedItem = this.cache?.get(cacheKey); if (cachedItem) { return cachedItem; } - const url = this.formatUrl(endpoint, params); - const response = await this.fetch(url, { - ...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); + const response = await this.axios.get(endpoint, config); if (this.cache && ttl !== 0) { - this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL); + this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); } - return data; + return response.data; } protected async post( endpoint: string, data?: Record, - params?: Record, - ttl?: number, - config?: RequestInit + config?: AxiosRequestConfig, + ttl?: number ): Promise { - const headers = { ...this.defaultHeaders, ...config?.headers }; const cacheKey = this.serializeCacheKey(endpoint, { - config: { ...this.params, ...params }, - headers, - data, + config: config?.params, + ...(data ? { data } : {}), }); const cachedItem = this.cache?.get(cacheKey); @@ -123,115 +88,23 @@ class ExternalAPI { return cachedItem; } - const url = this.formatUrl(endpoint, params); - const response = await this.fetch(url, { - method: 'POST', - ...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); + const response = await this.axios.post(endpoint, data, config); if (this.cache && ttl !== 0) { - this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL); + this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); } - return resData; - } - - protected async put( - endpoint: string, - data: Record, - params?: Record, - ttl?: number, - config?: RequestInit - ): Promise { - const headers = { ...this.defaultHeaders, ...config?.headers }; - const cacheKey = this.serializeCacheKey(endpoint, { - config: { ...this.params, ...params }, - data, - headers, - }); - - const cachedItem = this.cache?.get(cacheKey); - if (cachedItem) { - return cachedItem; - } - - const url = this.formatUrl(endpoint, params); - const response = await this.fetch(url, { - method: 'PUT', - ...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 && ttl !== 0) { - this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL); - } - - return resData; - } - - protected async delete( - endpoint: string, - params?: Record, - config?: RequestInit - ): Promise { - 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; + return response.data; } protected async getRolling( endpoint: string, - params?: Record, - ttl?: number, - config?: RequestInit, - overwriteBaseUrl?: string + config?: AxiosRequestConfig, + ttl?: number ): Promise { - const headers = { ...this.defaultHeaders, ...config?.headers }; const cacheKey = this.serializeCacheKey(endpoint, { - ...this.params, - ...params, - headers, + ...config?.params, + headers: config?.headers, }); const cachedItem = this.cache?.get(cacheKey); @@ -243,82 +116,29 @@ class ExternalAPI { keyTtl - (ttl ?? DEFAULT_TTL) * 1000 < Date.now() - DEFAULT_ROLLING_BUFFER ) { - const url = this.formatUrl(endpoint, params, overwriteBaseUrl); - this.fetch(url, { - ...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); + this.axios.get(endpoint, config).then((response) => { + this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); }); } return cachedItem; } - const url = this.formatUrl(endpoint, params, overwriteBaseUrl); - const response = await this.fetch(url, { - ...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); + const response = await this.axios.get(endpoint, config); - if (this.cache) { - this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL); + if (this.cache && ttl !== 0) { + this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); } - return data; + return response.data; } protected removeCache(endpoint: string, options?: Record) { const cacheKey = this.serializeCacheKey(endpoint, { - ...this.params, ...options, }); this.cache?.del(cacheKey); } - private formatUrl( - endpoint: string, - params?: Record, - 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, options?: Record @@ -329,29 +149,6 @@ class ExternalAPI { return `${this.baseUrl}${endpoint}${JSON.stringify(options)}`; } - - 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; diff --git a/server/api/github.ts b/server/api/github.ts index 500272188..3a85d91bb 100644 --- a/server/api/github.ts +++ b/server/api/github.ts @@ -1,6 +1,6 @@ -import ExternalAPI from '@server/api/externalapi'; import cacheManager from '@server/lib/cache'; import logger from '@server/logger'; +import ExternalAPI from './externalapi'; interface GitHubRelease { url: string; @@ -67,6 +67,10 @@ class GithubAPI extends ExternalAPI { 'https://api.github.com', {}, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, nodeCache: cacheManager.getCache('github').data, } ); @@ -81,7 +85,9 @@ class GithubAPI extends ExternalAPI { const data = await this.get( '/repos/fallenbagel/jellyseerr/releases', { - per_page: take.toString(), + params: { + per_page: take, + }, } ); @@ -106,8 +112,10 @@ class GithubAPI extends ExternalAPI { const data = await this.get( '/repos/fallenbagel/jellyseerr/commits', { - per_page: take.toString(), - branch, + params: { + per_page: take, + branch, + }, } ); diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index 5ce1d9cf7..42d6e3b70 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -130,6 +130,8 @@ class JellyfinAPI extends ExternalAPI { { headers: { 'X-Emby-Authorization': authHeaderVal, + 'Content-Type': 'application/json', + Accept: 'application/json', }, } ); @@ -143,7 +145,7 @@ class JellyfinAPI extends ExternalAPI { ClientIP?: string ): Promise { const authenticate = async (useHeaders: boolean) => { - const headers: { [key: string]: string } = + const headers = useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {}; return this.post( @@ -152,8 +154,6 @@ class JellyfinAPI extends ExternalAPI { Username, Pw: Password, }, - {}, - undefined, { headers } ); }; @@ -163,36 +163,36 @@ class JellyfinAPI extends ExternalAPI { } catch (e) { logger.debug('Failed to authenticate with headers', { label: 'Jellyfin API', - error: e.cause.message ?? e.cause.statusText, + error: e.response?.statusText, ip: ClientIP, }); - if (!e.cause.status) { + if (!e.response?.status) { throw new ApiError(404, ApiErrorCode.InvalidUrl); } - if (e.cause.status === 401) { - throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials); + if (e.response?.status === 401) { + throw new ApiError(e.response?.status, ApiErrorCode.InvalidCredentials); } } try { return await authenticate(false); } catch (e) { - if (e.cause.status === 401) { - throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials); + if (e.response?.status === 401) { + throw new ApiError(e.response?.status, ApiErrorCode.InvalidCredentials); } logger.error( - 'Something went wrong while authenticating with the Jellyfin server', + `Something went wrong while authenticating with the Jellyfin server: ${e.message}`, { label: 'Jellyfin API', - error: e.cause.message ?? e.cause.statusText, + error: e.response?.status, ip: ClientIP, } ); - throw new ApiError(e.cause.status, ApiErrorCode.Unknown); + throw new ApiError(e.response?.status, ApiErrorCode.Unknown); } } @@ -207,7 +207,7 @@ class JellyfinAPI extends ExternalAPI { return systemInfoResponse; } catch (e) { - throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } @@ -220,11 +220,11 @@ class JellyfinAPI extends ExternalAPI { return serverResponse.ServerName; } catch (e) { logger.error( - 'Something went wrong while getting the server name from the Jellyfin server', - { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } + `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.cause?.status, ApiErrorCode.Unknown); + throw new ApiError(e.response?.status, ApiErrorCode.Unknown); } } @@ -235,11 +235,11 @@ class JellyfinAPI extends ExternalAPI { return { users: userReponse }; } catch (e) { logger.error( - 'Something went wrong while getting the account from the Jellyfin server', - { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } + `Something went wrong while getting the account from the Jellyfin server: ${e.message}`, + { label: 'Jellyfin API', error: e.response?.status } ); - throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } @@ -251,11 +251,11 @@ class JellyfinAPI extends ExternalAPI { return userReponse; } catch (e) { logger.error( - 'Something went wrong while getting the account from the Jellyfin server', - { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } + `Something went wrong while getting the account from the Jellyfin server: ${e.message}`, + { label: 'Jellyfin API', error: e.response?.status } ); - throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } @@ -275,10 +275,10 @@ class JellyfinAPI extends ExternalAPI { return this.mapLibraries(mediaFolderResponse.Items); } catch (e) { logger.error( - 'Something went wrong while getting libraries from the Jellyfin server', + `Something went wrong while getting libraries from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API', - error: e.cause.message ?? e.cause.statusText, + error: e.response?.status, } ); @@ -315,26 +315,20 @@ class JellyfinAPI extends ExternalAPI { public async getLibraryContents(id: string): Promise { try { - const libraryItemsResponse = await this.get(`/Items`, { - SortBy: 'SortName', - SortOrder: 'Ascending', - IncludeItemTypes: 'Series,Movie,Others', - Recursive: 'true', - StartIndex: '0', - ParentId: id, - collapseBoxSetItems: 'false', - }); + const libraryItemsResponse = await this.get( + `/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', - { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } + `Something went wrong while getting library content from the Jellyfin server: ${e.message}`, + { label: 'Jellyfin API', error: e?.response?.status } ); - throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } @@ -344,27 +338,22 @@ class JellyfinAPI extends ExternalAPI { this.mediaServerType === MediaServerType.JELLYFIN ? `/Items/Latest` : `/Users/${this.userId}/Items/Latest`; - - const baseParams = { - Limit: '12', - ParentId: id, - }; - - const params = - this.mediaServerType === MediaServerType.JELLYFIN - ? { ...baseParams, userId: this.userId ?? `Me` } - : baseParams; - - const itemResponse = await this.get(endpoint, params); + const itemResponse = await this.get( + `${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', - { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } + `Something went wrong while getting library content from the Jellyfin server: ${e.message}`, + { label: 'Jellyfin API', error: e.response?.status } ); - throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } @@ -373,23 +362,25 @@ class JellyfinAPI extends ExternalAPI { ): Promise { try { const itemResponse = await this.get(`/Items`, { - ids: id, - fields: 'ProviderIds,MediaSources,Width,Height,IsHD,DateCreated', + params: { + ids: id, + fields: 'ProviderIds,MediaSources,Width,Height,IsHD,DateCreated', + }, }); return itemResponse.Items?.[0]; } catch (e) { if (availabilitySync.running) { - if (e.cause?.status === 500) { + if (e.response?.status === 500) { return undefined; } } logger.error( - 'Something went wrong while getting library content from the Jellyfin server', - { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } + `Something went wrong while getting library content from the Jellyfin server: ${e.message}`, + { label: 'Jellyfin API', error: e.response?.status } ); - throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } @@ -400,11 +391,11 @@ class JellyfinAPI extends ExternalAPI { return seasonResponse.Items; } catch (e) { logger.error( - 'Something went wrong while getting the list of seasons from the Jellyfin server', - { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } + `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.cause?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } @@ -414,10 +405,7 @@ class JellyfinAPI extends ExternalAPI { ): Promise { try { const episodeResponse = await this.get( - `/Shows/${seriesID}/Episodes`, - { - seasonId: seasonID, - } + `/Shows/${seriesID}/Episodes?seasonId=${seasonID}` ); return episodeResponse.Items.filter( @@ -425,11 +413,11 @@ class JellyfinAPI extends ExternalAPI { ); } catch (e) { logger.error( - 'Something went wrong while getting the list of episodes from the Jellyfin server', - { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } + `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.cause?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } @@ -442,8 +430,8 @@ class JellyfinAPI extends ExternalAPI { ).AccessToken; } catch (e) { logger.error( - 'Something went wrong while creating an API key from the Jellyfin server', - { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } + `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); diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 92bffa808..ad3561a4e 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -1,10 +1,10 @@ -import ExternalAPI from '@server/api/externalapi'; import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; import cacheManager from '@server/lib/cache'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { randomUUID } from 'node:crypto'; import xml2js from 'xml2js'; +import ExternalAPI from './externalapi'; interface PlexAccountResponse { user: PlexUser; @@ -143,6 +143,8 @@ class PlexTvAPI extends ExternalAPI { { headers: { 'X-Plex-Token': authToken, + 'Content-Type': 'application/json', + Accept: 'application/json', }, nodeCache: cacheManager.getCache('plextv').data, } @@ -153,11 +155,15 @@ class PlexTvAPI extends ExternalAPI { public async getDevices(): Promise { try { - const devicesResp = await this.get('/api/resources', { - includeHttps: '1', - }); + const devicesResp = await this.axios.get( + '/api/resources?includeHttps=1', + { + transformResponse: [], + responseType: 'text', + } + ); const parsedXml = await xml2js.parseStringPromise( - devicesResp as DeviceResponse + devicesResp.data as DeviceResponse ); return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({ name: pxml.$.name, @@ -205,11 +211,11 @@ class PlexTvAPI extends ExternalAPI { public async getUser(): Promise { try { - const account = await this.get( + const account = await this.axios.get( '/users/account.json' ); - return account.user; + return account.data.user; } catch (e) { logger.error( `Something went wrong while getting the account from plex.tv: ${e.message}`, @@ -249,10 +255,13 @@ class PlexTvAPI extends ExternalAPI { } public async getUsers(): Promise { - const data = await this.get('/api/users'); + const response = await this.axios.get('/api/users', { + transformResponse: [], + responseType: 'text', + }); const parsedXml = (await xml2js.parseStringPromise( - data as string + response.data )) as UsersResponse; return parsedXml; } @@ -272,28 +281,26 @@ class PlexTvAPI extends ExternalAPI { this.authToken ); - const params = new URLSearchParams({ - 'X-Plex-Container-Start': offset.toString(), - 'X-Plex-Container-Size': size.toString(), - }); - const response = await this.fetch( - `https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`, + const response = await this.axios.get( + '/library/sections/watchlist/all', { - headers: { - ...this.defaultHeaders, - ...(cachedWatchlist?.etag - ? { 'If-None-Match': cachedWatchlist.etag } - : {}), + params: { + 'X-Plex-Container-Start': offset, + 'X-Plex-Container-Size': size, }, + headers: { + 'If-None-Match': cachedWatchlist?.etag, + }, + baseURL: 'https://metadata.provider.plex.tv', + validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error } ); - const data = (await response.json()) as WatchlistResponse; // If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache. if (response.status >= 200 && response.status <= 299) { cachedWatchlist = { - etag: response.headers.get('etag') ?? '', - response: data, + etag: response.headers.etag, + response: response.data, }; watchlistCache.data.set( @@ -307,10 +314,9 @@ class PlexTvAPI extends ExternalAPI { async (watchlistItem) => { const detailedResponse = await this.getRolling( `/library/metadata/${watchlistItem.ratingKey}`, - {}, - undefined, - {}, - 'https://metadata.provider.plex.tv' + { + baseURL: 'https://metadata.provider.plex.tv', + } ); const metadata = detailedResponse.MediaContainer.Metadata[0]; @@ -361,16 +367,11 @@ class PlexTvAPI extends ExternalAPI { public async pingToken() { try { - const data: { pong: unknown } = await this.get( - '/api/v2/ping', - {}, - undefined, - { - headers: { - 'X-Plex-Client-Identifier': randomUUID(), - }, - } - ); + const data: { pong: unknown } = await this.get('/api/v2/ping', { + headers: { + 'X-Plex-Client-Identifier': randomUUID(), + }, + }); if (!data?.pong) { throw new Error('No pong response'); } diff --git a/server/api/pushover.ts b/server/api/pushover.ts index 31d0639f8..41754368d 100644 --- a/server/api/pushover.ts +++ b/server/api/pushover.ts @@ -1,4 +1,4 @@ -import ExternalAPI from '@server/api/externalapi'; +import ExternalAPI from './externalapi'; interface PushoverSoundsResponse { sounds: { @@ -26,13 +26,24 @@ export const mapSounds = (sounds: { class PushoverAPI extends ExternalAPI { constructor() { - super('https://api.pushover.net/1'); + super( + 'https://api.pushover.net/1', + {}, + { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + } + ); } public async getSounds(appToken: string): Promise { try { const data = await this.get('/sounds.json', { - token: appToken, + params: { + token: appToken, + }, }); return mapSounds(data.sounds); diff --git a/server/api/rating/imdbRadarrProxy.ts b/server/api/rating/imdbRadarrProxy.ts index f7b101cde..15ee551ef 100644 --- a/server/api/rating/imdbRadarrProxy.ts +++ b/server/api/rating/imdbRadarrProxy.ts @@ -155,13 +155,13 @@ export interface IMDBRating { */ class IMDBRadarrProxy extends ExternalAPI { constructor() { - super( - 'https://api.radarr.video/v1', - {}, - { - nodeCache: cacheManager.getCache('imdb').data, - } - ); + super('https://api.radarr.video/v1', { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + nodeCache: cacheManager.getCache('imdb').data, + }); } /** diff --git a/server/api/rating/rottentomatoes.ts b/server/api/rating/rottentomatoes.ts index bfded7671..346144336 100644 --- a/server/api/rating/rottentomatoes.ts +++ b/server/api/rating/rottentomatoes.ts @@ -105,12 +105,15 @@ class RottenTomatoes extends ExternalAPI { super( 'https://79frdp12pn-dsn.algolia.net/1/indexes/*', { - 'x-algolia-agent': 'Algolia for JavaScript (4.14.3); Browser (lite)', + 'x-algolia-agent': + 'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)', 'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561', 'x-algolia-application-id': '79FRDP12PN', }, { headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', 'x-algolia-usertoken': settings.clientId, }, nodeCache: cacheManager.getCache('rt').data, diff --git a/server/api/servarr/base.ts b/server/api/servarr/base.ts index 8b0d5ca09..c49b93610 100644 --- a/server/api/servarr/base.ts +++ b/server/api/servarr/base.ts @@ -113,9 +113,9 @@ class ServarrBase extends ExternalAPI { public getSystemStatus = async (): Promise => { try { - const data = await this.get('/system/status'); + const response = await this.axios.get('/system/status'); - return data; + return response.data; } catch (e) { throw new Error( `[${this.apiName}] Failed to retrieve system status: ${e.message}` @@ -157,15 +157,16 @@ class ServarrBase extends ExternalAPI { public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => { try { - const data = await this.get>( + const response = await this.axios.get>( `/queue`, { - includeEpisode: 'true', - }, - 0 + params: { + includeEpisode: true, + }, + } ); - return data.records; + return response.data.records; } catch (e) { throw new Error( `[${this.apiName}] Failed to retrieve queue: ${e.message}` @@ -175,9 +176,9 @@ class ServarrBase extends ExternalAPI { public getTags = async (): Promise => { try { - const data = await this.get(`/tag`); + const response = await this.axios.get(`/tag`); - return data; + return response.data; } catch (e) { throw new Error( `[${this.apiName}] Failed to retrieve tags: ${e.message}` @@ -187,11 +188,11 @@ class ServarrBase extends ExternalAPI { public createTag = async ({ label }: { label: string }): Promise => { try { - const data = await this.post(`/tag`, { + const response = await this.axios.post(`/tag`, { label, }); - return data; + return response.data; } catch (e) { throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`); } @@ -206,15 +207,10 @@ class ServarrBase extends ExternalAPI { options: Record ): Promise { try { - await this.post( - `/command`, - { - name: commandName, - ...options, - }, - {}, - 0 - ); + await this.axios.post(`/command`, { + name: commandName, + ...options, + }); } catch (e) { throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`); } diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index 638af88a2..3d0cf53ad 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -70,9 +70,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { public getMovies = async (): Promise => { try { - const data = await this.get('/movie'); + const response = await this.axios.get('/movie'); - return data; + return response.data; } catch (e) { throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`); } @@ -80,9 +80,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { public getMovie = async ({ id }: { id: number }): Promise => { try { - const data = await this.get(`/movie/${id}`); + const response = await this.axios.get(`/movie/${id}`); - return data; + return response.data; } catch (e) { throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`); } @@ -90,15 +90,17 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { public async getMovieByTmdbId(id: number): Promise { try { - const data = await this.get('/movie/lookup', { - term: `tmdb:${id}`, + const response = await this.axios.get('/movie/lookup', { + params: { + term: `tmdb:${id}`, + }, }); - if (!data[0]) { + if (!response.data[0]) { throw new Error('Movie not found'); } - return data[0]; + return response.data[0]; } catch (e) { logger.error('Error retrieving movie by TMDB ID', { label: 'Radarr API', @@ -128,7 +130,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { // movie exists in Radarr but is neither downloaded nor monitored if (movie.id && !movie.monitored) { - const data = await this.put(`/movie`, { + const response = await this.axios.put(`/movie`, { ...movie, title: options.title, qualityProfileId: options.qualityProfileId, @@ -145,25 +147,25 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { }, }); - if (data.monitored) { + if (response.data.monitored) { logger.info( 'Found existing title in Radarr and set it to monitored.', { label: 'Radarr', - movieId: data.id, - movieTitle: data.title, + movieId: response.data.id, + movieTitle: response.data.title, } ); logger.debug('Radarr update details', { label: 'Radarr', - movie: data, + movie: response.data, }); if (options.searchNow) { - this.searchMovie(data.id); + this.searchMovie(response.data.id); } - return data; + return response.data; } else { logger.error('Failed to update existing movie in Radarr.', { label: 'Radarr', @@ -181,7 +183,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { return movie; } - const data = await this.post(`/movie`, { + const response = await this.axios.post(`/movie`, { title: options.title, qualityProfileId: options.qualityProfileId, profileId: options.profileId, @@ -197,11 +199,11 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { }, }); - if (data.id) { + if (response.data.id) { logger.info('Radarr accepted request', { label: 'Radarr' }); logger.debug('Radarr add details', { label: 'Radarr', - movie: data, + movie: response.data, }); } else { logger.error('Failed to add movie to Radarr', { @@ -210,22 +212,15 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { }); throw new Error('Failed to add movie to Radarr'); } - return data; + return response.data; } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error( 'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.', { label: 'Radarr', errorMessage: e.message, options, - response: errorData, + response: e?.response?.data, } ); throw new Error('Failed to add movie to Radarr'); @@ -254,9 +249,11 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { public removeMovie = async (movieId: number): Promise => { try { const { id, title } = await this.getMovieByTmdbId(movieId); - await this.delete(`/movie/${id}`, { - deleteFiles: 'true', - addImportExclusion: 'false', + await this.axios.delete(`/movie/${id}`, { + params: { + deleteFiles: true, + addImportExclusion: false, + }, }); logger.info(`[Radarr] Removed movie ${title}`); } catch (e) { @@ -274,13 +271,10 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { if (tmdbId) { this.removeCache('/movie/lookup', { term: `tmdb:${tmdbId}`, - headers: this.defaultHeaders, }); } if (externalId) { - this.removeCache(`/movie/${externalId}`, { - headers: this.defaultHeaders, - }); + this.removeCache(`/movie/${externalId}`); } }; } diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 0cbd4a57d..0e623cef6 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -117,9 +117,9 @@ class SonarrAPI extends ServarrBase<{ public async getSeries(): Promise { try { - const data = await this.get('/series'); + const response = await this.axios.get('/series'); - return data; + return response.data; } catch (e) { throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`); } @@ -127,9 +127,9 @@ class SonarrAPI extends ServarrBase<{ public async getSeriesById(id: number): Promise { try { - const data = await this.get(`/series/${id}`); + const response = await this.axios.get(`/series/${id}`); - return data; + return response.data; } catch (e) { throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`); } @@ -137,15 +137,17 @@ class SonarrAPI extends ServarrBase<{ public async getSeriesByTitle(title: string): Promise { try { - const data = await this.get('/series/lookup', { - term: title, + const response = await this.axios.get('/series/lookup', { + params: { + term: title, + }, }); - if (!data[0]) { + if (!response.data[0]) { throw new Error('No series found'); } - return data; + return response.data; } catch (e) { logger.error('Error retrieving series by series title', { label: 'Sonarr API', @@ -158,15 +160,17 @@ class SonarrAPI extends ServarrBase<{ public async getSeriesByTvdbId(id: number): Promise { try { - const data = await this.get('/series/lookup', { - term: `tvdb:${id}`, + const response = await this.axios.get('/series/lookup', { + params: { + term: `tvdb:${id}`, + }, }); - if (!data[0]) { + if (!response.data[0]) { throw new Error('Series not found'); } - return data[0]; + return response.data[0]; } catch (e) { logger.error('Error retrieving series by tvdb ID', { label: 'Sonarr API', @@ -189,27 +193,27 @@ class SonarrAPI extends ServarrBase<{ : series.tags; series.seasons = this.buildSeasonList(options.seasons, series.seasons); - const newSeriesData = await this.put( + const newSeriesResponse = await this.axios.put( '/series', - series as any + series ); - if (newSeriesData.id) { + if (newSeriesResponse.data.id) { logger.info('Updated existing series in Sonarr.', { label: 'Sonarr', - seriesId: newSeriesData.id, - seriesTitle: newSeriesData.title, + seriesId: newSeriesResponse.data.id, + seriesTitle: newSeriesResponse.data.title, }); logger.debug('Sonarr update details', { label: 'Sonarr', - movie: newSeriesData, + series: newSeriesResponse.data, }); if (options.searchNow) { - this.searchSeries(newSeriesData.id); + this.searchSeries(newSeriesResponse.data.id); } - return newSeriesData; + return newSeriesResponse.data; } else { logger.error('Failed to update series in Sonarr', { label: 'Sonarr', @@ -219,35 +223,38 @@ class SonarrAPI extends ServarrBase<{ } } - const createdSeriesData = await this.post('/series', { - tvdbId: options.tvdbid, - title: options.title, - qualityProfileId: options.profileId, - languageProfileId: options.languageProfileId, - seasons: this.buildSeasonList( - options.seasons, - series.seasons.map((season) => ({ - seasonNumber: season.seasonNumber, - // We force all seasons to false if its the first request - monitored: false, - })) - ), - tags: options.tags, - seasonFolder: options.seasonFolder, - monitored: options.monitored, - rootFolderPath: options.rootFolderPath, - seriesType: options.seriesType, - addOptions: { - ignoreEpisodesWithFiles: true, - searchForMissingEpisodes: options.searchNow, - }, - } as Partial); + const createdSeriesResponse = await this.axios.post( + '/series', + { + tvdbId: options.tvdbid, + title: options.title, + qualityProfileId: options.profileId, + languageProfileId: options.languageProfileId, + seasons: this.buildSeasonList( + options.seasons, + series.seasons.map((season) => ({ + seasonNumber: season.seasonNumber, + // We force all seasons to false if its the first request + monitored: false, + })) + ), + tags: options.tags, + seasonFolder: options.seasonFolder, + monitored: options.monitored, + rootFolderPath: options.rootFolderPath, + seriesType: options.seriesType, + addOptions: { + ignoreEpisodesWithFiles: true, + searchForMissingEpisodes: options.searchNow, + }, + } as Partial + ); - if (createdSeriesData.id) { + if (createdSeriesResponse.data.id) { logger.info('Sonarr accepted request', { label: 'Sonarr' }); logger.debug('Sonarr add details', { label: 'Sonarr', - movie: createdSeriesData, + series: createdSeriesResponse.data, }); } else { logger.error('Failed to add movie to Sonarr', { @@ -257,20 +264,13 @@ class SonarrAPI extends ServarrBase<{ throw new Error('Failed to add series to Sonarr'); } - return createdSeriesData; + return createdSeriesResponse.data; } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Something went wrong while adding a series to Sonarr.', { label: 'Sonarr API', errorMessage: e.message, options, - response: errorData, + response: e?.response?.data, }); throw new Error('Failed to add series'); } @@ -342,13 +342,14 @@ class SonarrAPI extends ServarrBase<{ return newSeasons; } - public removeSerie = async (serieId: number): Promise => { try { const { id, title } = await this.getSeriesByTvdbId(serieId); - await this.delete(`/series/${id}`, { - deleteFiles: 'true', - addImportExclusion: 'false', + await this.axios.delete(`/series/${id}`, { + params: { + deleteFiles: true, + addImportExclusion: false, + }, }); logger.info(`[Radarr] Removed serie ${title}`); } catch (e) { @@ -368,18 +369,14 @@ class SonarrAPI extends ServarrBase<{ if (tvdbId) { this.removeCache('/series/lookup', { term: `tvdb:${tvdbId}`, - headers: this.defaultHeaders, }); } if (externalId) { - this.removeCache(`/series/${externalId}`, { - headers: this.defaultHeaders, - }); + this.removeCache(`/series/${externalId}`); } if (title) { this.removeCache('/series/lookup', { term: title, - headers: this.defaultHeaders, }); } }; diff --git a/server/api/tautulli.ts b/server/api/tautulli.ts index a7e667033..0e5e07071 100644 --- a/server/api/tautulli.ts +++ b/server/api/tautulli.ts @@ -1,7 +1,8 @@ -import ExternalAPI from '@server/api/externalapi'; import type { User } from '@server/entity/User'; import type { TautulliSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import type { AxiosInstance } from 'axios'; +import axios from 'axios'; import { uniqWith } from 'lodash'; export interface TautulliHistoryRecord { @@ -112,25 +113,25 @@ interface TautulliInfoResponse { }; } -class TautulliAPI extends ExternalAPI { +class TautulliAPI { + private axios: AxiosInstance; + constructor(settings: TautulliSettings) { - super( - `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${ + this.axios = axios.create({ + baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${ settings.port }${settings.urlBase ?? ''}`, - { - apikey: settings.apiKey || '', - } - ); + params: { apikey: settings.apiKey }, + }); } public async getInfo(): Promise { try { return ( - await this.get('/api/v2', { - cmd: 'get_tautulli_info', + await this.axios.get('/api/v2', { + params: { cmd: 'get_tautulli_info' }, }) - ).response.data; + ).data.response.data; } catch (e) { logger.error('Something went wrong fetching Tautulli server info', { label: 'Tautulli API', @@ -147,12 +148,14 @@ class TautulliAPI extends ExternalAPI { ): Promise { try { return ( - await this.get('/api/v2', { - cmd: 'get_item_watch_time_stats', - rating_key: ratingKey, - grouping: '1', + await this.axios.get('/api/v2', { + params: { + cmd: 'get_item_watch_time_stats', + rating_key: ratingKey, + grouping: 1, + }, }) - ).response.data; + ).data.response.data; } catch (e) { logger.error( 'Something went wrong fetching media watch stats from Tautulli', @@ -173,12 +176,14 @@ class TautulliAPI extends ExternalAPI { ): Promise { try { return ( - await this.get('/api/v2', { - cmd: 'get_item_user_stats', - rating_key: ratingKey, - grouping: '1', + await this.axios.get('/api/v2', { + params: { + cmd: 'get_item_user_stats', + rating_key: ratingKey, + grouping: 1, + }, }) - ).response.data; + ).data.response.data; } catch (e) { logger.error( 'Something went wrong fetching media watch users from Tautulli', @@ -201,13 +206,15 @@ class TautulliAPI extends ExternalAPI { } return ( - await this.get('/api/v2', { - cmd: 'get_user_watch_time_stats', - user_id: user.plexId.toString(), - query_days: '0', - grouping: '1', + await this.axios.get('/api/v2', { + params: { + cmd: 'get_user_watch_time_stats', + user_id: user.plexId, + query_days: 0, + grouping: 1, + }, }) - ).response.data[0]; + ).data.response.data[0]; } catch (e) { logger.error( 'Something went wrong fetching user watch stats from Tautulli', @@ -238,17 +245,19 @@ class TautulliAPI extends ExternalAPI { while (results.length < 20) { const tautulliData = ( - await this.get('/api/v2', { - cmd: 'get_history', - grouping: '1', - order_column: 'date', - order_dir: 'desc', - user_id: user.plexId.toString(), - media_type: 'movie,episode', - length: take.toString(), - start: start.toString(), + await this.axios.get('/api/v2', { + params: { + cmd: 'get_history', + grouping: 1, + order_column: 'date', + order_dir: 'desc', + user_id: user.plexId, + media_type: 'movie,episode', + length: take, + start, + }, }) - ).response.data.data; + ).data.response.data.data; if (!tautulliData.length) { return results; diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 1e2caa710..6b6c86ec1 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -115,8 +115,8 @@ class TheMovieDb extends ExternalAPI { { nodeCache: cacheManager.getCache('tmdb').data, rateLimit: { + maxRequests: 20, maxRPS: 50, - id: 'tmdb', }, } ); @@ -133,10 +133,7 @@ class TheMovieDb extends ExternalAPI { }: SearchOptions): Promise => { try { const data = await this.get('/search/multi', { - query, - page: page.toString(), - include_adult: includeAdult ? 'true' : 'false', - language, + params: { query, page, include_adult: includeAdult, language }, }); return data; @@ -159,11 +156,13 @@ class TheMovieDb extends ExternalAPI { }: SingleSearchOptions): Promise => { try { const data = await this.get('/search/movie', { - query, - page: page.toString(), - include_adult: includeAdult ? 'true' : 'false', - language, - primary_release_year: year?.toString() || '', + params: { + query, + page, + include_adult: includeAdult, + language, + primary_release_year: year, + }, }); return data; @@ -186,11 +185,13 @@ class TheMovieDb extends ExternalAPI { }: SingleSearchOptions): Promise => { try { const data = await this.get('/search/tv', { - query, - page: page.toString(), - include_adult: includeAdult ? 'true' : 'false', - language, - first_air_date_year: year?.toString() || '', + params: { + query, + page, + include_adult: includeAdult, + language, + first_air_date_year: year, + }, }); return data; @@ -213,7 +214,7 @@ class TheMovieDb extends ExternalAPI { }): Promise => { try { const data = await this.get(`/person/${personId}`, { - language, + params: { language }, }); return data; @@ -233,7 +234,7 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( `/person/${personId}/combined_credits`, { - language, + params: { language }, } ); @@ -256,10 +257,12 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( `/movie/${movieId}`, { - language, - append_to_response: - 'credits,external_ids,videos,keywords,release_dates,watch/providers', - include_video_language: language + ', en', + params: { + language, + append_to_response: + 'credits,external_ids,videos,keywords,release_dates,watch/providers', + include_video_language: language + ', en', + }, }, 43200 ); @@ -281,10 +284,12 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( `/tv/${tvId}`, { - language, - append_to_response: - 'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers', - include_video_language: language + ', en', + params: { + language, + append_to_response: + 'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers', + include_video_language: language + ', en', + }, }, 43200 ); @@ -308,8 +313,10 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( `/tv/${tvId}/season/${seasonNumber}`, { - language: language || '', - append_to_response: 'external_ids', + params: { + language, + append_to_response: 'external_ids', + }, } ); @@ -332,8 +339,10 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( `/movie/${movieId}/recommendations`, { - page: page.toString(), - language, + params: { + page, + language, + }, } ); @@ -356,8 +365,10 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( `/movie/${movieId}/similar`, { - page: page.toString(), - language, + params: { + page, + language, + }, } ); @@ -380,8 +391,10 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( `/keyword/${keywordId}/movies`, { - page: page.toString(), - language, + params: { + page, + language, + }, } ); @@ -404,8 +417,10 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( `/tv/${tvId}/recommendations`, { - page: page.toString(), - language, + params: { + page, + language, + }, } ); @@ -428,8 +443,10 @@ class TheMovieDb extends ExternalAPI { }): Promise { try { const data = await this.get(`/tv/${tvId}/similar`, { - page: page.toString(), - language, + params: { + page, + language, + }, }); return data; @@ -470,38 +487,40 @@ class TheMovieDb extends ExternalAPI { .split('T')[0]; const data = await this.get('/discover/movie', { - sort_by: sortBy, - page: page.toString(), - include_adult: includeAdult ? 'true' : 'false', - language, - region: this.discoverRegion || '', - with_original_language: - originalLanguage && originalLanguage !== 'all' - ? originalLanguage - : originalLanguage === 'all' - ? '' - : this.originalLanguage || '', - // Set our release date values, but check if one is set and not the other, - // so we can force a past date or a future date. TMDB Requires both values if one is set! - 'primary_release_date.gte': - !primaryReleaseDateGte && primaryReleaseDateLte - ? defaultPastDate - : primaryReleaseDateGte || '', - 'primary_release_date.lte': - !primaryReleaseDateLte && primaryReleaseDateGte - ? defaultFutureDate - : primaryReleaseDateLte || '', - with_genres: genre || '', - with_companies: studio || '', - with_keywords: keywords || '', - 'with_runtime.gte': withRuntimeGte || '', - 'with_runtime.lte': withRuntimeLte || '', - 'vote_average.gte': voteAverageGte || '', - 'vote_average.lte': voteAverageLte || '', - 'vote_count.gte': voteCountGte || '', - 'vote_count.lte': voteCountLte || '', - watch_region: watchRegion || '', - with_watch_providers: watchProviders || '', + params: { + sort_by: sortBy, + page, + include_adult: includeAdult, + language, + region: this.discoverRegion || '', + with_original_language: + originalLanguage && originalLanguage !== 'all' + ? originalLanguage + : originalLanguage === 'all' + ? undefined + : this.originalLanguage, + // Set our release date values, but check if one is set and not the other, + // so we can force a past date or a future date. TMDB Requires both values if one is set! + 'primary_release_date.gte': + !primaryReleaseDateGte && primaryReleaseDateLte + ? defaultPastDate + : primaryReleaseDateGte, + 'primary_release_date.lte': + !primaryReleaseDateLte && primaryReleaseDateGte + ? defaultFutureDate + : primaryReleaseDateLte, + with_genres: genre, + with_companies: studio, + with_keywords: keywords, + 'with_runtime.gte': withRuntimeGte, + 'with_runtime.lte': withRuntimeLte, + 'vote_average.gte': voteAverageGte, + 'vote_average.lte': voteAverageLte, + 'vote_count.gte': voteCountGte, + 'vote_count.lte': voteCountLte, + watch_region: watchRegion, + with_watch_providers: watchProviders, + }, }); return data; @@ -543,41 +562,41 @@ class TheMovieDb extends ExternalAPI { .split('T')[0]; const data = await this.get('/discover/tv', { - sort_by: sortBy, - page: page.toString(), - language, - region: this.discoverRegion || '', - // Set our release date values, but check if one is set and not the other, - // so we can force a past date or a future date. TMDB Requires both values if one is set! - 'first_air_date.gte': - !firstAirDateGte && firstAirDateLte - ? defaultPastDate - : firstAirDateGte || '', - 'first_air_date.lte': - !firstAirDateLte && firstAirDateGte - ? defaultFutureDate - : firstAirDateLte || '', - with_original_language: - originalLanguage && originalLanguage !== 'all' - ? originalLanguage - : originalLanguage === 'all' - ? '' - : this.originalLanguage || '', - include_null_first_air_dates: includeEmptyReleaseDate - ? 'true' - : 'false', - with_genres: genre || '', - with_networks: network?.toString() || '', - with_keywords: keywords || '', - 'with_runtime.gte': withRuntimeGte || '', - 'with_runtime.lte': withRuntimeLte || '', - 'vote_average.gte': voteAverageGte || '', - 'vote_average.lte': voteAverageLte || '', - 'vote_count.gte': voteCountGte || '', - 'vote_count.lte': voteCountLte || '', - with_watch_providers: watchProviders || '', - watch_region: watchRegion || '', - with_status: withStatus || '', + params: { + sort_by: sortBy, + page, + language, + region: this.discoverRegion || '', + // Set our release date values, but check if one is set and not the other, + // so we can force a past date or a future date. TMDB Requires both values if one is set! + 'first_air_date.gte': + !firstAirDateGte && firstAirDateLte + ? defaultPastDate + : firstAirDateGte, + 'first_air_date.lte': + !firstAirDateLte && firstAirDateGte + ? defaultFutureDate + : firstAirDateLte, + with_original_language: + originalLanguage && originalLanguage !== 'all' + ? originalLanguage + : originalLanguage === 'all' + ? undefined + : this.originalLanguage, + include_null_first_air_dates: includeEmptyReleaseDate, + with_genres: genre, + with_networks: network, + with_keywords: keywords, + 'with_runtime.gte': withRuntimeGte, + 'with_runtime.lte': withRuntimeLte, + 'vote_average.gte': voteAverageGte, + 'vote_average.lte': voteAverageLte, + 'vote_count.gte': voteCountGte, + 'vote_count.lte': voteCountLte, + with_watch_providers: watchProviders, + watch_region: watchRegion, + with_status: withStatus, + }, }); return data; @@ -597,10 +616,12 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( '/movie/upcoming', { - page: page.toString(), - language, - region: this.discoverRegion || '', - originalLanguage: this.originalLanguage || '', + params: { + page, + language, + region: this.discoverRegion, + originalLanguage: this.originalLanguage, + }, } ); @@ -623,9 +644,11 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( `/trending/all/${timeWindow}`, { - page: page.toString(), - language, - region: this.discoverRegion || '', + params: { + page, + language, + region: this.discoverRegion, + }, } ); @@ -646,7 +669,9 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( `/trending/movie/${timeWindow}`, { - page: page.toString(), + params: { + page, + }, } ); @@ -667,7 +692,9 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( `/trending/tv/${timeWindow}`, { - page: page.toString(), + params: { + page, + }, } ); @@ -696,8 +723,10 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( `/find/${externalId}`, { - external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id', - language, + params: { + external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id', + language, + }, } ); @@ -787,7 +816,9 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( `/collection/${collectionId}`, { - language, + params: { + language, + }, } ); @@ -860,7 +891,9 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( '/genre/movie/list', { - language, + params: { + language, + }, }, 86400 // 24 hours ); @@ -872,7 +905,9 @@ class TheMovieDb extends ExternalAPI { const englishData = await this.get( '/genre/movie/list', { - language: 'en', + params: { + language: 'en', + }, }, 86400 // 24 hours ); @@ -907,7 +942,9 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( '/genre/tv/list', { - language, + params: { + language, + }, }, 86400 // 24 hours ); @@ -919,7 +956,9 @@ class TheMovieDb extends ExternalAPI { const englishData = await this.get( '/genre/tv/list', { - language: 'en', + params: { + language: 'en', + }, }, 86400 // 24 hours ); @@ -974,8 +1013,10 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( '/search/keyword', { - query, - page: page.toString(), + params: { + query, + page, + }, }, 86400 // 24 hours ); @@ -997,8 +1038,10 @@ class TheMovieDb extends ExternalAPI { const data = await this.get( '/search/company', { - query, - page: page.toString(), + params: { + query, + page, + }, }, 86400 // 24 hours ); @@ -1018,7 +1061,9 @@ class TheMovieDb extends ExternalAPI { const data = await this.get<{ results: TmdbWatchProviderRegion[] }>( '/watch/providers/regions', { - language: language ? this.originalLanguage || '' : '', + params: { + language: language ?? this.originalLanguage, + }, }, 86400 // 24 hours ); @@ -1042,8 +1087,10 @@ class TheMovieDb extends ExternalAPI { const data = await this.get<{ results: TmdbWatchProviderDetails[] }>( '/watch/providers/movie', { - language: language ? this.originalLanguage || '' : '', - watch_region: watchRegion, + params: { + language: language ?? this.originalLanguage, + watch_region: watchRegion, + }, }, 86400 // 24 hours ); @@ -1067,8 +1114,10 @@ class TheMovieDb extends ExternalAPI { const data = await this.get<{ results: TmdbWatchProviderDetails[] }>( '/watch/providers/tv', { - language: language ? this.originalLanguage || '' : '', - watch_region: watchRegion, + params: { + language: language ?? this.originalLanguage, + watch_region: watchRegion, + }, }, 86400 // 24 hours ); diff --git a/server/index.ts b/server/index.ts index d8aadfa0a..cca0ab82e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -35,8 +35,6 @@ import * as OpenApiValidator from 'express-openapi-validator'; import type { Store } from 'express-session'; import session from 'express-session'; import next from 'next'; -import dns from 'node:dns'; -import net from 'node:net'; import path from 'path'; import swaggerUi from 'swagger-ui-express'; import YAML from 'yamljs'; @@ -74,15 +72,6 @@ app const settings = await getSettings().load(); restartFlag.initializeSettings(settings); - // Check if we force IPv4 first - if ( - process.env.forceIpv4First === 'true' || - settings.network.forceIpv4First - ) { - dns.setDefaultResultOrder('ipv4first'); - net.setDefaultAutoSelectFamily(false); - } - // Register HTTP proxy if (settings.network.proxy.enabled) { await createCustomProxyAgent(settings.network.proxy); diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index 607245698..079fcc011 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -1,9 +1,8 @@ import logger from '@server/logger'; -import type { RateLimitOptions } from '@server/utils/rateLimit'; -import rateLimit from '@server/utils/rateLimit'; +import axios from 'axios'; +import rateLimit, { type rateLimitOptions } from 'axios-rate-limit'; import { createHash } from 'crypto'; import { promises } from 'fs'; -import mime from 'mime/lite'; import path, { join } from 'path'; type ImageResponse = { @@ -131,33 +130,29 @@ class ImageProxy { return 0; } - private fetch: typeof fetch; + private axios; private cacheVersion; private key; - private baseUrl; - private headers: HeadersInit | null = null; constructor( key: string, baseUrl: string, options: { cacheVersion?: number; - rateLimitOptions?: RateLimitOptions; - headers?: HeadersInit; + rateLimitOptions?: rateLimitOptions; + headers?: Record; } = {} ) { this.cacheVersion = options.cacheVersion ?? 1; - this.baseUrl = baseUrl; this.key = key; + this.axios = axios.create({ + baseURL: baseUrl, + headers: options.headers, + }); if (options.rateLimitOptions) { - this.fetch = rateLimit(fetch, { - ...options.rateLimitOptions, - }); - } else { - this.fetch = fetch; + this.axios = rateLimit(this.axios, options.rateLimitOptions); } - this.headers = options.headers || null; } public async getImage( @@ -269,34 +264,19 @@ class ImageProxy { ): Promise { try { const directory = join(this.getCacheDirectory(), cacheKey); - const href = - this.baseUrl + - (this.baseUrl.length > 0 - ? this.baseUrl.endsWith('/') - ? '' - : '/' - : '') + - (path.startsWith('/') ? path.slice(1) : path); - const response = await this.fetch(href, { - headers: this.headers || undefined, + const response = await this.axios.get(path, { + responseType: 'arraybuffer', }); - if (!response.ok) { - return null; - } - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - const extension = mime.getExtension( - response.headers.get('content-type') ?? '' - ); + const buffer = Buffer.from(response.data, 'binary'); + const extension = path.split('.').pop() ?? ''; let maxAge = Number( - (response.headers.get('cache-control') ?? '0').split('=')[1] + (response.headers['cache-control'] ?? '0').split('=')[1] ); if (!maxAge) maxAge = 86400; const expireAt = Date.now() + maxAge * 1000; - const etag = (response.headers.get('etag') ?? '').replace(/"/g, ''); + const etag = (response.headers.etag ?? '').replace(/"/g, ''); await this.writeToCacheDir( directory, diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 242ee728d..cabd332de 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -4,6 +4,7 @@ import { User } from '@server/entity/User'; import type { NotificationAgentDiscord } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import logger from '@server/logger'; +import axios from 'axios'; import { hasNotificationType, Notification, @@ -297,39 +298,23 @@ class DiscordAgent userMentions.push(`<@&${settings.options.webhookRoleId}>`); } - const response = await fetch(settings.options.webhookUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username: settings.options.botUsername - ? settings.options.botUsername - : getSettings().main.applicationTitle, - avatar_url: settings.options.botAvatarUrl, - embeds: [this.buildEmbed(type, payload)], - content: userMentions.join(' '), - } as DiscordWebhookPayload), - }); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } + await axios.post(settings.options.webhookUrl, { + username: settings.options.botUsername + ? settings.options.botUsername + : getSettings().main.applicationTitle, + avatar_url: settings.options.botAvatarUrl, + embeds: [this.buildEmbed(type, payload)], + content: userMentions.join(' '), + } as DiscordWebhookPayload); return true; } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error sending Discord notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: errorData, + response: e?.response?.data, }); return false; diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts index 2f4bddf15..514281f72 100644 --- a/server/lib/notifications/agents/gotify.ts +++ b/server/lib/notifications/agents/gotify.ts @@ -2,6 +2,7 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue'; import type { NotificationAgentGotify } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import axios from 'axios'; import { hasNotificationType, Notification } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent } from './agent'; @@ -139,32 +140,16 @@ class GotifyAgent const endpoint = `${settings.options.url}/message?token=${settings.options.token}`; const notificationPayload = this.getNotificationPayload(type, payload); - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(notificationPayload), - }); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } + await axios.post(endpoint, notificationPayload); return true; } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error sending Gotify notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: errorData, + response: e?.response?.data, }); return false; diff --git a/server/lib/notifications/agents/lunasea.ts b/server/lib/notifications/agents/lunasea.ts index b8e473845..acfa7df9d 100644 --- a/server/lib/notifications/agents/lunasea.ts +++ b/server/lib/notifications/agents/lunasea.ts @@ -3,6 +3,7 @@ import { MediaStatus } from '@server/constants/media'; import type { NotificationAgentLunaSea } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import axios from 'axios'; import { hasNotificationType, Notification } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent } from './agent'; @@ -100,39 +101,28 @@ class LunaSeaAgent }); try { - const response = await fetch(settings.options.webhookUrl, { - method: 'POST', - headers: settings.options.profileName + await axios.post( + settings.options.webhookUrl, + this.buildPayload(type, payload), + settings.options.profileName ? { - 'Content-Type': 'application/json', + headers: { + Authorization: `Basic ${Buffer.from( + `${settings.options.profileName}:` + ).toString('base64')}`, + }, } - : { - 'Content-Type': 'application/json', - Authorization: `Basic ${Buffer.from( - `${settings.options.profileName}:` - ).toString('base64')}`, - }, - body: JSON.stringify(this.buildPayload(type, payload)), - }); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } + : undefined + ); return true; } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error sending LunaSea notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: errorData, + response: e?.response?.data, }); return false; diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts index 882e82764..eed4fda91 100644 --- a/server/lib/notifications/agents/pushbullet.ts +++ b/server/lib/notifications/agents/pushbullet.ts @@ -5,6 +5,7 @@ import { User } from '@server/entity/User'; import type { NotificationAgentPushbullet } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import logger from '@server/logger'; +import axios from 'axios'; import { hasNotificationType, Notification, @@ -122,34 +123,22 @@ class PushbulletAgent }); try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Access-Token': settings.options.accessToken, - }, - body: JSON.stringify({ - ...notificationPayload, - channel_tag: settings.options.channelTag, - }), - }); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } + await axios.post( + endpoint, + { ...notificationPayload, channel_tag: settings.options.channelTag }, + { + headers: { + 'Access-Token': settings.options.accessToken, + }, + } + ); } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error sending Pushbullet notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: errorData, + response: e.response?.data, }); return false; @@ -174,32 +163,19 @@ class PushbulletAgent }); try { - const response = await fetch(endpoint, { - method: 'POST', + await axios.post(endpoint, notificationPayload, { headers: { - 'Content-Type': 'application/json', 'Access-Token': payload.notifyUser.settings.pushbulletAccessToken, }, - body: JSON.stringify(notificationPayload), }); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error sending Pushbullet notification', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: errorData, + response: e.response?.data, }); return false; @@ -235,32 +211,19 @@ class PushbulletAgent }); try { - const response = await fetch(endpoint, { - method: 'POST', + await axios.post(endpoint, notificationPayload, { headers: { - 'Content-Type': 'application/json', 'Access-Token': user.settings.pushbulletAccessToken, }, - body: JSON.stringify(notificationPayload), }); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error sending Pushbullet notification', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: errorData, + response: e.response?.data, }); return false; diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index abdb78f22..7abf0d72a 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -5,6 +5,7 @@ import { User } from '@server/entity/User'; import type { NotificationAgentPushover } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import logger from '@server/logger'; +import axios from 'axios'; import { hasNotificationType, Notification, @@ -51,15 +52,12 @@ class PushoverAgent imageUrl: string ): Promise> { try { - const response = await fetch(imageUrl); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } - const arrayBuffer = await response.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString('base64'); + const response = await axios.get(imageUrl, { + responseType: 'arraybuffer', + }); + const base64 = Buffer.from(response.data, 'binary').toString('base64'); const contentType = ( - response.headers.get('Content-Type') || - response.headers.get('content-type') + response.headers['Content-Type'] || response.headers['content-type'] )?.toString(); return { @@ -67,17 +65,10 @@ class PushoverAgent attachment_type: contentType, }; } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error getting image payload', { label: 'Notifications', errorMessage: e.message, - response: errorData, + response: e?.response?.data, }); return {}; } @@ -210,35 +201,19 @@ class PushoverAgent }); try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - ...notificationPayload, - token: settings.options.accessToken, - user: settings.options.userToken, - sound: settings.options.sound, - } as PushoverPayload), - }); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } + await axios.post(endpoint, { + ...notificationPayload, + token: settings.options.accessToken, + user: settings.options.userToken, + sound: settings.options.sound, + } as PushoverPayload); } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error sending Pushover notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: errorData, + response: e.response?.data, }); return false; @@ -266,36 +241,20 @@ class PushoverAgent }); try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - ...notificationPayload, - token: payload.notifyUser.settings.pushoverApplicationToken, - user: payload.notifyUser.settings.pushoverUserKey, - sound: payload.notifyUser.settings.pushoverSound, - } as PushoverPayload), - }); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } + await axios.post(endpoint, { + ...notificationPayload, + token: payload.notifyUser.settings.pushoverApplicationToken, + user: payload.notifyUser.settings.pushoverUserKey, + sound: payload.notifyUser.settings.pushoverSound, + } as PushoverPayload); } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error sending Pushover notification', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: errorData, + response: e.response?.data, }); return false; @@ -332,35 +291,19 @@ class PushoverAgent }); try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - ...notificationPayload, - token: user.settings.pushoverApplicationToken, - user: user.settings.pushoverUserKey, - } as PushoverPayload), - }); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } + await axios.post(endpoint, { + ...notificationPayload, + token: user.settings.pushoverApplicationToken, + user: user.settings.pushoverUserKey, + } as PushoverPayload); } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error sending Pushover notification', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: errorData, + response: e.response?.data, }); return false; diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index 1d6485cc5..8f1f0c953 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -2,6 +2,7 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue'; import type { NotificationAgentSlack } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import axios from 'axios'; import { hasNotificationType, Notification } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent } from './agent'; @@ -237,32 +238,19 @@ class SlackAgent subject: payload.subject, }); try { - const response = await fetch(settings.options.webhookUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(this.buildEmbed(type, payload)), - }); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } + await axios.post( + settings.options.webhookUrl, + this.buildEmbed(type, payload) + ); return true; } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error sending Slack notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: errorData, + response: e?.response?.data, }); return false; diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index db12b4947..01d4de497 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -5,6 +5,7 @@ import { User } from '@server/entity/User'; import type { NotificationAgentTelegram } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import logger from '@server/logger'; +import axios from 'axios'; import { hasNotificationType, Notification, @@ -176,35 +177,19 @@ class TelegramAgent }); try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - ...notificationPayload, - chat_id: settings.options.chatId, - message_thread_id: settings.options.messageThreadId, - disable_notification: !!settings.options.sendSilently, - } as TelegramMessagePayload | TelegramPhotoPayload), - }); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } + await axios.post(endpoint, { + ...notificationPayload, + chat_id: settings.options.chatId, + message_thread_id: settings.options.messageThreadId, + disable_notification: !!settings.options.sendSilently, + } as TelegramMessagePayload | TelegramPhotoPayload); } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error sending Telegram notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: errorData, + response: e?.response?.data, }); return false; @@ -228,38 +213,22 @@ class TelegramAgent }); try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - ...notificationPayload, - chat_id: payload.notifyUser.settings.telegramChatId, - message_thread_id: - payload.notifyUser.settings.telegramMessageThreadId, - disable_notification: - !!payload.notifyUser.settings.telegramSendSilently, - } as TelegramMessagePayload | TelegramPhotoPayload), - }); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } + await axios.post(endpoint, { + ...notificationPayload, + chat_id: payload.notifyUser.settings.telegramChatId, + message_thread_id: + payload.notifyUser.settings.telegramMessageThreadId, + disable_notification: + !!payload.notifyUser.settings.telegramSendSilently, + } as TelegramMessagePayload | TelegramPhotoPayload); } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error sending Telegram notification', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: errorData, + response: e?.response?.data, }); return false; @@ -293,36 +262,20 @@ class TelegramAgent }); try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - ...notificationPayload, - chat_id: user.settings.telegramChatId, - message_thread_id: user.settings.telegramMessageThreadId, - disable_notification: !!user.settings?.telegramSendSilently, - } as TelegramMessagePayload | TelegramPhotoPayload), - }); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } + await axios.post(endpoint, { + ...notificationPayload, + chat_id: user.settings.telegramChatId, + message_thread_id: user.settings.telegramMessageThreadId, + disable_notification: !!user.settings?.telegramSendSilently, + } as TelegramMessagePayload | TelegramPhotoPayload); } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error sending Telegram notification', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: errorData, + response: e?.response?.data, }); return false; diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index d91683bee..c441cb65b 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -3,6 +3,7 @@ import { MediaStatus } from '@server/constants/media'; import type { NotificationAgentWebhook } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import axios from 'axios'; import { get } from 'lodash'; import { hasNotificationType, Notification } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; @@ -177,35 +178,26 @@ class WebhookAgent }); try { - const response = await fetch(settings.options.webhookUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(settings.options.authHeader - ? { Authorization: settings.options.authHeader } - : {}), - }, - body: JSON.stringify(this.buildPayload(type, payload)), - }); - if (!response.ok) { - throw new Error(response.statusText, { cause: response }); - } + await axios.post( + settings.options.webhookUrl, + this.buildPayload(type, payload), + settings.options.authHeader + ? { + headers: { + Authorization: settings.options.authHeader, + }, + } + : undefined + ); return true; } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } logger.error('Error sending webhook notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: errorData, + response: e?.response?.data, }); return false; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 3630c42eb..a4738d659 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -136,7 +136,6 @@ export interface MainSettings { export interface NetworkSettings { csrfProtection: boolean; - forceIpv4First: boolean; trustProxy: boolean; proxy: ProxySettings; } @@ -510,7 +509,6 @@ class Settings { network: { csrfProtection: false, trustProxy: false, - forceIpv4First: false, proxy: { enabled: false, hostname: '', diff --git a/server/routes/avatarproxy.ts b/server/routes/avatarproxy.ts index 0065f011b..15118d1d8 100644 --- a/server/routes/avatarproxy.ts +++ b/server/routes/avatarproxy.ts @@ -6,6 +6,7 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { getAppVersion } from '@server/utils/appVersion'; import { getHostname } from '@server/utils/getHostname'; +import axios from 'axios'; import { Router } from 'express'; import gravatarUrl from 'gravatar-url'; import { createHash } from 'node:crypto'; @@ -54,22 +55,26 @@ export async function checkAvatarChanged( const jellyfinAvatarUrl = getJellyfinAvatarUrl(user.jellyfinUserId); - const headResponse = await fetch(jellyfinAvatarUrl, { method: 'HEAD' }); - if (!headResponse.ok) { + let headResponse; + try { + headResponse = await axios.head(jellyfinAvatarUrl); + if (headResponse.status !== 200) { + return { changed: false }; + } + } catch (error) { return { changed: false }; } const settings = getSettings(); let remoteVersion: string; if (settings.main.mediaServerType === MediaServerType.JELLYFIN) { - const remoteLastModifiedStr = - headResponse.headers.get('last-modified') || ''; + const remoteLastModifiedStr = headResponse.headers['last-modified'] || ''; remoteVersion = ( Date.parse(remoteLastModifiedStr) || Date.now() ).toString(); } else if (settings.main.mediaServerType === MediaServerType.EMBY) { remoteVersion = - headResponse.headers.get('etag')?.replace(/"/g, '') || + headResponse.headers['etag']?.replace(/"/g, '') || Date.now().toString(); } else { remoteVersion = Date.now().toString(); diff --git a/server/routes/imageproxy.ts b/server/routes/imageproxy.ts index df4b4ffe0..6cf104f52 100644 --- a/server/routes/imageproxy.ts +++ b/server/routes/imageproxy.ts @@ -5,6 +5,7 @@ import { Router } from 'express'; const router = Router(); const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', { rateLimitOptions: { + maxRequests: 20, maxRPS: 50, }, }); diff --git a/server/utils/customProxyAgent.ts b/server/utils/customProxyAgent.ts index 5f163c3de..040ac5cd9 100644 --- a/server/utils/customProxyAgent.ts +++ b/server/utils/customProxyAgent.ts @@ -1,5 +1,6 @@ import type { ProxySettings } from '@server/lib/settings'; import logger from '@server/logger'; +import axios from 'axios'; import type { Dispatcher } from 'undici'; import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici'; @@ -73,15 +74,8 @@ export default async function createCustomProxyAgent( } try { - const res = await fetch('https://www.google.com', { method: 'HEAD' }); - if (res.ok) { - logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' }); - } else { - logger.error('Proxy responded, but with a non-OK status: ' + res.status, { - label: 'Proxy', - }); - setGlobalDispatcher(defaultAgent); - } + await axios.head('https://www.google.com'); + logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' }); } catch (e) { logger.error( 'Failed to connect to the proxy: ' + e.message + ': ' + e.cause, diff --git a/server/utils/rateLimit.ts b/server/utils/rateLimit.ts deleted file mode 100644 index 0ecdec5c5..000000000 --- a/server/utils/rateLimit.ts +++ /dev/null @@ -1,68 +0,0 @@ -export type RateLimitOptions = { - maxRPS: number; - id?: string; -}; - -type RateLimiteState) => Promise, U> = { - queue: { - args: Parameters; - resolve: (value: U) => void; - reject: (reason?: unknown) => void; - }[]; - lastTimestamps: number[]; - timeout: ReturnType; -}; - -const rateLimitById: Record = {}; - -/** - * Add a rate limit to a function so it doesn't exceed a maximum number of requests per second. Function calls exceeding the rate will be delayed. - * @param fn The function to rate limit - * @param options.maxRPS Maximum number of Requests Per Second - * @param options.id An ID to share between rate limits, so it uses the same request queue. - * @returns The function with a rate limit - */ -export default function rateLimit< - T extends (...args: Parameters) => Promise, - U ->(fn: T, options: RateLimitOptions): (...args: Parameters) => Promise { - const state: RateLimiteState = (rateLimitById[ - options.id || '' - ] as RateLimiteState) || { queue: [], lastTimestamps: [] }; - if (options.id) { - rateLimitById[options.id] = state; - } - - const processQueue = () => { - // remove old timestamps - state.lastTimestamps = state.lastTimestamps.filter( - (timestamp) => Date.now() - timestamp < 1000 - ); - - if (state.lastTimestamps.length < options.maxRPS) { - // process requests if RPS not exceeded - const item = state.queue.shift(); - if (!item) return; - state.lastTimestamps.push(Date.now()); - const { args, resolve, reject } = item; - fn(...args) - .then(resolve) - .catch(reject); - processQueue(); - } else { - // rerun once the oldest item in queue is older than 1s - if (state.timeout) clearTimeout(state.timeout); - state.timeout = setTimeout( - processQueue, - 1000 - (Date.now() - state.lastTimestamps[0]) - ); - } - }; - - return (...args: Parameters): Promise => { - return new Promise((resolve, reject) => { - state.queue.push({ args, resolve, reject }); - processQueue(); - }); - }; -} diff --git a/server/utils/restartFlag.ts b/server/utils/restartFlag.ts index ffd64df3f..d0a492ba3 100644 --- a/server/utils/restartFlag.ts +++ b/server/utils/restartFlag.ts @@ -17,8 +17,7 @@ class RestartFlag { return ( this.networkSettings.csrfProtection !== networkSettings.csrfProtection || this.networkSettings.trustProxy !== networkSettings.trustProxy || - this.networkSettings.proxy.enabled !== networkSettings.proxy.enabled || - this.networkSettings.forceIpv4First !== networkSettings.forceIpv4First + this.networkSettings.proxy.enabled !== networkSettings.proxy.enabled ); } } diff --git a/src/components/Blacklist/index.tsx b/src/components/Blacklist/index.tsx index a8b8dae7f..5c83465ff 100644 --- a/src/components/Blacklist/index.tsx +++ b/src/components/Blacklist/index.tsx @@ -23,6 +23,7 @@ import type { } from '@server/interfaces/api/blacklistInterfaces'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; +import axios from 'axios'; import Link from 'next/link'; import { useRouter } from 'next/router'; import type { ChangeEvent } from 'react'; @@ -238,11 +239,9 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { const removeFromBlacklist = async (tmdbId: number, title?: string) => { setIsUpdating(true); - const res = await fetch('/api/v1/blacklist/' + tmdbId, { - method: 'DELETE', - }); + try { + await axios.delete(`/api/v1/blacklist/${tmdbId}`); - if (res.status === 204) { addToast( {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { @@ -252,7 +251,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { , { appearance: 'success', autoDismiss: true } ); - } else { + } catch { addToast(intl.formatMessage(globalMessages.blacklistError), { appearance: 'error', autoDismiss: true, diff --git a/src/components/BlacklistBlock/index.tsx b/src/components/BlacklistBlock/index.tsx index 8d619aa3c..6980a02ef 100644 --- a/src/components/BlacklistBlock/index.tsx +++ b/src/components/BlacklistBlock/index.tsx @@ -7,6 +7,7 @@ import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid'; import type { Blacklist } from '@server/entity/Blacklist'; +import axios from 'axios'; import Link from 'next/link'; import { useState } from 'react'; import { useIntl } from 'react-intl'; @@ -38,11 +39,9 @@ const BlacklistBlock = ({ const removeFromBlacklist = async (tmdbId: number, title?: string) => { setIsUpdating(true); - const res = await fetch('/api/v1/blacklist/' + tmdbId, { - method: 'DELETE', - }); + try { + await axios.delete('/api/v1/blacklist/' + tmdbId); - if (res.status === 204) { addToast( {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { @@ -52,7 +51,7 @@ const BlacklistBlock = ({ , { appearance: 'success', autoDismiss: true } ); - } else { + } catch { addToast(intl.formatMessage(globalMessages.blacklistError), { appearance: 'error', autoDismiss: true, diff --git a/src/components/BlacklistModal/index.tsx b/src/components/BlacklistModal/index.tsx index 4ef1a7b6b..dda789035 100644 --- a/src/components/BlacklistModal/index.tsx +++ b/src/components/BlacklistModal/index.tsx @@ -4,6 +4,7 @@ import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; +import axios from 'axios'; import { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; @@ -44,12 +45,8 @@ const BlacklistModal = ({ if (!show) return; try { setError(null); - const response = await fetch(`/api/v1/${type}/${tmdbId}`); - if (!response.ok) { - throw new Error(); - } - const result = await response.json(); - setData(result); + const response = await axios.get(`/api/v1/${type}/${tmdbId}`); + setData(response.data); } catch (err) { setError(err); } diff --git a/src/components/Discover/CreateSlider/index.tsx b/src/components/Discover/CreateSlider/index.tsx index 1d558faa6..32cca0794 100644 --- a/src/components/Discover/CreateSlider/index.tsx +++ b/src/components/Discover/CreateSlider/index.tsx @@ -14,6 +14,7 @@ import { DiscoverSliderType } from '@server/constants/discover'; import type DiscoverSlider from '@server/entity/DiscoverSlider'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import type { Keyword, ProductionCompany } from '@server/models/common'; +import axios from 'axios'; import { Field, Form, Formik } from 'formik'; import { useCallback, useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; @@ -76,9 +77,11 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { const keywords = await Promise.all( slider.data.split(',').map(async (keywordId) => { - const res = await fetch(`/api/v1/keyword/${keywordId}`); - const keyword: Keyword = await res.json(); - return keyword; + const keyword = await axios.get( + `/api/v1/keyword/${keywordId}` + ); + + return keyword.data; }) ); @@ -95,13 +98,15 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { return; } - const res = await fetch( + const response = await axios.get( `/api/v1/genres/${ slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv' }` ); - const genres: TmdbGenre[] = await res.json(); - const genre = genres.find((genre) => genre.id === Number(slider.data)); + + const genre = response.data.find( + (genre) => genre.id === Number(slider.data) + ); setDefaultDataValue([ { @@ -116,8 +121,11 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { return; } - const res = await fetch(`/api/v1/studio/${slider.data}`); - const studio: ProductionCompany = await res.json(); + const response = await axios.get( + `/api/v1/studio/${slider.data}` + ); + + const studio = response.data; setDefaultDataValue([ { @@ -160,17 +168,16 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { ); const loadKeywordOptions = async (inputValue: string) => { - const res = await fetch( - `/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`, + const results = await axios.get( + '/api/v1/search/keyword', { - headers: { - 'Content-Type': 'application/json', + params: { + query: encodeURIExtraParams(inputValue), }, } ); - const results: TmdbKeywordSearchResponse = await res.json(); - return results.results.map((result) => ({ + return results.data.results.map((result) => ({ label: result.name, value: result.id, })); @@ -181,37 +188,38 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { return []; } - const res = await fetch( - `/api/v1/search/company?query=${encodeURIExtraParams(inputValue)}`, + const results = await axios.get( + '/api/v1/search/company', { - headers: { - 'Content-Type': 'application/json', + params: { + query: encodeURIExtraParams(inputValue), }, } ); - const results: TmdbCompanySearchResponse = await res.json(); - return results.results.map((result) => ({ + return results.data.results.map((result) => ({ label: result.name, value: result.id, })); }; const loadMovieGenreOptions = async () => { - const res = await fetch('/api/v1/discover/genreslider/movie'); - const results: GenreSliderItem[] = await res.json(); + const results = await axios.get( + '/api/v1/discover/genreslider/movie' + ); - return results.map((result) => ({ + return results.data.map((result) => ({ label: result.name, value: result.id, })); }; const loadTvGenreOptions = async () => { - const res = await fetch('/api/v1/discover/genreslider/tv'); - const results: GenreSliderItem[] = await res.json(); + const results = await axios.get( + '/api/v1/discover/genreslider/tv' + ); - return results.map((result) => ({ + return results.data.map((result) => ({ label: result.name, value: result.id, })); @@ -306,31 +314,17 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { onSubmit={async (values, { resetForm }) => { try { if (slider) { - const res = await fetch(`/api/v1/settings/discover/${slider.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - type: Number(values.sliderType), - title: values.title, - data: values.data, - }), + await axios.put(`/api/v1/settings/discover/${slider.id}`, { + type: Number(values.sliderType), + title: values.title, + data: values.data, }); - if (!res.ok) throw new Error(); } else { - const res = await fetch('/api/v1/settings/discover/add', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - type: Number(values.sliderType), - title: values.title, - data: values.data, - }), + await axios.post('/api/v1/settings/discover/add', { + type: Number(values.sliderType), + title: values.title, + data: values.data, }); - if (!res.ok) throw new Error(); } addToast( diff --git a/src/components/Discover/DiscoverSliderEdit/index.tsx b/src/components/Discover/DiscoverSliderEdit/index.tsx index cb58b9c5e..8fd556fed 100644 --- a/src/components/Discover/DiscoverSliderEdit/index.tsx +++ b/src/components/Discover/DiscoverSliderEdit/index.tsx @@ -20,6 +20,7 @@ import { } from '@heroicons/react/24/solid'; import { DiscoverSliderType } from '@server/constants/discover'; import type DiscoverSlider from '@server/entity/DiscoverSlider'; +import axios from 'axios'; import { useRef, useState } from 'react'; import { useDrag, useDrop } from 'react-aria'; import { useIntl } from 'react-intl'; @@ -77,10 +78,7 @@ const DiscoverSliderEdit = ({ const deleteSlider = async () => { try { - const res = await fetch(`/api/v1/settings/discover/${slider.id}`, { - method: 'DELETE', - }); - if (!res.ok) throw new Error(); + await axios.delete(`/api/v1/settings/discover/${slider.id}`); addToast(intl.formatMessage(messages.deletesuccess), { appearance: 'success', autoDismiss: true, diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 370384f16..d46e99555 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -28,6 +28,7 @@ import { } from '@heroicons/react/24/solid'; import { DiscoverSliderType } from '@server/constants/discover'; import type DiscoverSlider from '@server/entity/DiscoverSlider'; +import axios from 'axios'; import { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; @@ -75,14 +76,7 @@ const Discover = () => { const updateSliders = async () => { try { - const res = await fetch('/api/v1/settings/discover', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(sliders), - }); - if (!res.ok) throw new Error(); + await axios.post('/api/v1/settings/discover', sliders); addToast(intl.formatMessage(messages.updatesuccess), { appearance: 'success', @@ -100,10 +94,7 @@ const Discover = () => { const resetSliders = async () => { try { - const res = await fetch('/api/v1/settings/discover/reset', { - method: 'GET', - }); - if (!res.ok) throw new Error(); + await axios.get('/api/v1/settings/discover/reset'); addToast(intl.formatMessage(messages.resetsuccess), { appearance: 'success', diff --git a/src/components/IssueDetails/IssueComment/index.tsx b/src/components/IssueDetails/IssueComment/index.tsx index 5b9dd8739..5808ab3a8 100644 --- a/src/components/IssueDetails/IssueComment/index.tsx +++ b/src/components/IssueDetails/IssueComment/index.tsx @@ -6,6 +6,7 @@ import defineMessages from '@app/utils/defineMessages'; import { Menu, Transition } from '@headlessui/react'; import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import type { default as IssueCommentType } from '@server/entity/IssueComment'; +import axios from 'axios'; import { Field, Form, Formik } from 'formik'; import Link from 'next/link'; import { Fragment, useState } from 'react'; @@ -48,10 +49,7 @@ const IssueComment = ({ const deleteComment = async () => { try { - const res = await fetch(`/api/v1/issueComment/${comment.id}`, { - method: 'DELETE', - }); - if (!res.ok) throw new Error(); + await axios.delete(`/api/v1/issueComment/${comment.id}`); } catch (e) { // something went wrong deleting the comment } finally { @@ -178,17 +176,9 @@ const IssueComment = ({ { - const res = await fetch( - `/api/v1/issueComment/${comment.id}`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ message: values.newMessage }), - } - ); - if (!res.ok) throw new Error(); + await axios.put(`/api/v1/issueComment/${comment.id}`, { + message: values.newMessage, + }); if (onUpdate) { onUpdate(); diff --git a/src/components/IssueDetails/index.tsx b/src/components/IssueDetails/index.tsx index e73147c58..e9da91d22 100644 --- a/src/components/IssueDetails/index.tsx +++ b/src/components/IssueDetails/index.tsx @@ -27,6 +27,7 @@ import { MediaServerType } from '@server/constants/server'; import type Issue from '@server/entity/Issue'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; +import axios from 'axios'; import { Field, Form, Formik } from 'formik'; import Link from 'next/link'; import { useRouter } from 'next/router'; @@ -121,14 +122,9 @@ const IssueDetails = () => { const editFirstComment = async (newMessage: string) => { try { - const res = await fetch(`/api/v1/issueComment/${firstComment.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ message: newMessage }), + await axios.put(`/api/v1/issueComment/${firstComment.id}`, { + message: newMessage, }); - if (!res.ok) throw new Error(); addToast(intl.formatMessage(messages.toasteditdescriptionsuccess), { appearance: 'success', @@ -145,10 +141,7 @@ const IssueDetails = () => { const updateIssueStatus = async (newStatus: 'open' | 'resolved') => { try { - const res = await fetch(`/api/v1/issue/${issueData.id}/${newStatus}`, { - method: 'POST', - }); - if (!res.ok) throw new Error(); + await axios.post(`/api/v1/issue/${issueData.id}/${newStatus}`); addToast(intl.formatMessage(messages.toaststatusupdated), { appearance: 'success', @@ -166,10 +159,7 @@ const IssueDetails = () => { const deleteIssue = async () => { try { - const res = await fetch(`/api/v1/issue/${issueData.id}`, { - method: 'DELETE', - }); - if (!res.ok) throw new Error(); + await axios.delete(`/api/v1/issue/${issueData.id}`); mutate('/api/v1/issue/count'); addToast(intl.formatMessage(messages.toastissuedeleted), { @@ -504,17 +494,9 @@ const IssueDetails = () => { }} validationSchema={CommentSchema} onSubmit={async (values, { resetForm }) => { - const res = await fetch( - `/api/v1/issue/${issueData?.id}/comment`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ message: values.message }), - } - ); - if (!res.ok) throw new Error(); + await axios.post(`/api/v1/issue/${issueData?.id}/comment`, { + message: values.message, + }); revalidateIssue(); resetForm(); }} diff --git a/src/components/IssueModal/CreateIssueModal/index.tsx b/src/components/IssueModal/CreateIssueModal/index.tsx index 58836ef8f..db6e0ded4 100644 --- a/src/components/IssueModal/CreateIssueModal/index.tsx +++ b/src/components/IssueModal/CreateIssueModal/index.tsx @@ -11,6 +11,7 @@ import { MediaStatus } from '@server/constants/media'; import type Issue from '@server/entity/Issue'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; +import axios from 'axios'; import { Field, Formik } from 'formik'; import Link from 'next/link'; import { useIntl } from 'react-intl'; @@ -100,22 +101,14 @@ const CreateIssueModal = ({ validationSchema={CreateIssueModalSchema} onSubmit={async (values) => { try { - const res = await fetch('/api/v1/issue', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - issueType: values.selectedIssue.issueType, - message: values.message, - mediaId: data?.mediaInfo?.id, - problemSeason: values.problemSeason, - problemEpisode: - values.problemSeason > 0 ? values.problemEpisode : 0, - }), + const newIssue = await axios.post('/api/v1/issue', { + issueType: values.selectedIssue.issueType, + message: values.message, + mediaId: data?.mediaInfo?.id, + problemSeason: values.problemSeason, + problemEpisode: + values.problemSeason > 0 ? values.problemEpisode : 0, }); - if (!res.ok) throw new Error(); - const newIssue: Issue = await res.json(); if (data) { addToast( @@ -126,7 +119,7 @@ const CreateIssueModal = ({ strong: (msg: React.ReactNode) => {msg}, })} - +