You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
overseerr/server/routes/request.ts

555 lines
16 KiB

import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import {
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 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 = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 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();
return res.status(200).json({
pageInfo: {
pages: Math.ceil(requestCount / pageSize),
pageSize,
results: requestCount,
page: Math.ceil(skip / pageSize) + 1,
},
results: requests,
});
} 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 });
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;