mirror of
https://github.com/seerr-team/seerr.git
synced 2026-02-07 06:02:54 -05:00
fix(servarr): add timeout to Radarr/Sonarr API requests to prevent infinite loading (#2375)
* fix(servarr): add timeout to Radarr/Sonarr API requests to prevent infinite loading Adds a 5-second timeout to all Radarr/Sonarr API requests and displays a warning banner when services are unreachable. This prevents the Recent Requests section and request list pages from hanging indefinitely when a configured service has connection issues. fix #2374 * fix(requests): only show service error banner to users with advanced permissions
This commit is contained in:
@@ -92,11 +92,13 @@ class ServarrBase<QueueItemAppendT> 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<QueueItemAppendT> extends ExternalAPI {
|
||||
},
|
||||
{
|
||||
nodeCache: cacheManager.getCache(cacheName).data,
|
||||
timeout,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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<RadarrMovie[]> => {
|
||||
|
||||
@@ -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<SonarrSeries[]> {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -275,6 +275,24 @@ requestRoutes.get<Record<string, unknown>, 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 });
|
||||
|
||||
@@ -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<RequestResultsResponse>(
|
||||
'/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 (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
@@ -29,6 +45,23 @@ const RecentRequestsSlider = () => {
|
||||
<ArrowRightCircleIcon />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{hasServiceErrors &&
|
||||
(hasPermission(Permission.MANAGE_REQUESTS) ||
|
||||
hasPermission(Permission.REQUEST_ADVANCED)) && (
|
||||
<div className="service-error-banner">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0" />
|
||||
<span>
|
||||
{intl.formatMessage(messages.unableToConnect, {
|
||||
services: [
|
||||
...requests.serviceErrors.radarr.map((s) => s.name),
|
||||
...requests.serviceErrors.sonarr.map((s) => s.name),
|
||||
].join(', '),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Slider
|
||||
sliderKey="requests"
|
||||
isLoading={!requests}
|
||||
|
||||
@@ -5,9 +5,10 @@ import PageTitle from '@app/components/Common/PageTitle';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import RequestItem from '@app/components/RequestList/RequestItem';
|
||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
@@ -30,6 +31,8 @@ const messages = defineMessages('components.RequestList', {
|
||||
sortAdded: 'Most Recent',
|
||||
sortModified: 'Last Modified',
|
||||
sortDirection: 'Toggle Sort Direction',
|
||||
unableToConnect:
|
||||
'Unable to connect to {services}. Some information may be unavailable.',
|
||||
});
|
||||
|
||||
enum Filter {
|
||||
@@ -56,7 +59,7 @@ const RequestList = () => {
|
||||
const { user } = useUser({
|
||||
id: Number(router.query.userId),
|
||||
});
|
||||
const { user: currentUser } = useUser();
|
||||
const { user: currentUser, hasPermission } = useUser();
|
||||
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING);
|
||||
const [currentSort, setCurrentSort] = useState<Sort>('added');
|
||||
const [currentMediaType, setCurrentMediaType] = useState<string>('all');
|
||||
@@ -288,6 +291,25 @@ const RequestList = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.serviceErrors &&
|
||||
(data.serviceErrors.radarr.length > 0 ||
|
||||
data.serviceErrors.sonarr.length > 0) &&
|
||||
(hasPermission(Permission.MANAGE_REQUESTS) ||
|
||||
hasPermission(Permission.REQUEST_ADVANCED)) && (
|
||||
<div className="service-error-banner">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0" />
|
||||
<span>
|
||||
{intl.formatMessage(messages.unableToConnect, {
|
||||
services: [
|
||||
...data.serviceErrors.radarr.map((s) => s.name),
|
||||
...data.serviceErrors.sonarr.map((s) => s.name),
|
||||
].join(', '),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.results.map((request) => {
|
||||
return (
|
||||
<div className="py-2" key={`request-list-${request.id}`}>
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
"components.Discover.PlexWatchlistSlider.emptywatchlist": "Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> 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)",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user