diff --git a/server/api/servarr/base.ts b/server/api/servarr/base.ts index 5343898f5..5c2e1a7d1 100644 --- a/server/api/servarr/base.ts +++ b/server/api/servarr/base.ts @@ -92,11 +92,13 @@ class ServarrBase extends ExternalAPI { apiKey, cacheName, apiName, + timeout = 5000, }: { url: string; apiKey: string; cacheName: AvailableCacheIds; apiName: string; + timeout?: number; }) { super( url, @@ -105,6 +107,7 @@ class ServarrBase extends ExternalAPI { }, { nodeCache: cacheManager.getCache(cacheName).data, + timeout, } ); diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index 3d0cf53ad..29f492d73 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -64,8 +64,16 @@ export interface RadarrMovie { } class RadarrAPI extends ServarrBase<{ movieId: number }> { - constructor({ url, apiKey }: { url: string; apiKey: string }) { - super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr' }); + constructor({ + url, + apiKey, + timeout, + }: { + url: string; + apiKey: string; + timeout?: number; + }) { + super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr', timeout }); } public getMovies = async (): Promise => { diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 01b429ba5..e8b39c6f7 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -111,8 +111,16 @@ class SonarrAPI extends ServarrBase<{ episodeId: number; episode: EpisodeResult; }> { - constructor({ url, apiKey }: { url: string; apiKey: string }) { - super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' }); + constructor({ + url, + apiKey, + timeout, + }: { + url: string; + apiKey: string; + timeout?: number; + }) { + super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr', timeout }); } public async getSeries(): Promise { diff --git a/server/interfaces/api/requestInterfaces.ts b/server/interfaces/api/requestInterfaces.ts index 4a41ae993..9b9c62995 100644 --- a/server/interfaces/api/requestInterfaces.ts +++ b/server/interfaces/api/requestInterfaces.ts @@ -7,6 +7,10 @@ export interface RequestResultsResponse extends PaginatedResponse { profileName?: string; canRemove?: boolean; })[]; + serviceErrors: { + radarr: { id: number; name: string }[]; + sonarr: { id: number; name: string }[]; + }; } export type MediaRequestBody = { diff --git a/server/routes/request.ts b/server/routes/request.ts index a142a6c06..412f4eb92 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -275,6 +275,24 @@ requestRoutes.get, RequestResultsResponse>( page: Math.ceil(skip / pageSize) + 1, }, results: mappedRequests, + serviceErrors: { + radarr: radarrServers + .filter((s) => !s.profiles) + .map((s) => ({ + id: s.id, + name: + settings.radarr.find((r) => r.id === s.id)?.name || + `Radarr ${s.id}`, + })), + sonarr: sonarrServers + .filter((s) => !s.profiles) + .map((s) => ({ + id: s.id, + name: + settings.sonarr.find((r) => r.id === s.id)?.name || + `Sonarr ${s.id}`, + })), + }, }); } catch (e) { next({ status: 500, message: e.message }); diff --git a/src/components/Discover/RecentRequestsSlider/index.tsx b/src/components/Discover/RecentRequestsSlider/index.tsx index 3662f0e00..cd868e27c 100644 --- a/src/components/Discover/RecentRequestsSlider/index.tsx +++ b/src/components/Discover/RecentRequestsSlider/index.tsx @@ -1,14 +1,25 @@ import { sliderTitles } from '@app/components/Discover/constants'; import RequestCard from '@app/components/RequestCard'; import Slider from '@app/components/Slider'; -import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; +import { Permission, useUser } from '@app/hooks/useUser'; +import defineMessages from '@app/utils/defineMessages'; +import { + ArrowRightCircleIcon, + ExclamationTriangleIcon, +} from '@heroicons/react/24/outline'; import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces'; import Link from 'next/link'; import { useIntl } from 'react-intl'; import useSWR from 'swr'; +const messages = defineMessages('components.Discover.RecentRequestsSlider', { + unableToConnect: + 'Unable to connect to {services}. Some information may be unavailable.', +}); + const RecentRequestsSlider = () => { const intl = useIntl(); + const { hasPermission } = useUser(); const { data: requests, error: requestError } = useSWR( '/api/v1/request?filter=all&take=10&sort=modified&skip=0', @@ -21,6 +32,11 @@ const RecentRequestsSlider = () => { return null; } + const hasServiceErrors = + requests?.serviceErrors && + (requests.serviceErrors.radarr.length > 0 || + requests.serviceErrors.sonarr.length > 0); + return ( <>
@@ -29,6 +45,23 @@ const RecentRequestsSlider = () => {
+ + {hasServiceErrors && + (hasPermission(Permission.MANAGE_REQUESTS) || + hasPermission(Permission.REQUEST_ADVANCED)) && ( +
+ + + {intl.formatMessage(messages.unableToConnect, { + services: [ + ...requests.serviceErrors.radarr.map((s) => s.name), + ...requests.serviceErrors.sonarr.map((s) => s.name), + ].join(', '), + })} + +
+ )} + { const { user } = useUser({ id: Number(router.query.userId), }); - const { user: currentUser } = useUser(); + const { user: currentUser, hasPermission } = useUser(); const [currentFilter, setCurrentFilter] = useState(Filter.PENDING); const [currentSort, setCurrentSort] = useState('added'); const [currentMediaType, setCurrentMediaType] = useState('all'); @@ -288,6 +291,25 @@ const RequestList = () => { + + {data.serviceErrors && + (data.serviceErrors.radarr.length > 0 || + data.serviceErrors.sonarr.length > 0) && + (hasPermission(Permission.MANAGE_REQUESTS) || + hasPermission(Permission.REQUEST_ADVANCED)) && ( +
+ + + {intl.formatMessage(messages.unableToConnect, { + services: [ + ...data.serviceErrors.radarr.map((s) => s.name), + ...data.serviceErrors.sonarr.map((s) => s.name), + ].join(', '), + })} + +
+ )} + {data.results.map((request) => { return (
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index b2ddcc2fb..fb53bfc2b 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -102,6 +102,7 @@ "components.Discover.PlexWatchlistSlider.emptywatchlist": "Media added to your Plex Watchlist will appear here.", "components.Discover.PlexWatchlistSlider.plexwatchlist": "Your Watchlist", "components.Discover.RecentlyAddedSlider.recentlyAdded": "Recently Added", + "components.Discover.RecentRequestsSlider.unableToConnect": "Unable to connect to {services}. Some information may be unavailable.", "components.Discover.StudioSlider.studios": "Studios", "components.Discover.TvGenreList.seriesgenres": "Series Genres", "components.Discover.TvGenreSlider.tvgenres": "Series Genres", @@ -515,6 +516,7 @@ "components.RequestList.sortAdded": "Most Recent", "components.RequestList.sortDirection": "Toggle Sort Direction", "components.RequestList.sortModified": "Last Modified", + "components.RequestList.unableToConnect": "Unable to connect to {services}. Some information may be unavailable.", "components.RequestModal.AdvancedRequester.advancedoptions": "Advanced", "components.RequestModal.AdvancedRequester.animenote": "* This series is an anime.", "components.RequestModal.AdvancedRequester.default": "{name} (Default)", diff --git a/src/styles/globals.css b/src/styles/globals.css index abbdea537..a17375173 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -105,6 +105,10 @@ @apply relative mb-4 mt-6 flex; } + .service-error-banner { + @apply mb-2 flex items-center gap-2 rounded-md border border-yellow-500 bg-yellow-500 bg-opacity-20 px-3 py-2 text-sm text-yellow-200; + } + .slider-title { @apply inline-flex items-center text-xl font-bold leading-7 text-gray-300 sm:truncate sm:text-2xl sm:leading-9; }