From 63dc27d400ecc80a18442fc42dd417cc03c3f9e1 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Mon, 31 Mar 2025 10:40:30 +0200 Subject: [PATCH 01/99] fix(job): handle media removal for 4k on the same server (#1543) This PR fixes a bug where the avaibility sync job was not removing properly 4k items when the same Radarr server was used for both non-4k and 4k media. --- server/api/servarr/radarr.ts | 32 ++++++++++++++++++++++++++++++++ server/lib/availabilitySync.ts | 6 +++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index 35d24024..b5e12d2e 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -29,6 +29,38 @@ export interface RadarrMovie { added: string; hasFile: boolean; tags: number[]; + movieFile?: { + id: number; + movieId: number; + relativePath?: string; + path?: string; + size: number; + dateAdded: string; + sceneName?: string; + releaseGroup?: string; + edition?: string; + indexerFlags?: number; + mediaInfo: { + id: number; + audioBitrate: number; + audioChannels: number; + audioCodec?: string; + audioLanguages?: string; + audioStreamCount: number; + videoBitDepth: number; + videoBitrate: number; + videoCodec?: string; + videoFps: number; + videoDynamicRange?: string; + videoDynamicRangeType?: string; + resolution?: string; + runTime?: string; + scanType?: string; + subtitles?: string; + }; + originalFilePath?: string; + qualityCutoffNotMet: boolean; + }; } class RadarrAPI extends ServarrBase<{ movieId: number }> { diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 0fdfd627..3f78551e 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -747,7 +747,11 @@ class AvailabilitySync { } if (radarr && radarr.hasFile) { - existsInRadarr = true; + const resolution = + radarr?.movieFile?.mediaInfo?.resolution?.split('x'); + const is4kMovie = + resolution?.length === 2 && Number(resolution[0]) >= 2000; + existsInRadarr = is4k ? is4kMovie : !is4kMovie; } } catch (ex) { if (!ex.message.includes('404')) { From 8dc1d8196c67bee0e772941445c294f0ca367961 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Mon, 31 Mar 2025 20:06:33 +0200 Subject: [PATCH 02/99] fix: correct "Remove from *arr" button (#1544) This PR fixes the "Delete from *arr" button in the request list. It checks from the API whether the *arr server corresponding to the request still exists before displaying the remove button, and fixes a cache removal issue that could cause problems when deleting recently added media. This PR also reverts #1476, which introduced problems during removal. fix #1494 --- server/api/externalapi.ts | 2 +- server/api/servarr/radarr.ts | 5 +- server/api/servarr/sonarr.ts | 6 +- server/interfaces/api/requestInterfaces.ts | 5 +- server/routes/media.ts | 20 ----- server/routes/request.ts | 34 +++++++- .../RequestList/RequestItem/index.tsx | 80 +++++++------------ src/components/RequestList/index.tsx | 13 +-- 8 files changed, 75 insertions(+), 90 deletions(-) diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index d17ebf99..85612808 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -289,7 +289,7 @@ class ExternalAPI { return data; } - protected removeCache(endpoint: string, options?: Record) { + protected removeCache(endpoint: string, options?: Record) { const cacheKey = this.serializeCacheKey(endpoint, { ...this.params, ...options, diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index b5e12d2e..638af88a 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -274,10 +274,13 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { if (tmdbId) { this.removeCache('/movie/lookup', { term: `tmdb:${tmdbId}`, + headers: this.defaultHeaders, }); } if (externalId) { - this.removeCache(`/movie/${externalId}`); + this.removeCache(`/movie/${externalId}`, { + headers: this.defaultHeaders, + }); } }; } diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 0a9c2732..0cbd4a57 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -368,14 +368,18 @@ class SonarrAPI extends ServarrBase<{ if (tvdbId) { this.removeCache('/series/lookup', { term: `tvdb:${tvdbId}`, + headers: this.defaultHeaders, }); } if (externalId) { - this.removeCache(`/series/${externalId}`); + this.removeCache(`/series/${externalId}`, { + headers: this.defaultHeaders, + }); } if (title) { this.removeCache('/series/lookup', { term: title, + headers: this.defaultHeaders, }); } }; diff --git a/server/interfaces/api/requestInterfaces.ts b/server/interfaces/api/requestInterfaces.ts index 88b1201d..4a41ae99 100644 --- a/server/interfaces/api/requestInterfaces.ts +++ b/server/interfaces/api/requestInterfaces.ts @@ -3,7 +3,10 @@ import type { MediaRequest } from '@server/entity/MediaRequest'; import type { NonFunctionProperties, PaginatedResponse } from './common'; export interface RequestResultsResponse extends PaginatedResponse { - results: NonFunctionProperties[]; + results: (NonFunctionProperties & { + profileName?: string; + canRemove?: boolean; + })[]; } export type MediaRequestBody = { diff --git a/server/routes/media.ts b/server/routes/media.ts index 3ad197c9..60191e5d 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -237,19 +237,6 @@ mediaRoutes.delete( } if (isMovie) { - // check if the movie exists - try { - await (service as RadarrAPI).getMovie({ - id: parseInt( - is4k - ? (media.externalServiceSlug4k as string) - : (media.externalServiceSlug as string) - ), - }); - } catch { - return res.status(204).send(); - } - // remove the movie await (service as RadarrAPI).removeMovie( parseInt( is4k @@ -264,13 +251,6 @@ mediaRoutes.delete( if (!tvdbId) { throw new Error('TVDB ID not found'); } - // check if the series exists - try { - await (service as SonarrAPI).getSeriesByTvdbId(tvdbId); - } catch { - return res.status(204).send(); - } - // remove the series await (service as SonarrAPI).removeSerie(tvdbId); } diff --git a/server/routes/request.ts b/server/routes/request.ts index 89e5352f..50d4a6f0 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -189,7 +189,7 @@ requestRoutes.get, RequestResultsResponse>( ); // add profile names to the media requests, with undefined if not found - const requestsWithProfileNames = requests.map((r) => { + let mappedRequests = requests.map((r) => { switch (r.type) { case MediaType.MOVIE: { const profileName = radarrServers @@ -212,6 +212,36 @@ requestRoutes.get, RequestResultsResponse>( } }); + // add canRemove prop if user has permission + if (req.user?.hasPermission(Permission.MANAGE_REQUESTS)) { + mappedRequests = mappedRequests.map((r) => { + switch (r.type) { + case MediaType.MOVIE: { + return { + ...r, + // check if the radarr server for this request is configured + canRemove: radarrServers.some( + (server) => + server.id === + (r.is4k ? r.media.serviceId4k : r.media.serviceId) + ), + }; + } + case MediaType.TV: { + return { + ...r, + // check if the sonarr server for this request is configured + canRemove: sonarrServers.some( + (server) => + server.id === + (r.is4k ? r.media.serviceId4k : r.media.serviceId) + ), + }; + } + } + }); + } + return res.status(200).json({ pageInfo: { pages: Math.ceil(requestCount / pageSize), @@ -219,7 +249,7 @@ requestRoutes.get, RequestResultsResponse>( results: requestCount, page: Math.ceil(skip / pageSize) + 1, }, - results: requestsWithProfileNames, + results: mappedRequests, }); } catch (e) { next({ status: 500, message: e.message }); diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 037590e5..dd72ee7e 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -17,10 +17,10 @@ import { TrashIcon, XMarkIcon, } from '@heroicons/react/24/solid'; -import { MediaRequestStatus, MediaType } from '@server/constants/media'; +import { MediaRequestStatus } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type { NonFunctionProperties } from '@server/interfaces/api/common'; -import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; +import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; import Link from 'next/link'; @@ -292,18 +292,11 @@ const RequestItemError = ({ }; interface RequestItemProps { - request: NonFunctionProperties & { profileName?: string }; + request: RequestResultsResponse['results'][number]; revalidateList: () => void; - radarrData?: RadarrSettings[]; - sonarrData?: SonarrSettings[]; } -const RequestItem = ({ - request, - revalidateList, - radarrData, - sonarrData, -}: RequestItemProps) => { +const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const settings = useSettings(); const { ref, inView } = useInView({ triggerOnce: true, @@ -398,23 +391,6 @@ const RequestItem = ({ iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k, }); - const serviceExists = () => { - if (title?.mediaInfo) { - if (title?.mediaInfo.mediaType === MediaType.MOVIE) { - return ( - radarrData?.find((radarr) => radarr.id === request.serverId) !== - undefined - ); - } else { - return ( - sonarrData?.find((sonarr) => sonarr.id === request.serverId) !== - undefined - ); - } - } - return false; - }; - if (!title && !error) { return (
deleteRequest()} - confirmText={intl.formatMessage(globalMessages.areyousure)} - className="w-full" - > - - {intl.formatMessage(messages.deleterequest)} - - )} - {hasPermission(Permission.MANAGE_REQUESTS) && - title?.mediaInfo?.serviceId && - serviceExists() && ( - deleteMediaFile()} - confirmText={intl.formatMessage(globalMessages.areyousure)} - className="w-full" - > - - - {intl.formatMessage(messages.removearr, { - arr: request.type === 'movie' ? 'Radarr' : 'Sonarr', - })} - - + <> + deleteRequest()} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + {intl.formatMessage(messages.deleterequest)} + + {request.canRemove && ( + deleteMediaFile()} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + + {intl.formatMessage(messages.removearr, { + arr: request.type === 'movie' ? 'Radarr' : 'Sonarr', + })} + + + )} + )} {requestData.status === MediaRequestStatus.PENDING && hasPermission(Permission.MANAGE_REQUESTS) && ( diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index 468c0853..6cdf5b0b 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -17,8 +17,6 @@ import { FunnelIcon, } from '@heroicons/react/24/solid'; import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces'; -import { Permission } from '@server/lib/permissions'; -import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; @@ -53,7 +51,7 @@ const RequestList = () => { const { user } = useUser({ id: Number(router.query.userId), }); - const { user: currentUser, hasPermission } = useUser(); + const { user: currentUser } = useUser(); const [currentFilter, setCurrentFilter] = useState(Filter.PENDING); const [currentSort, setCurrentSort] = useState('added'); const [currentSortDirection, setCurrentSortDirection] = @@ -64,13 +62,6 @@ const RequestList = () => { const pageIndex = page - 1; const updateQueryParams = useUpdateQueryParams({ page: page.toString() }); - const { data: radarrData } = useSWR( - hasPermission(Permission.ADMIN) ? '/api/v1/settings/radarr' : null - ); - const { data: sonarrData } = useSWR( - hasPermission(Permission.ADMIN) ? '/api/v1/settings/sonarr' : null - ); - const { data, error, @@ -254,8 +245,6 @@ const RequestList = () => { revalidate()} - radarrData={radarrData} - sonarrData={sonarrData} />
); From 85bbc857141d38bcf5244078437ed6a3318bba67 Mon Sep 17 00:00:00 2001 From: Kugelstift <100831349+Kugelstift@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:34:06 +0200 Subject: [PATCH 03/99] fix(auth): Bitwarden autofill fix on local/Jellyfin login (2) (#1487) * Update JellyfinLogin.tsx * Update LocalLogin.tsx * Update index.tsx * Update index.tsx prettier * Update JellyfinLogin.tsx * Update LocalLogin.tsx * Update index.tsx --- src/components/Common/SensitiveInput/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Common/SensitiveInput/index.tsx b/src/components/Common/SensitiveInput/index.tsx index 336044de..f21dc35b 100644 --- a/src/components/Common/SensitiveInput/index.tsx +++ b/src/components/Common/SensitiveInput/index.tsx @@ -29,7 +29,6 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => { data-form-type="other" data-1pignore="true" data-lpignore="true" - data-bwignore="true" {...componentProps} className={`rounded-l-only ${componentProps.className ?? ''}`} type={ From 2f6be955b51e8920c8954413286577e6fea4aee2 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 3 Apr 2025 18:15:39 +0200 Subject: [PATCH 04/99] fix(job): rename Plex Sync to Jellyfin Sync (#1549) Some logs for the Jellyfin scanners were labelled 'Plex Sync' instead of 'Jellyfin Sync', leading to confusion --- server/lib/scanners/jellyfin/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index b4816ae5..bfef4f7e 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -65,8 +65,8 @@ class JellyfinScanner { if (!metadata?.Id) { logger.debug('No Id metadata for this title. Skipping', { - label: 'Plex Sync', - ratingKey: jellyfinitem.Id, + label: 'Jellyfin Sync', + jellyfinItemId: jellyfinitem.Id, }); return; } @@ -204,8 +204,8 @@ class JellyfinScanner { if (!metadata?.Id) { logger.debug('No Id metadata for this title. Skipping', { - label: 'Plex Sync', - ratingKey: jellyfinitem.Id, + label: 'Jellyfin Sync', + jellyfinItemId: jellyfinitem.Id, }); return; } From 90c58de9b269e90f900cfbf2dd2dd872dbc5be35 Mon Sep 17 00:00:00 2001 From: Ludovic Ortega Date: Fri, 4 Apr 2025 00:38:48 +0200 Subject: [PATCH 05/99] chore(helm): bump jellyseerr to 2.5.2 (#1551) Signed-off-by: Ludovic Ortega --- charts/jellyseerr-chart/Chart.yaml | 4 ++-- charts/jellyseerr-chart/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/jellyseerr-chart/Chart.yaml b/charts/jellyseerr-chart/Chart.yaml index 833c48fc..8d2e5cae 100644 --- a/charts/jellyseerr-chart/Chart.yaml +++ b/charts/jellyseerr-chart/Chart.yaml @@ -3,8 +3,8 @@ kubeVersion: ">=1.23.0-0" name: jellyseerr-chart description: Jellyseerr helm chart for Kubernetes type: application -version: 2.3.2 -appVersion: "2.5.1" +version: 2.3.3 +appVersion: "2.5.2" maintainers: - name: Jellyseerr url: https://github.com/Fallenbagel/jellyseerr diff --git a/charts/jellyseerr-chart/README.md b/charts/jellyseerr-chart/README.md index 72dce724..98dafbe3 100644 --- a/charts/jellyseerr-chart/README.md +++ b/charts/jellyseerr-chart/README.md @@ -1,6 +1,6 @@ # jellyseerr-chart -![Version: 2.3.2](https://img.shields.io/badge/Version-2.3.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.5.1](https://img.shields.io/badge/AppVersion-2.5.1-informational?style=flat-square) +![Version: 2.3.3](https://img.shields.io/badge/Version-2.3.3-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.5.2](https://img.shields.io/badge/AppVersion-2.5.2-informational?style=flat-square) Jellyseerr helm chart for Kubernetes From 5865478a3b866f2e5cc0a509d579a81346da129f Mon Sep 17 00:00:00 2001 From: DominicKo <25477935+brotaxt@users.noreply.github.com> Date: Fri, 4 Apr 2025 19:26:32 +0200 Subject: [PATCH 06/99] rebased on latest development branch (#1482) Co-authored-by: a17f885 <{ID}+{username}@users.noreply.github.com> --- server/api/themoviedb/index.ts | 47 ++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index cb4d4071..1e2caa71 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -1,5 +1,6 @@ import ExternalAPI from '@server/api/externalapi'; import cacheManager from '@server/lib/cache'; +import { getSettings } from '@server/lib/settings'; import { sortBy } from 'lodash'; import type { TmdbCollection, @@ -99,6 +100,7 @@ interface DiscoverTvOptions { } class TheMovieDb extends ExternalAPI { + private locale: string; private discoverRegion?: string; private originalLanguage?: string; constructor({ @@ -118,6 +120,7 @@ class TheMovieDb extends ExternalAPI { }, } ); + this.locale = getSettings().main?.locale || 'en'; this.discoverRegion = discoverRegion; this.originalLanguage = originalLanguage; } @@ -126,7 +129,7 @@ class TheMovieDb extends ExternalAPI { query, page = 1, includeAdult = false, - language = 'en', + language = this.locale, }: SearchOptions): Promise => { try { const data = await this.get('/search/multi', { @@ -151,7 +154,7 @@ class TheMovieDb extends ExternalAPI { query, page = 1, includeAdult = false, - language = 'en', + language = this.locale, year, }: SingleSearchOptions): Promise => { try { @@ -178,7 +181,7 @@ class TheMovieDb extends ExternalAPI { query, page = 1, includeAdult = false, - language = 'en', + language = this.locale, year, }: SingleSearchOptions): Promise => { try { @@ -203,7 +206,7 @@ class TheMovieDb extends ExternalAPI { public getPerson = async ({ personId, - language = 'en', + language = this.locale, }: { personId: number; language?: string; @@ -221,7 +224,7 @@ class TheMovieDb extends ExternalAPI { public getPersonCombinedCredits = async ({ personId, - language = 'en', + language = this.locale, }: { personId: number; language?: string; @@ -244,7 +247,7 @@ class TheMovieDb extends ExternalAPI { public getMovie = async ({ movieId, - language = 'en', + language = this.locale, }: { movieId: number; language?: string; @@ -269,7 +272,7 @@ class TheMovieDb extends ExternalAPI { public getTvShow = async ({ tvId, - language = 'en', + language = this.locale, }: { tvId: number; language?: string; @@ -319,7 +322,7 @@ class TheMovieDb extends ExternalAPI { public async getMovieRecommendations({ movieId, page = 1, - language = 'en', + language = this.locale, }: { movieId: number; page?: number; @@ -343,7 +346,7 @@ class TheMovieDb extends ExternalAPI { public async getMovieSimilar({ movieId, page = 1, - language = 'en', + language = this.locale, }: { movieId: number; page?: number; @@ -367,7 +370,7 @@ class TheMovieDb extends ExternalAPI { public async getMoviesByKeyword({ keywordId, page = 1, - language = 'en', + language = this.locale, }: { keywordId: number; page?: number; @@ -391,7 +394,7 @@ class TheMovieDb extends ExternalAPI { public async getTvRecommendations({ tvId, page = 1, - language = 'en', + language = this.locale, }: { tvId: number; page?: number; @@ -417,7 +420,7 @@ class TheMovieDb extends ExternalAPI { public async getTvSimilar({ tvId, page = 1, - language = 'en', + language = this.locale, }: { tvId: number; page?: number; @@ -439,7 +442,7 @@ class TheMovieDb extends ExternalAPI { sortBy = 'popularity.desc', page = 1, includeAdult = false, - language = 'en', + language = this.locale, primaryReleaseDateGte, primaryReleaseDateLte, originalLanguage, @@ -510,7 +513,7 @@ class TheMovieDb extends ExternalAPI { public getDiscoverTv = async ({ sortBy = 'popularity.desc', page = 1, - language = 'en', + language = this.locale, firstAirDateGte, firstAirDateLte, includeEmptyReleaseDate = false, @@ -585,7 +588,7 @@ class TheMovieDb extends ExternalAPI { public getUpcomingMovies = async ({ page = 1, - language = 'en', + language = this.locale, }: { page: number; language: string; @@ -610,7 +613,7 @@ class TheMovieDb extends ExternalAPI { public getAllTrending = async ({ page = 1, timeWindow = 'day', - language = 'en', + language = this.locale, }: { page?: number; timeWindow?: 'day' | 'week'; @@ -677,7 +680,7 @@ class TheMovieDb extends ExternalAPI { public async getByExternalId({ externalId, type, - language = 'en', + language = this.locale, }: | { externalId: string; @@ -706,7 +709,7 @@ class TheMovieDb extends ExternalAPI { public async getMediaByImdbId({ imdbId, - language = 'en', + language = this.locale, }: { imdbId: string; language?: string; @@ -745,7 +748,7 @@ class TheMovieDb extends ExternalAPI { public async getShowByTvdbId({ tvdbId, - language = 'en', + language = this.locale, }: { tvdbId: number; language?: string; @@ -775,7 +778,7 @@ class TheMovieDb extends ExternalAPI { public async getCollection({ collectionId, - language = 'en', + language = this.locale, }: { collectionId: number; language?: string; @@ -849,7 +852,7 @@ class TheMovieDb extends ExternalAPI { } public async getMovieGenres({ - language = 'en', + language = this.locale, }: { language?: string; } = {}): Promise { @@ -896,7 +899,7 @@ class TheMovieDb extends ExternalAPI { } public async getTvGenres({ - language = 'en', + language = this.locale, }: { language?: string; } = {}): Promise { From 7d36dc182b462c12a31833ee08e74255d7964c24 Mon Sep 17 00:00:00 2001 From: Jessie Wilson <48299282+jessielw@users.noreply.github.com> Date: Fri, 4 Apr 2025 13:27:30 -0400 Subject: [PATCH 07/99] feat: now uses markdown linebreaks instead of relying purely on newlines (#1514) refactor: displayUrl now applies an ellipsis if the url is longer than 40 characters Co-authored-by: jesterr0 --- server/lib/notifications/agents/gotify.ts | 25 ++++++++++++++--------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts index 299effe4..31561847 100644 --- a/server/lib/notifications/agents/gotify.ts +++ b/server/lib/notifications/agents/gotify.ts @@ -47,10 +47,11 @@ class GotifyAgent const title = payload.event ? `${payload.event} - ${payload.subject}` : payload.subject; - let message = payload.message ?? ''; + + let message = payload.message ? `${payload.message} \n\n` : ''; if (payload.request) { - message += `\n\nRequested By: ${payload.request.requestedBy.displayName}`; + message += `\n**Requested By:** ${payload.request.requestedBy.displayName} `; let status = ''; switch (type) { @@ -73,16 +74,18 @@ class GotifyAgent } if (status) { - message += `\nRequest Status: ${status}`; + message += `\n**Request Status:** ${status} `; } } else if (payload.comment) { - message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`; + message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message} `; } else if (payload.issue) { - message += `\n\nReported By: ${payload.issue.createdBy.displayName}`; - message += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`; - message += `\nIssue Status: ${ + message += `\n\n**Reported By:** ${payload.issue.createdBy.displayName} `; + message += `\n**Issue Type:** ${ + IssueTypeName[payload.issue.issueType] + } `; + message += `\n**Issue Status:** ${ payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved' - }`; + } `; if (type == Notification.ISSUE_CREATED) { priority = 1; @@ -90,12 +93,14 @@ class GotifyAgent } for (const extra of payload.extra ?? []) { - message += `\n\n**${extra.name}**\n${extra.value}`; + message += `\n\n**${extra.name}**\n${extra.value} `; } if (applicationUrl && payload.media) { const actionUrl = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; - message += `\n\nOpen in ${applicationTitle}(${actionUrl})`; + const displayUrl = + actionUrl.length > 40 ? `${actionUrl.slice(0, 41)}...` : actionUrl; + message += `\n\n**Open in ${applicationTitle}:** [${displayUrl}](${actionUrl}) `; } return { From bea57c330aa57f5455dcae673ef119a65fac26fd Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 5 Apr 2025 01:29:00 +0800 Subject: [PATCH 08/99] docs: add jessielw as a contributor for code (#1556) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 20 ++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index b68f27ca..53c2553a 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -811,6 +811,15 @@ "contributions": [ "code" ] + }, + { + "login": "jessielw", + "name": "Jessie Wilson", + "avatar_url": "https://avatars.githubusercontent.com/u/48299282?v=4", + "profile": "http://www.linkedin.com/in/jessielwilson", + "contributions": [ + "code" + ] } ] } diff --git a/README.md b/README.md index 02d8839e..36bffeae 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors **Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**. @@ -117,7 +117,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon David Fernández Alcoba
David Fernández Alcoba

💻 Gauvino
Gauvino

🌍 EthanArmbrust
EthanArmbrust

💻 - Eduardo
Eduardo

📖 + Eduardo
Eduardo

📖 💻 RickLuiken
RickLuiken

💻 Br33ce
Br33ce

🌍 @@ -184,6 +184,22 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Stancu Florin
Stancu Florin

💻 RankWeis
RankWeis

💻 + + Joseph Risk
Joseph Risk

💻 + Loetwiek
Loetwiek

💻 + Fuochi
Fuochi

📖 + David Emrich
David Emrich

💻 + Max T. Kristiansen
Max T. Kristiansen

💻 + Damien Fajole
Damien Fajole

💻 + Ahmed Siddiqui
Ahmed Siddiqui

💻 + + + JackOXI
JackOXI

💻 + Stancu Florin
Stancu Florin

💻 + Lukas Miklosko
Lukas Miklosko

💻 + Gauthier
Gauthier

💻 + Jessie Wilson
Jessie Wilson

💻 + From 4cf799d6ebeffa86d96646258a7faf31d6050fbe Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 5 Apr 2025 01:32:02 +0800 Subject: [PATCH 09/99] docs: add brotaxt as a contributor for code (#1558) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 53c2553a..94747443 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -820,6 +820,15 @@ "contributions": [ "code" ] + }, + { + "login": "brotaxt", + "name": "DominicKo", + "avatar_url": "https://avatars.githubusercontent.com/u/25477935?v=4", + "profile": "https://github.com/brotaxt", + "contributions": [ + "code" + ] } ] } diff --git a/README.md b/README.md index 36bffeae..7f1976da 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors **Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**. @@ -199,6 +199,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Lukas Miklosko
Lukas Miklosko

💻 Gauthier
Gauthier

💻 Jessie Wilson
Jessie Wilson

💻 + DominicKo
DominicKo

💻 From 14ee52e93e7e6b2c1c5ec6cf05abcac3c0d8163a Mon Sep 17 00:00:00 2001 From: Corentin Normand Date: Fri, 4 Apr 2025 19:41:54 +0200 Subject: [PATCH 10/99] feat(discord.ts): adds a link to the pending approval discord notification (#436) Co-authored-by: Corentin Normand --- server/lib/notifications/agents/discord.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 8eb1d99d..242ee728 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -110,6 +110,8 @@ class DiscordAgent ): DiscordRichEmbed { const { applicationUrl } = getSettings().main; + const appUrl = + applicationUrl || `http://localhost:${process.env.port || 5055}`; let color = EmbedColors.DARK_PURPLE; const fields: Field[] = []; @@ -124,7 +126,7 @@ class DiscordAgent switch (type) { case Notification.MEDIA_PENDING: color = EmbedColors.ORANGE; - status = 'Pending Approval'; + status = `[Pending Approval](${appUrl}/requests)`; break; case Notification.MEDIA_APPROVED: case Notification.MEDIA_AUTO_APPROVED: From 5a6ff61f640fb5d3c7a2bb9195bf106944f99cb4 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 5 Apr 2025 01:42:28 +0800 Subject: [PATCH 11/99] docs: add corentinnormand as a contributor for code (#1559) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 94747443..9abeb59c 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -829,6 +829,15 @@ "contributions": [ "code" ] + }, + { + "login": "corentinnormand", + "name": "Corentin Normand", + "avatar_url": "https://avatars.githubusercontent.com/u/30508927?v=4", + "profile": "https://doctolib.com", + "contributions": [ + "code" + ] } ] } diff --git a/README.md b/README.md index 7f1976da..b84f0eba 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors **Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**. @@ -200,6 +200,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Gauthier
Gauthier

💻 Jessie Wilson
Jessie Wilson

💻 DominicKo
DominicKo

💻 + Corentin Normand
Corentin Normand

💻 From 21400cecdc1b964023087bce479bb7d141049080 Mon Sep 17 00:00:00 2001 From: Nathan Lemmon Date: Sun, 6 Apr 2025 09:49:43 -0500 Subject: [PATCH 12/99] feat(gotify): added priority input for gotify (#1410) * feat(gotify notification): added priority input for gotify Added priority field for gotify messages on the gotify settings page issue 562 * feat(gotify notification): added requested changes fixed json end of file new line, removed unused code, added default priority for previous configurations * feat(gotify notifcation): fixed cypress/config/settings.cypress.json fixed cypress/config/settings.cypress.json * Update cypress/config/settings.cypress.json Removed extra line from settings.cypress.json Co-authored-by: Gauthier --------- Co-authored-by: Gauthier --- cypress/config/settings.cypress.json | 3 +- server/lib/notifications/agents/gotify.ts | 14 ++++--- server/lib/settings/index.ts | 2 + .../NotificationsGotify/index.tsx | 38 +++++++++++++++++++ src/i18n/locale/en.json | 2 + 5 files changed, 52 insertions(+), 7 deletions(-) diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index 702ee148..c466b2bf 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -138,7 +138,8 @@ "types": 0, "options": { "url": "", - "token": "" + "token": "", + "priority": 0 } } } diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts index 31561847..2f4bddf1 100644 --- a/server/lib/notifications/agents/gotify.ts +++ b/server/lib/notifications/agents/gotify.ts @@ -30,7 +30,12 @@ class GotifyAgent public shouldSend(): boolean { const settings = this.getSettings(); - if (settings.enabled && settings.options.url && settings.options.token) { + if ( + settings.enabled && + settings.options.url && + settings.options.token && + settings.options.priority + ) { return true; } @@ -42,7 +47,8 @@ class GotifyAgent payload: NotificationPayload ): GotifyPayload { const { applicationUrl, applicationTitle } = getSettings().main; - let priority = 0; + const settings = this.getSettings(); + const priority = settings.options.priority ?? 1; const title = payload.event ? `${payload.event} - ${payload.subject}` @@ -86,10 +92,6 @@ class GotifyAgent message += `\n**Issue Status:** ${ payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved' } `; - - if (type == Notification.ISSUE_CREATED) { - priority = 1; - } } for (const extra of payload.extra ?? []) { diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index d85f7137..3630c42e 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -254,6 +254,7 @@ export interface NotificationAgentGotify extends NotificationAgentConfig { options: { url: string; token: string; + priority: number; }; } @@ -463,6 +464,7 @@ class Settings { options: { url: '', token: '', + priority: 0, }, }, }, diff --git a/src/components/Settings/Notifications/NotificationsGotify/index.tsx b/src/components/Settings/Notifications/NotificationsGotify/index.tsx index e6c4ebee..a24be1a2 100644 --- a/src/components/Settings/Notifications/NotificationsGotify/index.tsx +++ b/src/components/Settings/Notifications/NotificationsGotify/index.tsx @@ -17,9 +17,11 @@ const messages = defineMessages( agentenabled: 'Enable Agent', url: 'Server URL', token: 'Application Token', + priority: 'Priority', validationUrlRequired: 'You must provide a valid URL', validationUrlTrailingSlash: 'URL must not end in a trailing slash', validationTokenRequired: 'You must provide an application token', + validationPriorityRequired: 'You must set a priority number', gotifysettingssaved: 'Gotify notification settings saved successfully!', gotifysettingsfailed: 'Gotify notification settings failed to save.', toastGotifyTestSending: 'Sending Gotify test notification…', @@ -65,6 +67,15 @@ const NotificationsGotify = () => { .required(intl.formatMessage(messages.validationTokenRequired)), otherwise: Yup.string().nullable(), }), + priority: Yup.string().when('enabled', { + is: true, + then: Yup.string() + .nullable() + .min(0) + .max(9) + .required(intl.formatMessage(messages.validationPriorityRequired)), + otherwise: Yup.string().nullable(), + }), }); if (!data && !error) { @@ -78,6 +89,7 @@ const NotificationsGotify = () => { types: data?.types, url: data?.options.url, token: data?.options.token, + priority: data?.options.priority, }} validationSchema={NotificationsGotifySchema} onSubmit={async (values) => { @@ -93,6 +105,7 @@ const NotificationsGotify = () => { options: { url: values.url, token: values.token, + priority: Number(values.priority), }, }), }); @@ -147,6 +160,7 @@ const NotificationsGotify = () => { options: { url: values.url, token: values.token, + priority: Number(values.priority), }, }), } @@ -216,6 +230,30 @@ const NotificationsGotify = () => { )} +
+ +
+ + {errors.priority && + touched.priority && + typeof errors.priority === 'string' && ( +
{errors.priority}
+ )} +
+
{ diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 121f6882..d4269b96 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -598,11 +598,13 @@ "components.Settings.Notifications.NotificationsGotify.agentenabled": "Enable Agent", "components.Settings.Notifications.NotificationsGotify.gotifysettingsfailed": "Gotify notification settings failed to save.", "components.Settings.Notifications.NotificationsGotify.gotifysettingssaved": "Gotify notification settings saved successfully!", + "components.Settings.Notifications.NotificationsGotify.priority": "Priority", "components.Settings.Notifications.NotificationsGotify.toastGotifyTestFailed": "Gotify test notification failed to send.", "components.Settings.Notifications.NotificationsGotify.toastGotifyTestSending": "Sending Gotify test notification…", "components.Settings.Notifications.NotificationsGotify.toastGotifyTestSuccess": "Gotify test notification sent!", "components.Settings.Notifications.NotificationsGotify.token": "Application Token", "components.Settings.Notifications.NotificationsGotify.url": "Server URL", + "components.Settings.Notifications.NotificationsGotify.validationPriorityRequired": "You must set a priority number", "components.Settings.Notifications.NotificationsGotify.validationTokenRequired": "You must provide an application token", "components.Settings.Notifications.NotificationsGotify.validationTypes": "You must select at least one notification type", "components.Settings.Notifications.NotificationsGotify.validationUrlRequired": "You must provide a valid URL", From a488f850f3d6f0d9896362db873bd86ec1b7040a Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 8 Apr 2025 13:20:10 +0200 Subject: [PATCH 13/99] 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 c466b2bf..7bcb5324 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 78b7e073..d5adf84f 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 6954992d..b427bcf8 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 597cba32..deacbda3 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 14377781..709d2c6d 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 86ec16a6..ac2a8f21 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 175f8bf8..7f859eae 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 85612808..82e10718 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 50027218..3a85d91b 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 5ce1d9cf..42d6e3b7 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 92bffa80..ad3561a4 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 31d0639f..41754368 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 f7b101cd..15ee551e 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 bfded767..34614433 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 8b0d5ca0..c49b9361 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 638af88a..3d0cf53a 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 0cbd4a57..0e623cef 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 a7e66703..0e5e0707 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 1e2caa71..6b6c86ec 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 d8aadfa0..cca0ab82 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 60724569..079fcc01 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 242ee728..cabd332d 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 2f4bddf1..514281f7 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 b8e47384..acfa7df9 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 882e8276..eed4fda9 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 abdb78f2..7abf0d72 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 1d6485cc..8f1f0c95 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 db12b494..01d4de49 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 d91683be..c441cb65 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 3630c42e..a4738d65 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 0065f011..15118d1d 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 df4b4ffe..6cf104f5 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 5f163c3d..040ac5cd 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 0ecdec5c..00000000 --- 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 ffd64df3..d0a492ba 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 a8b8dae7..5c83465f 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 8d619aa3..6980a02e 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 4ef1a7b6..dda78903 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 1d558faa..32cca079 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 cb58b9c5..8fd556fe 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 370384f1..d46e9955 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 5b9dd873..5808ab3a 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 e73147c5..e9da91d2 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 58836ef8..db6e0ded 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}, })} - + + + )} ) : ( data.results.map((item: BlacklistItem) => { @@ -352,7 +409,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { numeric="auto" /> ), - user: ( + user: item.user ? ( { + ) : item.blacklistedTags ? ( + + + + ) : ( + + ??? + ), })} diff --git a/src/components/BlacklistBlock/index.tsx b/src/components/BlacklistBlock/index.tsx index 6980a02e..1e8f1fb6 100644 --- a/src/components/BlacklistBlock/index.tsx +++ b/src/components/BlacklistBlock/index.tsx @@ -1,3 +1,4 @@ +import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge'; import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; @@ -77,22 +78,33 @@ const BlacklistBlock = ({
- - - - - - - {data.user.displayName} + {data.user ? ( + <> + + + + + + + {data.user.displayName} + + - - + + ) : data.blacklistedTags ? ( + <> + + {intl.formatMessage(messages.blacklistedby)}:  + + + + ) : null}
diff --git a/src/components/BlacklistedTagsBadge/index.tsx b/src/components/BlacklistedTagsBadge/index.tsx new file mode 100644 index 00000000..eb1c9a47 --- /dev/null +++ b/src/components/BlacklistedTagsBadge/index.tsx @@ -0,0 +1,62 @@ +import Badge from '@app/components/Common/Badge'; +import Tooltip from '@app/components/Common/Tooltip'; +import defineMessages from '@app/utils/defineMessages'; +import { TagIcon } from '@heroicons/react/20/solid'; +import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces'; +import type { Keyword } from '@server/models/common'; +import axios from 'axios'; +import { useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.Settings', { + blacklistedTagsText: 'Blacklisted Tags', +}); + +interface BlacklistedTagsBadgeProps { + data: BlacklistItem; +} + +const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => { + const [tagNamesBlacklistedFor, setTagNamesBlacklistedFor] = + useState('Loading...'); + const intl = useIntl(); + + useEffect(() => { + if (!data.blacklistedTags) { + return; + } + + const keywordIds = data.blacklistedTags.slice(1, -1).split(','); + Promise.all( + keywordIds.map(async (keywordId) => { + try { + const { data } = await axios.get( + `/api/v1/keyword/${keywordId}` + ); + return data.name; + } catch (err) { + return ''; + } + }) + ).then((keywords) => { + setTagNamesBlacklistedFor(keywords.join(', ')); + }); + }, [data.blacklistedTags]); + + return ( + + + + {intl.formatMessage(messages.blacklistedTagsText)} + + + ); +}; + +export default BlacklistedTagsBadge; diff --git a/src/components/BlacklistedTagsSelector/index.tsx b/src/components/BlacklistedTagsSelector/index.tsx new file mode 100644 index 00000000..b83691ef --- /dev/null +++ b/src/components/BlacklistedTagsSelector/index.tsx @@ -0,0 +1,402 @@ +import Modal from '@app/components/Common/Modal'; +import Tooltip from '@app/components/Common/Tooltip'; +import CopyButton from '@app/components/Settings/CopyButton'; +import { encodeURIExtraParams } from '@app/hooks/useDiscover'; +import defineMessages from '@app/utils/defineMessages'; +import { Transition } from '@headlessui/react'; +import { ArrowDownIcon } from '@heroicons/react/24/solid'; +import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces'; +import type { Keyword } from '@server/models/common'; +import axios from 'axios'; +import { useFormikContext } from 'formik'; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import { useIntl } from 'react-intl'; +import type { ClearIndicatorProps, GroupBase, MultiValue } from 'react-select'; +import { components } from 'react-select'; +import AsyncSelect from 'react-select/async'; + +const messages = defineMessages('components.Settings', { + copyBlacklistedTags: 'Copied blacklisted tags to clipboard.', + copyBlacklistedTagsTip: 'Copy blacklisted tag configuration', + copyBlacklistedTagsEmpty: 'Nothing to copy', + importBlacklistedTagsTip: 'Import blacklisted tag configuration', + clearBlacklistedTagsConfirm: + 'Are you sure you want to clear the blacklisted tags?', + yes: 'Yes', + no: 'No', + searchKeywords: 'Search keywords…', + starttyping: 'Starting typing to search.', + nooptions: 'No results.', + blacklistedTagImportTitle: 'Import Blacklisted Tag Configuration', + blacklistedTagImportInstructions: 'Paste blacklist tag configuration below.', + valueRequired: 'You must provide a value.', + noSpecialCharacters: + 'Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.', + invalidKeyword: '{keywordId} is not a TMDB keyword.', +}); + +type SingleVal = { + label: string; + value: number; +}; + +type BlacklistedTagsSelectorProps = { + defaultValue?: string; +}; + +const BlacklistedTagsSelector = ({ + defaultValue, +}: BlacklistedTagsSelectorProps) => { + const { setFieldValue } = useFormikContext(); + const [value, setValue] = useState(defaultValue); + const intl = useIntl(); + const [selectorValue, setSelectorValue] = + useState | null>(null); + + const update = useCallback( + (value: MultiValue | null) => { + const strVal = value?.map((v) => v.value).join(','); + setSelectorValue(value); + setValue(strVal); + setFieldValue('blacklistedTags', strVal); + }, + [setSelectorValue, setValue, setFieldValue] + ); + + const copyDisabled = value === null || value?.length === 0; + + return ( + <> + + + + + + ); +}; + +type BaseSelectorMultiProps = { + defaultValue?: string; + value: MultiValue | null; + onChange: (value: MultiValue | null) => void; + components?: Partial; +}; + +const ControlledKeywordSelector = ({ + defaultValue, + onChange, + components, + value, +}: BaseSelectorMultiProps) => { + const intl = useIntl(); + + useEffect(() => { + const loadDefaultKeywords = async (): Promise => { + if (!defaultValue) { + return; + } + + const keywords = await Promise.all( + defaultValue.split(',').map(async (keywordId) => { + const { data } = await axios.get( + `/api/v1/keyword/${keywordId}` + ); + return data; + }) + ); + + onChange( + keywords.map((keyword) => ({ + label: keyword.name, + value: keyword.id, + })) + ); + }; + + loadDefaultKeywords(); + }, [defaultValue, onChange]); + + const loadKeywordOptions = async (inputValue: string) => { + const { data } = await axios.get( + `/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}` + ); + + return data.results.map((result) => ({ + label: result.name, + value: result.id, + })); + }; + + return ( + + inputValue === '' + ? intl.formatMessage(messages.starttyping) + : intl.formatMessage(messages.nooptions) + } + value={value} + loadOptions={loadKeywordOptions} + placeholder={intl.formatMessage(messages.searchKeywords)} + onChange={onChange} + components={components} + /> + ); +}; + +type BlacklistedTagsImportButtonProps = { + setSelector: (value: MultiValue) => void; +}; + +const BlacklistedTagsImportButton = ({ + setSelector, +}: BlacklistedTagsImportButtonProps) => { + const [show, setShow] = useState(false); + const formRef = useRef(null); + const intl = useIntl(); + + const onConfirm = useCallback(async () => { + if (formRef.current) { + if (await formRef.current.submitForm()) { + setShow(false); + } + } + }, []); + + const onClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + setShow(true); + }, []); + + return ( + <> + + setShow(false)} + > + + + + + + + + + ); +}; + +type BlacklistedTagImportFormProps = BlacklistedTagsImportButtonProps; + +const BlacklistedTagImportForm = forwardRef< + Partial, + BlacklistedTagImportFormProps +>((props, ref) => { + const { setSelector } = props; + const intl = useIntl(); + const [formValue, setFormValue] = useState(''); + const [errors, setErrors] = useState([]); + + useImperativeHandle(ref, () => ({ + submitForm: handleSubmit, + formValue, + })); + + const validate = async () => { + if (formValue.length === 0) { + setErrors([intl.formatMessage(messages.valueRequired)]); + return false; + } + + if (!/^(?:\d+,)*\d+$/.test(formValue)) { + setErrors([intl.formatMessage(messages.noSpecialCharacters)]); + return false; + } + + const keywords = await Promise.allSettled( + formValue.split(',').map(async (keywordId) => { + try { + const { data } = await axios.get( + `/api/v1/keyword/${keywordId}` + ); + return { + label: data.name, + value: data.id, + }; + } catch (err) { + throw intl.formatMessage(messages.invalidKeyword, { keywordId }); + } + }) + ); + + const failures = keywords.filter( + (res) => res.status === 'rejected' + ) as PromiseRejectedResult[]; + if (failures.length > 0) { + setErrors(failures.map((failure) => `${failure.reason}`)); + return false; + } + + setSelector( + (keywords as PromiseFulfilledResult[]).map( + (keyword) => keyword.value + ) + ); + + setErrors([]); + return true; + }; + + const handleSubmit = validate; + + return ( +
+
+ +