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.
565 lines
16 KiB
565 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;
|
|
case 'completed':
|
|
case 'deleted':
|
|
statusFilter = [MediaRequestStatus.COMPLETED];
|
|
break;
|
|
default:
|
|
statusFilter = [
|
|
MediaRequestStatus.PENDING,
|
|
MediaRequestStatus.APPROVED,
|
|
MediaRequestStatus.DECLINED,
|
|
MediaRequestStatus.FAILED,
|
|
MediaRequestStatus.COMPLETED,
|
|
];
|
|
}
|
|
|
|
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;
|
|
case 'deleted':
|
|
mediaStatusFilter = [MediaStatus.DELETED];
|
|
break;
|
|
default:
|
|
mediaStatusFilter = [
|
|
MediaStatus.UNKNOWN,
|
|
MediaStatus.PENDING,
|
|
MediaStatus.PROCESSING,
|
|
MediaStatus.PARTIALLY_AVAILABLE,
|
|
MediaStatus.AVAILABLE,
|
|
MediaStatus.DELETED,
|
|
];
|
|
}
|
|
|
|
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 &&
|
|
r.status !== MediaRequestStatus.COMPLETED
|
|
)
|
|
.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;
|