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, 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( '/', 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;