mirror of
https://github.com/sct/overseerr.git
synced 2025-12-27 08:45:06 +01:00
If Jellyseerr can't connect to Radarr/Sonarr, the "Recent Requests" slider will not load because of the error throwed when trying to get the Quality Profile. This PR catch this error so the recent requests are well displayed.
617 lines
18 KiB
TypeScript
617 lines
18 KiB
TypeScript
import RadarrAPI from '@server/api/servarr/radarr';
|
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
|
import {
|
|
MediaRequestStatus,
|
|
MediaStatus,
|
|
MediaType,
|
|
} from '@server/constants/media';
|
|
import { getRepository } from '@server/datasource';
|
|
import Media from '@server/entity/Media';
|
|
import {
|
|
BlacklistedMediaError,
|
|
DuplicateMediaRequestError,
|
|
MediaRequest,
|
|
NoSeasonsAvailableError,
|
|
QuotaRestrictedError,
|
|
RequestPermissionError,
|
|
} from '@server/entity/MediaRequest';
|
|
import SeasonRequest from '@server/entity/SeasonRequest';
|
|
import { User } from '@server/entity/User';
|
|
import type {
|
|
MediaRequestBody,
|
|
RequestResultsResponse,
|
|
} from '@server/interfaces/api/requestInterfaces';
|
|
import { Permission } from '@server/lib/permissions';
|
|
import { getSettings } from '@server/lib/settings';
|
|
import logger from '@server/logger';
|
|
import { isAuthenticated } from '@server/middleware/auth';
|
|
import { Router } from 'express';
|
|
|
|
const requestRoutes = Router();
|
|
|
|
requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|
'/',
|
|
async (req, res, next) => {
|
|
try {
|
|
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
|
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
|
const requestedBy = req.query.requestedBy
|
|
? Number(req.query.requestedBy)
|
|
: null;
|
|
|
|
let statusFilter: MediaRequestStatus[];
|
|
|
|
switch (req.query.filter) {
|
|
case 'approved':
|
|
case 'processing':
|
|
case 'available':
|
|
statusFilter = [MediaRequestStatus.APPROVED];
|
|
break;
|
|
case 'pending':
|
|
statusFilter = [MediaRequestStatus.PENDING];
|
|
break;
|
|
case 'unavailable':
|
|
statusFilter = [
|
|
MediaRequestStatus.PENDING,
|
|
MediaRequestStatus.APPROVED,
|
|
];
|
|
break;
|
|
case 'failed':
|
|
statusFilter = [MediaRequestStatus.FAILED];
|
|
break;
|
|
default:
|
|
statusFilter = [
|
|
MediaRequestStatus.PENDING,
|
|
MediaRequestStatus.APPROVED,
|
|
MediaRequestStatus.DECLINED,
|
|
MediaRequestStatus.FAILED,
|
|
];
|
|
}
|
|
|
|
let mediaStatusFilter: MediaStatus[];
|
|
|
|
switch (req.query.filter) {
|
|
case 'available':
|
|
mediaStatusFilter = [MediaStatus.AVAILABLE];
|
|
break;
|
|
case 'processing':
|
|
case 'unavailable':
|
|
mediaStatusFilter = [
|
|
MediaStatus.UNKNOWN,
|
|
MediaStatus.PENDING,
|
|
MediaStatus.PROCESSING,
|
|
MediaStatus.PARTIALLY_AVAILABLE,
|
|
];
|
|
break;
|
|
default:
|
|
mediaStatusFilter = [
|
|
MediaStatus.UNKNOWN,
|
|
MediaStatus.PENDING,
|
|
MediaStatus.PROCESSING,
|
|
MediaStatus.PARTIALLY_AVAILABLE,
|
|
MediaStatus.AVAILABLE,
|
|
];
|
|
}
|
|
|
|
let sortFilter: string;
|
|
|
|
switch (req.query.sort) {
|
|
case 'modified':
|
|
sortFilter = 'request.updatedAt';
|
|
break;
|
|
default:
|
|
sortFilter = 'request.id';
|
|
}
|
|
|
|
let query = getRepository(MediaRequest)
|
|
.createQueryBuilder('request')
|
|
.leftJoinAndSelect('request.media', 'media')
|
|
.leftJoinAndSelect('request.seasons', 'seasons')
|
|
.leftJoinAndSelect('request.modifiedBy', 'modifiedBy')
|
|
.leftJoinAndSelect('request.requestedBy', 'requestedBy')
|
|
.where('request.status IN (:...requestStatus)', {
|
|
requestStatus: statusFilter,
|
|
})
|
|
.andWhere(
|
|
'((request.is4k = false AND media.status IN (:...mediaStatus)) OR (request.is4k = true AND media.status4k IN (:...mediaStatus)))',
|
|
{
|
|
mediaStatus: mediaStatusFilter,
|
|
}
|
|
);
|
|
|
|
if (
|
|
!req.user?.hasPermission(
|
|
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
|
{ type: 'or' }
|
|
)
|
|
) {
|
|
if (requestedBy && requestedBy !== req.user?.id) {
|
|
return next({
|
|
status: 403,
|
|
message: "You do not have permission to view this user's requests.",
|
|
});
|
|
}
|
|
|
|
query = query.andWhere('requestedBy.id = :id', {
|
|
id: req.user?.id,
|
|
});
|
|
} else if (requestedBy) {
|
|
query = query.andWhere('requestedBy.id = :id', {
|
|
id: requestedBy,
|
|
});
|
|
}
|
|
|
|
const [requests, requestCount] = await query
|
|
.orderBy(sortFilter, 'DESC')
|
|
.take(pageSize)
|
|
.skip(skip)
|
|
.getManyAndCount();
|
|
|
|
const settings = getSettings();
|
|
|
|
// get all quality profiles for every configured sonarr server
|
|
const sonarrServers = await Promise.all(
|
|
settings.sonarr.map(async (sonarrSetting) => {
|
|
const sonarr = new SonarrAPI({
|
|
apiKey: sonarrSetting.apiKey,
|
|
url: SonarrAPI.buildUrl(sonarrSetting, '/api/v3'),
|
|
});
|
|
|
|
return {
|
|
id: sonarrSetting.id,
|
|
profiles: await sonarr.getProfiles().catch(() => undefined),
|
|
};
|
|
})
|
|
);
|
|
|
|
// get all quality profiles for every configured radarr server
|
|
const radarrServers = await Promise.all(
|
|
settings.radarr.map(async (radarrSetting) => {
|
|
const radarr = new RadarrAPI({
|
|
apiKey: radarrSetting.apiKey,
|
|
url: RadarrAPI.buildUrl(radarrSetting, '/api/v3'),
|
|
});
|
|
|
|
return {
|
|
id: radarrSetting.id,
|
|
profiles: await radarr.getProfiles().catch(() => undefined),
|
|
};
|
|
})
|
|
);
|
|
|
|
// add profile names to the media requests, with undefined if not found
|
|
const requestsWithProfileNames = requests.map((r) => {
|
|
switch (r.type) {
|
|
case MediaType.MOVIE: {
|
|
const profileName = radarrServers
|
|
.find((serverr) => serverr.id === r.serverId)
|
|
?.profiles?.find((profile) => profile.id === r.profileId)?.name;
|
|
|
|
return {
|
|
...r,
|
|
profileName,
|
|
};
|
|
}
|
|
case MediaType.TV: {
|
|
return {
|
|
...r,
|
|
profileName: sonarrServers
|
|
.find((serverr) => serverr.id === r.serverId)
|
|
?.profiles?.find((profile) => profile.id === r.profileId)?.name,
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
return res.status(200).json({
|
|
pageInfo: {
|
|
pages: Math.ceil(requestCount / pageSize),
|
|
pageSize,
|
|
results: requestCount,
|
|
page: Math.ceil(skip / pageSize) + 1,
|
|
},
|
|
results: requestsWithProfileNames,
|
|
});
|
|
} catch (e) {
|
|
next({ status: 500, message: e.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
requestRoutes.post<never, MediaRequest, MediaRequestBody>(
|
|
'/',
|
|
async (req, res, next) => {
|
|
try {
|
|
if (!req.user) {
|
|
return next({
|
|
status: 401,
|
|
message: 'You must be logged in to request media.',
|
|
});
|
|
}
|
|
const request = await MediaRequest.request(req.body, req.user);
|
|
|
|
return res.status(201).json(request);
|
|
} catch (error) {
|
|
if (!(error instanceof Error)) {
|
|
return;
|
|
}
|
|
|
|
switch (error.constructor) {
|
|
case RequestPermissionError:
|
|
case QuotaRestrictedError:
|
|
return next({ status: 403, message: error.message });
|
|
case DuplicateMediaRequestError:
|
|
return next({ status: 409, message: error.message });
|
|
case NoSeasonsAvailableError:
|
|
return next({ status: 202, message: error.message });
|
|
case BlacklistedMediaError:
|
|
return next({ status: 403, message: error.message });
|
|
default:
|
|
return next({ status: 500, message: error.message });
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
requestRoutes.get('/count', async (_req, res, next) => {
|
|
const requestRepository = getRepository(MediaRequest);
|
|
|
|
try {
|
|
const query = requestRepository
|
|
.createQueryBuilder('request')
|
|
.leftJoinAndSelect('request.media', 'media');
|
|
|
|
const totalCount = await query.getCount();
|
|
|
|
const movieCount = await query
|
|
.where('request.type = :requestType', {
|
|
requestType: MediaType.MOVIE,
|
|
})
|
|
.getCount();
|
|
|
|
const tvCount = await query
|
|
.where('request.type = :requestType', {
|
|
requestType: MediaType.TV,
|
|
})
|
|
.getCount();
|
|
|
|
const pendingCount = await query
|
|
.where('request.status = :requestStatus', {
|
|
requestStatus: MediaRequestStatus.PENDING,
|
|
})
|
|
.getCount();
|
|
|
|
const approvedCount = await query
|
|
.where('request.status = :requestStatus', {
|
|
requestStatus: MediaRequestStatus.APPROVED,
|
|
})
|
|
.getCount();
|
|
|
|
const declinedCount = await query
|
|
.where('request.status = :requestStatus', {
|
|
requestStatus: MediaRequestStatus.DECLINED,
|
|
})
|
|
.getCount();
|
|
|
|
const processingCount = await query
|
|
.where('request.status = :requestStatus', {
|
|
requestStatus: MediaRequestStatus.APPROVED,
|
|
})
|
|
.andWhere(
|
|
'((request.is4k = false AND media.status != :availableStatus) OR (request.is4k = true AND media.status4k != :availableStatus))',
|
|
{
|
|
availableStatus: MediaStatus.AVAILABLE,
|
|
}
|
|
)
|
|
.getCount();
|
|
|
|
const availableCount = await query
|
|
.where('request.status = :requestStatus', {
|
|
requestStatus: MediaRequestStatus.APPROVED,
|
|
})
|
|
.andWhere(
|
|
'((request.is4k = false AND media.status = :availableStatus) OR (request.is4k = true AND media.status4k = :availableStatus))',
|
|
{
|
|
availableStatus: MediaStatus.AVAILABLE,
|
|
}
|
|
)
|
|
.getCount();
|
|
|
|
return res.status(200).json({
|
|
total: totalCount,
|
|
movie: movieCount,
|
|
tv: tvCount,
|
|
pending: pendingCount,
|
|
approved: approvedCount,
|
|
declined: declinedCount,
|
|
processing: processingCount,
|
|
available: availableCount,
|
|
});
|
|
} catch (e) {
|
|
logger.error('Something went wrong retrieving request counts', {
|
|
label: 'API',
|
|
errorMessage: e.message,
|
|
});
|
|
next({ status: 500, message: 'Unable to retrieve request counts.' });
|
|
}
|
|
});
|
|
|
|
requestRoutes.get('/:requestId', async (req, res, next) => {
|
|
const requestRepository = getRepository(MediaRequest);
|
|
|
|
try {
|
|
const request = await requestRepository.findOneOrFail({
|
|
where: { id: Number(req.params.requestId) },
|
|
relations: { requestedBy: true, modifiedBy: true },
|
|
});
|
|
|
|
if (
|
|
request.requestedBy.id !== req.user?.id &&
|
|
!req.user?.hasPermission(
|
|
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
|
{ type: 'or' }
|
|
)
|
|
) {
|
|
return next({
|
|
status: 403,
|
|
message: 'You do not have permission to view this request.',
|
|
});
|
|
}
|
|
|
|
return res.status(200).json(request);
|
|
} catch (e) {
|
|
logger.debug('Failed to retrieve request.', {
|
|
label: 'API',
|
|
errorMessage: e.message,
|
|
});
|
|
next({ status: 404, message: 'Request not found.' });
|
|
}
|
|
});
|
|
|
|
requestRoutes.put<{ requestId: string }>(
|
|
'/:requestId',
|
|
async (req, res, next) => {
|
|
const requestRepository = getRepository(MediaRequest);
|
|
const userRepository = getRepository(User);
|
|
try {
|
|
const request = await requestRepository.findOne({
|
|
where: {
|
|
id: Number(req.params.requestId),
|
|
},
|
|
});
|
|
|
|
if (!request) {
|
|
return next({ status: 404, message: 'Request not found.' });
|
|
}
|
|
|
|
if (
|
|
(request.requestedBy.id !== req.user?.id ||
|
|
(req.body.mediaType !== 'tv' &&
|
|
!req.user?.hasPermission(Permission.REQUEST_ADVANCED))) &&
|
|
!req.user?.hasPermission(Permission.MANAGE_REQUESTS)
|
|
) {
|
|
return next({
|
|
status: 403,
|
|
message: 'You do not have permission to modify this request.',
|
|
});
|
|
}
|
|
|
|
let requestUser = request.requestedBy;
|
|
|
|
if (
|
|
req.body.userId &&
|
|
req.body.userId !== request.requestedBy.id &&
|
|
!req.user?.hasPermission([
|
|
Permission.MANAGE_USERS,
|
|
Permission.MANAGE_REQUESTS,
|
|
])
|
|
) {
|
|
return next({
|
|
status: 403,
|
|
message: 'You do not have permission to modify the request user.',
|
|
});
|
|
} else if (req.body.userId) {
|
|
requestUser = await userRepository.findOneOrFail({
|
|
where: { id: req.body.userId },
|
|
});
|
|
}
|
|
|
|
if (req.body.mediaType === MediaType.MOVIE) {
|
|
request.serverId = req.body.serverId;
|
|
request.profileId = req.body.profileId;
|
|
request.rootFolder = req.body.rootFolder;
|
|
request.tags = req.body.tags;
|
|
request.requestedBy = requestUser as User;
|
|
|
|
requestRepository.save(request);
|
|
} else if (req.body.mediaType === MediaType.TV) {
|
|
const mediaRepository = getRepository(Media);
|
|
request.serverId = req.body.serverId;
|
|
request.profileId = req.body.profileId;
|
|
request.rootFolder = req.body.rootFolder;
|
|
request.languageProfileId = req.body.languageProfileId;
|
|
request.tags = req.body.tags;
|
|
request.requestedBy = requestUser as User;
|
|
|
|
const requestedSeasons = req.body.seasons as number[] | undefined;
|
|
|
|
if (!requestedSeasons || requestedSeasons.length === 0) {
|
|
throw new Error(
|
|
'Missing seasons. If you want to cancel a series request, use the DELETE method.'
|
|
);
|
|
}
|
|
|
|
// Get existing media so we can work with all the requests
|
|
const media = await mediaRepository.findOneOrFail({
|
|
where: { tmdbId: request.media.tmdbId, mediaType: MediaType.TV },
|
|
relations: { requests: true },
|
|
});
|
|
|
|
// Get all requested seasons that are not part of this request we are editing
|
|
const existingSeasons = media.requests
|
|
.filter(
|
|
(r) =>
|
|
r.is4k === request.is4k &&
|
|
r.id !== request.id &&
|
|
r.status !== MediaRequestStatus.DECLINED
|
|
)
|
|
.reduce((seasons, r) => {
|
|
const combinedSeasons = r.seasons.map(
|
|
(season) => season.seasonNumber
|
|
);
|
|
|
|
return [...seasons, ...combinedSeasons];
|
|
}, [] as number[]);
|
|
|
|
const filteredSeasons = requestedSeasons.filter(
|
|
(rs) => !existingSeasons.includes(rs)
|
|
);
|
|
|
|
if (filteredSeasons.length === 0) {
|
|
return next({
|
|
status: 202,
|
|
message: 'No seasons available to request',
|
|
});
|
|
}
|
|
|
|
const newSeasons = requestedSeasons.filter(
|
|
(sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn)
|
|
);
|
|
|
|
request.seasons = request.seasons.filter((rs) =>
|
|
filteredSeasons.includes(rs.seasonNumber)
|
|
);
|
|
|
|
if (newSeasons.length > 0) {
|
|
logger.debug('Adding new seasons to request', {
|
|
label: 'Media Request',
|
|
newSeasons,
|
|
});
|
|
request.seasons.push(
|
|
...newSeasons.map(
|
|
(ns) =>
|
|
new SeasonRequest({
|
|
seasonNumber: ns,
|
|
status: MediaRequestStatus.PENDING,
|
|
})
|
|
)
|
|
);
|
|
}
|
|
|
|
await requestRepository.save(request);
|
|
}
|
|
|
|
return res.status(200).json(request);
|
|
} catch (e) {
|
|
next({ status: 500, message: e.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
requestRoutes.delete('/:requestId', async (req, res, next) => {
|
|
const requestRepository = getRepository(MediaRequest);
|
|
|
|
try {
|
|
const request = await requestRepository.findOneOrFail({
|
|
where: { id: Number(req.params.requestId) },
|
|
relations: { requestedBy: true, modifiedBy: true },
|
|
});
|
|
|
|
if (
|
|
!req.user?.hasPermission(Permission.MANAGE_REQUESTS) &&
|
|
request.requestedBy.id !== req.user?.id &&
|
|
request.status !== 1
|
|
) {
|
|
return next({
|
|
status: 401,
|
|
message: 'You do not have permission to delete this request.',
|
|
});
|
|
}
|
|
|
|
await requestRepository.remove(request);
|
|
|
|
return res.status(204).send();
|
|
} catch (e) {
|
|
logger.error('Something went wrong deleting a request.', {
|
|
label: 'API',
|
|
errorMessage: e.message,
|
|
});
|
|
next({ status: 404, message: 'Request not found.' });
|
|
}
|
|
});
|
|
|
|
requestRoutes.post<{
|
|
requestId: string;
|
|
}>(
|
|
'/:requestId/retry',
|
|
isAuthenticated(Permission.MANAGE_REQUESTS),
|
|
async (req, res, next) => {
|
|
const requestRepository = getRepository(MediaRequest);
|
|
|
|
try {
|
|
const request = await requestRepository.findOneOrFail({
|
|
where: { id: Number(req.params.requestId) },
|
|
relations: { requestedBy: true, modifiedBy: true },
|
|
});
|
|
|
|
// this also triggers updating the parent media's status & sending to *arr
|
|
request.status = MediaRequestStatus.APPROVED;
|
|
await requestRepository.save(request);
|
|
|
|
return res.status(200).json(request);
|
|
} catch (e) {
|
|
logger.error('Error processing request retry', {
|
|
label: 'Media Request',
|
|
message: e.message,
|
|
});
|
|
next({ status: 404, message: 'Request not found.' });
|
|
}
|
|
}
|
|
);
|
|
|
|
requestRoutes.post<{
|
|
requestId: string;
|
|
status: 'pending' | 'approve' | 'decline';
|
|
}>(
|
|
'/:requestId/:status',
|
|
isAuthenticated(Permission.MANAGE_REQUESTS),
|
|
async (req, res, next) => {
|
|
const requestRepository = getRepository(MediaRequest);
|
|
|
|
try {
|
|
const request = await requestRepository.findOneOrFail({
|
|
where: { id: Number(req.params.requestId) },
|
|
relations: { requestedBy: true, modifiedBy: true },
|
|
});
|
|
|
|
let newStatus: MediaRequestStatus;
|
|
|
|
switch (req.params.status) {
|
|
case 'pending':
|
|
newStatus = MediaRequestStatus.PENDING;
|
|
break;
|
|
case 'approve':
|
|
newStatus = MediaRequestStatus.APPROVED;
|
|
break;
|
|
case 'decline':
|
|
newStatus = MediaRequestStatus.DECLINED;
|
|
break;
|
|
}
|
|
|
|
request.status = newStatus;
|
|
request.modifiedBy = req.user;
|
|
await requestRepository.save(request);
|
|
|
|
return res.status(200).json(request);
|
|
} catch (e) {
|
|
logger.error('Error processing request update', {
|
|
label: 'Media Request',
|
|
message: e.message,
|
|
});
|
|
next({ status: 404, message: 'Request not found.' });
|
|
}
|
|
}
|
|
);
|
|
|
|
export default requestRoutes;
|