From bdb33722e6df09dd6d8caa36b104b61c6b8dc00d Mon Sep 17 00:00:00 2001 From: sct Date: Sun, 17 Jan 2021 22:52:50 +0900 Subject: [PATCH] feat(requests): Request Overrides & Request Editing (#653) --- overseerr-api.yml | 100 ++++++ server/api/radarr.ts | 4 +- server/entity/MediaRequest.ts | 101 +++++- server/interfaces/api/serviceInterfaces.ts | 18 + server/lib/permissions.ts | 1 + server/routes/index.ts | 2 + server/routes/request.ts | 99 ++++++ server/routes/service.ts | 148 +++++++++ server/subscriber/MediaSubscriber.ts | 4 + src/components/Common/Alert/index.tsx | 4 +- .../Common/LoadingSpinner/index.tsx | 31 +- src/components/Common/Modal/index.tsx | 14 +- src/components/Common/SlideOver/index.tsx | 19 +- src/components/RequestBlock/index.tsx | 73 +++- .../RequestList/RequestItem/index.tsx | 35 +- .../RequestModal/AdvancedRequester/index.tsx | 312 ++++++++++++++++++ .../RequestModal/MovieRequestModal.tsx | 108 +++++- .../RequestModal/TvRequestModal.tsx | 154 ++++++++- src/components/RequestModal/index.tsx | 6 +- src/components/Settings/SettingsMain.tsx | 10 + src/components/UserEdit/index.tsx | 11 + src/hooks/useRequestOverride.ts | 46 +++ src/i18n/globalMessages.ts | 1 + src/i18n/locale/en.json | 18 + 24 files changed, 1256 insertions(+), 63 deletions(-) create mode 100644 server/interfaces/api/serviceInterfaces.ts create mode 100644 server/routes/service.ts create mode 100644 src/components/RequestModal/AdvancedRequester/index.tsx create mode 100644 src/hooks/useRequestOverride.ts diff --git a/overseerr-api.yml b/overseerr-api.yml index 6290c9ccf..ff614bbcc 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -2507,6 +2507,26 @@ paths: application/json: schema: $ref: '#/components/schemas/MediaRequest' + put: + summary: Update a specific MediaRequest + description: Updats a specific media request and returns the request in JSON format. Requires the `MANAGE_REQUESTS` permission. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + example: 1 + schema: + type: string + responses: + '200': + description: Succesfully updated request + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' delete: summary: Delete a request description: Removes a request. If the user has the `MANAGE_REQUESTS` permission, then any request can be removed. Otherwise, only pending requests can be removed. @@ -3066,6 +3086,86 @@ paths: application/json: schema: $ref: '#/components/schemas/Collection' + /service/radarr: + get: + summary: Returns non-sensitive radarr server list + description: Returns a list of radarr servers, both ID and name in JSON format + tags: + - service + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/RadarrSettings' + /service/radarr/{radarrId}: + get: + summary: Returns radarr server quality profiles and root folders + description: Returns a radarr server quality profile and root folder details in JSON format + tags: + - service + parameters: + - in: path + name: radarrId + required: true + schema: + type: number + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: object + properties: + server: + $ref: '#/components/schemas/RadarrSettings' + profiles: + $ref: '#/components/schemas/ServiceProfile' + /service/sonarr: + get: + summary: Returns non-sensitive sonarr server list + description: Returns a list of sonarr servers, both ID and name in JSON format + tags: + - service + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SonarrSettings' + /service/sonarr/{sonarrId}: + get: + summary: Returns sonarr server quality profiles and root folders + description: Returns a sonarr server quality profile and root folder details in JSON format + tags: + - service + parameters: + - in: path + name: sonarrId + required: true + schema: + type: number + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: object + properties: + server: + $ref: '#/components/schemas/SonarrSettings' + profiles: + $ref: '#/components/schemas/ServiceProfile' security: - cookieAuth: [] diff --git a/server/api/radarr.ts b/server/api/radarr.ts index f3aaedc4d..53d8c7ede 100644 --- a/server/api/radarr.ts +++ b/server/api/radarr.ts @@ -29,7 +29,7 @@ interface RadarrMovie { hasFile: boolean; } -interface RadarrRootFolder { +export interface RadarrRootFolder { id: number; path: string; freeSpace: number; @@ -40,7 +40,7 @@ interface RadarrRootFolder { }[]; } -interface RadarrProfile { +export interface RadarrProfile { id: number; name: string; } diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index caf128185..5553b2f4a 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -202,7 +202,7 @@ export class MediaRequest { } if ( - this.media.mediaType === MediaType.MOVIE && + media.mediaType === MediaType.MOVIE && this.status === MediaRequestStatus.DECLINED ) { if (this.is4k) { @@ -284,10 +284,24 @@ export class MediaRequest { return; } - const radarrSettings = settings.radarr.find( + let radarrSettings = settings.radarr.find( (radarr) => radarr.isDefault && radarr.is4k === this.is4k ); + if ( + this.serverId !== null && + this.serverId >= 0 && + radarrSettings?.id !== this.serverId + ) { + radarrSettings = settings.radarr.find( + (radarr) => radarr.id === this.serverId + ); + logger.info( + `Request has an override server: ${radarrSettings?.name}`, + { label: 'Media Request' } + ); + } + if (!radarrSettings) { logger.info( `There is no default ${ @@ -298,6 +312,30 @@ export class MediaRequest { return; } + let rootFolder = radarrSettings.activeDirectory; + let qualityProfile = radarrSettings.activeProfileId; + + if ( + this.rootFolder && + this.rootFolder !== '' && + this.rootFolder !== radarrSettings.activeDirectory + ) { + rootFolder = this.rootFolder; + logger.info(`Request has an override root folder: ${rootFolder}`, { + label: 'Media Request', + }); + } + + if ( + this.profileId && + this.profileId !== radarrSettings.activeProfileId + ) { + qualityProfile = this.profileId; + logger.info(`Request has an override profile id: ${qualityProfile}`, { + label: 'Media Request', + }); + } + const tmdb = new TheMovieDb(); const radarr = new RadarrAPI({ apiKey: radarrSettings.apiKey, @@ -310,9 +348,9 @@ export class MediaRequest { // Run this asynchronously so we don't wait for it on the UI side radarr .addMovie({ - profileId: radarrSettings.activeProfileId, - qualityProfileId: radarrSettings.activeProfileId, - rootFolderPath: radarrSettings.activeDirectory, + profileId: qualityProfile, + qualityProfileId: qualityProfile, + rootFolderPath: rootFolder, minimumAvailability: radarrSettings.minimumAvailability, title: movie.title, tmdbId: movie.id, @@ -376,10 +414,24 @@ export class MediaRequest { return; } - const sonarrSettings = settings.sonarr.find( + let sonarrSettings = settings.sonarr.find( (sonarr) => sonarr.isDefault && sonarr.is4k === this.is4k ); + if ( + this.serverId !== null && + this.serverId >= 0 && + sonarrSettings?.id !== this.serverId + ) { + sonarrSettings = settings.sonarr.find( + (sonarr) => sonarr.id === this.serverId + ); + logger.info( + `Request has an override server: ${sonarrSettings?.name}`, + { label: 'Media Request' } + ); + } + if (!sonarrSettings) { logger.info( `There is no default ${ @@ -423,17 +475,38 @@ export class MediaRequest { seriesType = 'anime'; } + let rootFolder = + seriesType === 'anime' && sonarrSettings.activeAnimeDirectory + ? sonarrSettings.activeAnimeDirectory + : sonarrSettings.activeDirectory; + let qualityProfile = + seriesType === 'anime' && sonarrSettings.activeAnimeProfileId + ? sonarrSettings.activeAnimeProfileId + : sonarrSettings.activeProfileId; + + if ( + this.rootFolder && + this.rootFolder !== '' && + this.rootFolder !== rootFolder + ) { + rootFolder = this.rootFolder; + logger.info(`Request has an override root folder: ${rootFolder}`, { + label: 'Media Request', + }); + } + + if (this.profileId && this.profileId !== qualityProfile) { + qualityProfile = this.profileId; + logger.info(`Request has an override profile id: ${qualityProfile}`, { + label: 'Media Request', + }); + } + // Run this asynchronously so we don't wait for it on the UI side sonarr .addSeries({ - profileId: - seriesType === 'anime' && sonarrSettings.activeAnimeProfileId - ? sonarrSettings.activeAnimeProfileId - : sonarrSettings.activeProfileId, - rootFolderPath: - seriesType === 'anime' && sonarrSettings.activeAnimeDirectory - ? sonarrSettings.activeAnimeDirectory - : sonarrSettings.activeDirectory, + profileId: qualityProfile, + rootFolderPath: rootFolder, title: series.name, tvdbid: series.external_ids.tvdb_id, seasons: this.seasons.map((season) => season.seasonNumber), diff --git a/server/interfaces/api/serviceInterfaces.ts b/server/interfaces/api/serviceInterfaces.ts new file mode 100644 index 000000000..fb4b2cd56 --- /dev/null +++ b/server/interfaces/api/serviceInterfaces.ts @@ -0,0 +1,18 @@ +import { RadarrProfile, RadarrRootFolder } from '../../api/radarr'; + +export interface ServiceCommonServer { + id: number; + name: string; + is4k: boolean; + isDefault: boolean; + activeProfileId: number; + activeDirectory: string; + activeAnimeProfileId?: number; + activeAnimeDirectory?: string; +} + +export interface ServiceCommonServerWithDetails { + server: ServiceCommonServer; + profiles: RadarrProfile[]; + rootFolders: Partial[]; +} diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index b1b559c44..cfda793c6 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -12,6 +12,7 @@ export enum Permission { REQUEST_4K = 1024, REQUEST_4K_MOVIE = 2048, REQUEST_4K_TV = 4096, + REQUEST_ADVANCED = 8192, } /** diff --git a/server/routes/index.ts b/server/routes/index.ts index 3e10bc9ce..282f0be81 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -14,6 +14,7 @@ import mediaRoutes from './media'; import personRoutes from './person'; import collectionRoutes from './collection'; import { getAppVersion, getCommitTag } from '../utils/appVersion'; +import serviceRoutes from './service'; const router = Router(); @@ -45,6 +46,7 @@ router.use('/tv', isAuthenticated(), tvRoutes); router.use('/media', isAuthenticated(), mediaRoutes); router.use('/person', isAuthenticated(), personRoutes); router.use('/collection', isAuthenticated(), collectionRoutes); +router.use('/service', isAuthenticated(), serviceRoutes); router.use('/auth', authRoutes); router.get('/', (_req, res) => { diff --git a/server/routes/request.ts b/server/routes/request.ts index 155371bb1..2d0455236 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -199,6 +199,9 @@ requestRoutes.post( ? req.user : undefined, is4k: req.body.is4k, + serverId: req.body.serverId, + profileId: req.body.profileId, + rootFolder: req.body.rootFolder, seasons: finalSeasons.map( (sn) => new SeasonRequest({ @@ -238,6 +241,102 @@ requestRoutes.get('/:requestId', async (req, res, next) => { } }); +requestRoutes.put<{ requestId: string }>( + '/:requestId', + isAuthenticated(Permission.MANAGE_REQUESTS), + async (req, res, next) => { + const requestRepository = getRepository(MediaRequest); + try { + const request = await requestRepository.findOne( + Number(req.params.requestId) + ); + + if (!request) { + return next({ status: 404, message: 'Request not found' }); + } + + if (req.body.mediaType === 'movie') { + request.serverId = req.body.serverId; + request.profileId = req.body.profileId; + request.rootFolder = req.body.rootFolder; + + requestRepository.save(request); + } else if (req.body.mediaType === 'tv') { + const mediaRepository = getRepository(Media); + request.serverId = req.body.serverId; + request.profileId = req.body.profileId; + request.rootFolder = req.body.rootFolder; + + const requestedSeasons = req.body.seasons as number[] | undefined; + + if (!requestedSeasons || requestedSeasons.length === 0) { + throw new Error( + 'Missing seasons. If you want to cancel a tv 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'], + }); + + // 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) + .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); diff --git a/server/routes/service.ts b/server/routes/service.ts new file mode 100644 index 000000000..c163a9403 --- /dev/null +++ b/server/routes/service.ts @@ -0,0 +1,148 @@ +import { Router } from 'express'; +import RadarrAPI from '../api/radarr'; +import SonarrAPI from '../api/sonarr'; +import { + ServiceCommonServer, + ServiceCommonServerWithDetails, +} from '../interfaces/api/serviceInterfaces'; +import { getSettings } from '../lib/settings'; + +const serviceRoutes = Router(); + +serviceRoutes.get('/radarr', async (req, res) => { + const settings = getSettings(); + + const filteredRadarrServers: ServiceCommonServer[] = settings.radarr.map( + (radarr) => ({ + id: radarr.id, + name: radarr.name, + is4k: radarr.is4k, + isDefault: radarr.isDefault, + activeDirectory: radarr.activeDirectory, + activeProfileId: radarr.activeProfileId, + }) + ); + + return res.status(200).json(filteredRadarrServers); +}); + +serviceRoutes.get<{ radarrId: string }>( + '/radarr/:radarrId', + async (req, res, next) => { + const settings = getSettings(); + + const radarrSettings = settings.radarr.find( + (radarr) => radarr.id === Number(req.params.radarrId) + ); + + if (!radarrSettings) { + return next({ + status: 404, + message: 'Radarr server with provided ID does not exist.', + }); + } + + const radarr = new RadarrAPI({ + apiKey: radarrSettings.apiKey, + url: `${radarrSettings.useSsl ? 'https' : 'http'}://${ + radarrSettings.hostname + }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`, + }); + + const profiles = await radarr.getProfiles(); + const rootFolders = await radarr.getRootFolders(); + + return res.status(200).json({ + server: { + id: radarrSettings.id, + name: radarrSettings.name, + is4k: radarrSettings.is4k, + isDefault: radarrSettings.isDefault, + activeDirectory: radarrSettings.activeDirectory, + activeProfileId: radarrSettings.activeProfileId, + }, + profiles: profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + })), + rootFolders: rootFolders.map((folder) => ({ + id: folder.id, + freeSpace: folder.freeSpace, + path: folder.path, + totalSpace: folder.totalSpace, + })), + } as ServiceCommonServerWithDetails); + } +); + +serviceRoutes.get('/sonarr', async (req, res) => { + const settings = getSettings(); + + const filteredSonarrServers: ServiceCommonServer[] = settings.sonarr.map( + (sonarr) => ({ + id: sonarr.id, + name: sonarr.name, + is4k: sonarr.is4k, + isDefault: sonarr.isDefault, + activeDirectory: sonarr.activeDirectory, + activeProfileId: sonarr.activeProfileId, + activeAnimeProfileId: sonarr.activeAnimeProfileId, + activeAnimeDirectory: sonarr.activeAnimeDirectory, + }) + ); + + return res.status(200).json(filteredSonarrServers); +}); + +serviceRoutes.get<{ sonarrId: string }>( + '/sonarr/:sonarrId', + async (req, res, next) => { + const settings = getSettings(); + + const sonarrSettings = settings.sonarr.find( + (radarr) => radarr.id === Number(req.params.sonarrId) + ); + + if (!sonarrSettings) { + return next({ + status: 404, + message: 'Radarr server with provided ID does not exist.', + }); + } + + const sonarr = new SonarrAPI({ + apiKey: sonarrSettings.apiKey, + url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${ + sonarrSettings.hostname + }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`, + }); + + const profiles = await sonarr.getProfiles(); + const rootFolders = await sonarr.getRootFolders(); + + return res.status(200).json({ + server: { + id: sonarrSettings.id, + name: sonarrSettings.name, + is4k: sonarrSettings.is4k, + isDefault: sonarrSettings.isDefault, + activeDirectory: sonarrSettings.activeDirectory, + activeProfileId: sonarrSettings.activeProfileId, + activeAnimeProfileId: sonarrSettings.activeAnimeProfileId, + activeAnimeDirectory: sonarrSettings.activeAnimeDirectory, + }, + profiles: profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + })), + rootFolders: rootFolders.map((folder) => ({ + id: folder.id, + freeSpace: folder.freeSpace, + path: folder.path, + totalSpace: folder.totalSpace, + })), + } as ServiceCommonServerWithDetails); + } +); + +export default serviceRoutes; diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index 0dd2f2aac..92c4803ec 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -103,6 +103,10 @@ export class MediaSubscriber implements EntitySubscriberInterface { } public beforeUpdate(event: UpdateEvent): void { + if (!event.entity) { + return; + } + if ( event.entity.mediaType === MediaType.MOVIE && event.entity.status === MediaStatus.AVAILABLE diff --git a/src/components/Common/Alert/index.tsx b/src/components/Common/Alert/index.tsx index 84c529c96..0202c27db 100644 --- a/src/components/Common/Alert/index.tsx +++ b/src/components/Common/Alert/index.tsx @@ -58,9 +58,9 @@ const Alert: React.FC = ({ title, children, type }) => {
{design.svg}
-

+
{title} -

+
{children}
diff --git a/src/components/Common/LoadingSpinner/index.tsx b/src/components/Common/LoadingSpinner/index.tsx index 3e3451bf1..022b35466 100644 --- a/src/components/Common/LoadingSpinner/index.tsx +++ b/src/components/Common/LoadingSpinner/index.tsx @@ -1,8 +1,37 @@ import React from 'react'; +export const SmallLoadingSpinner: React.FC = () => { + return ( +
+ + + + + + + + + + +
+ ); +}; + const LoadingSpinner: React.FC = () => { return ( -
+
= ({ return ReactDOM.createPortal( // eslint-disable-next-line jsx-a11y/no-static-element-interactions
{ if (e.key === 'Escape') { typeof onCancel === 'function' && backgroundClickable @@ -98,7 +98,7 @@ const Modal: React.FC = ({ show={!loading} >
= ({ >
{iconSvg && ( -
+
{iconSvg}
)} @@ -116,12 +116,12 @@ const Modal: React.FC = ({ }`} > {title && ( - + )}
@@ -131,7 +131,7 @@ const Modal: React.FC = ({
)} {(onCancel || onOk || onSecondary || onTertiary) && ( -
+
{typeof onOk === 'function' && ( - + + + + )} {request.status !== MediaRequestStatus.PENDING && ( @@ -209,6 +247,39 @@ const RequestBlock: React.FC = ({ request, onUpdate }) => {
)} + {(server || profile || rootFolder) && ( + <> +
+ {intl.formatMessage(messages.requestoverrides)} +
+
    + {server && ( +
  • + + {intl.formatMessage(messages.server)} + + {server} +
  • + )} + {profile !== null && ( +
  • + + {intl.formatMessage(messages.profilechanged)} + + ID {profile} +
  • + )} + {rootFolder && ( +
  • + + {intl.formatMessage(messages.rootfolder)} + + {rootFolder} +
  • + )} +
+ + )}
); diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 1ff736c38..5abf18363 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -24,6 +24,7 @@ import axios from 'axios'; import globalMessages from '../../../i18n/globalMessages'; import Link from 'next/link'; import { useToasts } from 'react-toast-notifications'; +import RequestModal from '../../RequestModal'; const messages = defineMessages({ requestedby: 'Requested by {username}', @@ -51,6 +52,7 @@ const RequestItem: React.FC = ({ const { addToast } = useToasts(); const intl = useIntl(); const { hasPermission } = useUser(); + const [showEditModal, setShowEditModal] = useState(false); const { locale } = useContext(LanguageContext); const url = request.type === 'movie' @@ -116,6 +118,18 @@ const RequestItem: React.FC = ({ return ( + setShowEditModal(false)} + onComplete={() => { + revalidateList(); + setShowEditModal(false); + }} + />
= ({ - + + + + )} diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx new file mode 100644 index 000000000..70f59eb96 --- /dev/null +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -0,0 +1,312 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { SmallLoadingSpinner } from '../../Common/LoadingSpinner'; +import type { + ServiceCommonServer, + ServiceCommonServerWithDetails, +} from '../../../../server/interfaces/api/serviceInterfaces'; +import { defineMessages, useIntl } from 'react-intl'; + +const formatBytes = (bytes: number, decimals = 2) => { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +}; + +const messages = defineMessages({ + advancedoptions: 'Advanced Options', + destinationserver: 'Destination Server', + qualityprofile: 'Quality Profile', + rootfolder: 'Root Folder', + animenote: '* This series is an anime.', + default: '(Default)', + loadingprofiles: 'Loading profiles…', + loadingfolders: 'Loading folders…', +}); + +export type RequestOverrides = { + server?: number; + profile?: number; + folder?: string; +}; + +interface AdvancedRequesterProps { + type: 'movie' | 'tv'; + is4k: boolean; + isAnime?: boolean; + defaultOverrides?: RequestOverrides; + onChange: (overrides: RequestOverrides) => void; +} + +const AdvancedRequester: React.FC = ({ + type, + is4k = false, + isAnime = false, + defaultOverrides, + onChange, +}) => { + const intl = useIntl(); + const { data, error } = useSWR( + `/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnMount: true, + } + ); + const [selectedServer, setSelectedServer] = useState( + defaultOverrides?.server !== undefined && defaultOverrides?.server >= 0 + ? defaultOverrides?.server + : null + ); + const [selectedProfile, setSelectedProfile] = useState( + defaultOverrides?.profile ?? -1 + ); + const [selectedFolder, setSelectedFolder] = useState( + defaultOverrides?.folder ?? '' + ); + const { + data: serverData, + isValidating, + } = useSWR( + selectedServer !== null + ? `/api/v1/service/${ + type === 'movie' ? 'radarr' : 'sonarr' + }/${selectedServer}` + : null, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + } + ); + + useEffect(() => { + let defaultServer = data?.find( + (server) => server.isDefault && is4k === server.is4k + ); + + if (!defaultServer && (data ?? []).length > 0) { + defaultServer = data?.[0]; + } + + if ( + defaultServer && + defaultServer.id !== selectedServer && + (!defaultOverrides || defaultOverrides.server === null) + ) { + setSelectedServer(defaultServer.id); + } + }, [data]); + + useEffect(() => { + if (serverData) { + const defaultProfile = serverData.profiles.find( + (profile) => + profile.id === + (isAnime + ? serverData.server.activeAnimeProfileId + : serverData.server.activeProfileId) + ); + const defaultFolder = serverData.rootFolders.find( + (folder) => + folder.path === + (isAnime + ? serverData.server.activeAnimeDirectory + : serverData.server.activeDirectory) + ); + + if ( + defaultProfile && + defaultProfile.id !== selectedProfile && + (!defaultOverrides || defaultOverrides.profile === null) + ) { + setSelectedProfile(defaultProfile.id); + } + + if ( + defaultFolder && + defaultFolder.path !== selectedFolder && + (!defaultOverrides || defaultOverrides.folder === null) + ) { + setSelectedFolder(defaultFolder?.path ?? ''); + } + } + }, [serverData]); + + useEffect(() => { + if ( + defaultOverrides && + defaultOverrides.server !== null && + defaultOverrides.server !== undefined + ) { + setSelectedServer(defaultOverrides.server); + } + + if ( + defaultOverrides && + defaultOverrides.profile !== null && + defaultOverrides.profile !== undefined + ) { + setSelectedProfile(defaultOverrides.profile); + } + + if ( + defaultOverrides && + defaultOverrides.folder !== null && + defaultOverrides.folder !== undefined + ) { + setSelectedFolder(defaultOverrides.folder); + } + }, [ + defaultOverrides?.server, + defaultOverrides?.folder, + defaultOverrides?.profile, + ]); + + useEffect(() => { + if (selectedServer !== null) { + onChange({ + folder: selectedFolder !== '' ? selectedFolder : undefined, + profile: selectedProfile !== -1 ? selectedProfile : undefined, + server: selectedServer ?? undefined, + }); + } + }, [selectedFolder, selectedServer, selectedProfile]); + + if (!data && !error) { + return ( +
+ +
+ ); + } + + if (!data || selectedServer === null) { + return null; + } + + return ( + <> +
+ + + + + {intl.formatMessage(messages.advancedoptions)} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ {isAnime && ( +
+ {intl.formatMessage(messages.animenote)} +
+ )} +
+ + ); +}; + +export default AdvancedRequester; diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index 79854c578..950ae50fc 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -13,6 +13,9 @@ import { MediaRequestStatus, } from '../../../server/constants/media'; import DownloadIcon from '../../assets/download.svg'; +import Alert from '../Common/Alert'; +import AdvancedRequester, { RequestOverrides } from './AdvancedRequester'; +import globalMessages from '../../i18n/globalMessages'; const messages = defineMessages({ requestadmin: @@ -33,11 +36,14 @@ const messages = defineMessages({ request4k: 'Request 4K', requestfrom: 'There is currently a pending request from {username}', request4kfrom: 'There is currently a pending 4K request from {username}', + errorediting: 'Something went wrong editing the request.', + requestedited: 'Request edited.', }); interface RequestModalProps extends React.HTMLAttributes { tmdbId: number; is4k?: boolean; + editRequest?: MediaRequest; onCancel?: () => void; onComplete?: (newStatus: MediaStatus) => void; onUpdating?: (isUpdating: boolean) => void; @@ -48,9 +54,14 @@ const MovieRequestModal: React.FC = ({ onComplete, tmdbId, onUpdating, - is4k, + editRequest, + is4k = false, }) => { const [isUpdating, setIsUpdating] = useState(false); + const [ + requestOverrides, + setRequestOverrides, + ] = useState(null); const { addToast } = useToasts(); const { data, error } = useSWR(`/api/v1/movie/${tmdbId}`, { revalidateOnMount: true, @@ -66,10 +77,19 @@ const MovieRequestModal: React.FC = ({ const sendRequest = useCallback(async () => { setIsUpdating(true); + let overrideParams = {}; + if (requestOverrides) { + overrideParams = { + serverId: requestOverrides.server, + profileId: requestOverrides.profile, + rootFolder: requestOverrides.folder, + }; + } const response = await axios.post('/api/v1/request', { mediaId: data?.id, mediaType: 'movie', is4k, + ...overrideParams, }); if (response.data) { @@ -94,7 +114,7 @@ const MovieRequestModal: React.FC = ({ ); setIsUpdating(false); } - }, [data, onComplete, addToast]); + }, [data, onComplete, addToast, requestOverrides]); const activeRequest = data?.mediaInfo?.requests?.find( (request) => request.is4k === !!is4k @@ -125,35 +145,64 @@ const MovieRequestModal: React.FC = ({ } }; + const updateRequest = async () => { + setIsUpdating(true); + + try { + await axios.put(`/api/v1/request/${editRequest?.id}`, { + mediaType: 'movie', + serverId: requestOverrides?.server, + profileId: requestOverrides?.profile, + rootFolder: requestOverrides?.folder, + }); + + addToast({intl.formatMessage(messages.requestedited)}, { + appearance: 'success', + autoDismiss: true, + }); + + if (onComplete) { + onComplete(MediaStatus.PENDING); + } + } catch (e) { + addToast({intl.formatMessage(messages.errorediting)}, { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsUpdating(false); + } + }; + const isOwner = activeRequest ? activeRequest.requestedBy.id === user?.id || hasPermission(Permission.MANAGE_REQUESTS) : false; - const text = hasPermission(Permission.MANAGE_REQUESTS) - ? intl.formatMessage(messages.requestadmin) - : undefined; - if (activeRequest?.status === MediaRequestStatus.PENDING) { return ( cancelRequest() : undefined} - okDisabled={isUpdating} title={intl.formatMessage( is4k ? messages.pending4krequest : messages.pendingrequest, { title: data?.title, } )} - okText={ + onOk={() => updateRequest()} + okDisabled={isUpdating} + okText={intl.formatMessage(globalMessages.edit)} + okButtonType="primary" + onSecondary={isOwner ? () => cancelRequest() : undefined} + secondaryDisabled={isUpdating} + secondaryText={ isUpdating ? intl.formatMessage(messages.cancelling) : intl.formatMessage(messages.cancel) } - okButtonType={'danger'} + secondaryButtonType="danger" cancelText={intl.formatMessage(messages.close)} iconSvg={} > @@ -163,6 +212,26 @@ const MovieRequestModal: React.FC = ({ username: activeRequest.requestedBy.username, } )} + {hasPermission(Permission.REQUEST_ADVANCED) && ( +
+ { + setRequestOverrides(overrides); + }} + /> +
+ )}
); } @@ -186,7 +255,24 @@ const MovieRequestModal: React.FC = ({ okButtonType={'primary'} iconSvg={} > -

{text}

+ {(hasPermission(Permission.MANAGE_REQUESTS) || + hasPermission(Permission.AUTO_APPROVE) || + hasPermission(Permission.AUTO_APPROVE_MOVIE)) && ( +

+ + {intl.formatMessage(messages.requestadmin)} + +

+ )} + {hasPermission(Permission.REQUEST_ADVANCED) && ( + { + setRequestOverrides(overrides); + }} + /> + )} ); }; diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 8df739bb5..b157bbff8 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -6,6 +6,7 @@ import { defineMessages, useIntl } from 'react-intl'; import { MediaRequest } from '../../../server/entity/MediaRequest'; import useSWR from 'swr'; import { useToasts } from 'react-toast-notifications'; +import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb'; import axios from 'axios'; import { MediaStatus, @@ -15,13 +16,14 @@ import { TvDetails } from '../../../server/models/Tv'; import Badge from '../Common/Badge'; import globalMessages from '../../i18n/globalMessages'; import SeasonRequest from '../../../server/entity/SeasonRequest'; +import Alert from '../Common/Alert'; +import AdvancedRequester, { RequestOverrides } from './AdvancedRequester'; const messages = defineMessages({ requestadmin: 'Your request will be immediately approved.', cancelrequest: 'This will remove your request. Are you sure you want to continue?', requestSuccess: '{title} successfully requested!', - requestCancel: 'Request for {title} cancelled', requesttitle: 'Request {title}', request4ktitle: 'Request {title} in 4K', requesting: 'Requesting...', @@ -34,6 +36,9 @@ const messages = defineMessages({ seasonnumber: 'Season {number}', extras: 'Extras', notrequested: 'Not Requested', + errorediting: 'Something went wrong editing the request.', + requestedited: 'Request edited.', + requestcancelled: 'Request cancelled.', }); interface RequestModalProps extends React.HTMLAttributes { @@ -42,6 +47,7 @@ interface RequestModalProps extends React.HTMLAttributes { onComplete?: (newStatus: MediaStatus) => void; onUpdating?: (isUpdating: boolean) => void; is4k?: boolean; + editRequest?: MediaRequest; } const TvRequestModal: React.FC = ({ @@ -49,14 +55,72 @@ const TvRequestModal: React.FC = ({ onComplete, tmdbId, onUpdating, + editRequest, is4k = false, }) => { const { addToast } = useToasts(); + const editingSeasons: number[] = (editRequest?.seasons ?? []).map( + (season) => season.seasonNumber + ); const { data, error } = useSWR(`/api/v1/tv/${tmdbId}`); - const [selectedSeasons, setSelectedSeasons] = useState([]); + const [ + requestOverrides, + setRequestOverrides, + ] = useState(null); + const [selectedSeasons, setSelectedSeasons] = useState( + editRequest ? editingSeasons : [] + ); const intl = useIntl(); const { hasPermission } = useUser(); + const updateRequest = async () => { + if (!editRequest) { + return; + } + + if (onUpdating) { + onUpdating(true); + } + + try { + if (selectedSeasons.length > 0) { + await axios.put(`/api/v1/request/${editRequest.id}`, { + mediaType: 'tv', + serverId: requestOverrides?.server, + profileId: requestOverrides?.profile, + rootFolder: requestOverrides?.folder, + seasons: selectedSeasons, + }); + } else { + await axios.delete(`/api/v1/request/${editRequest.id}`); + } + + addToast( + + {selectedSeasons.length > 0 + ? intl.formatMessage(messages.requestedited) + : intl.formatMessage(messages.requestcancelled)} + , + { + appearance: 'success', + autoDismiss: true, + } + ); + if (onComplete) { + onComplete(MediaStatus.PENDING); + } + } catch (e) { + addToast({intl.formatMessage(messages.errorediting)}, { + appearance: 'error', + autoDismiss: true, + }); + } finally { + if (onUpdating) { + onUpdating(false); + } + } + }; + const sendRequest = async () => { if (selectedSeasons.length === 0) { return; @@ -64,12 +128,21 @@ const TvRequestModal: React.FC = ({ if (onUpdating) { onUpdating(true); } + let overrideParams = {}; + if (requestOverrides) { + overrideParams = { + serverId: requestOverrides.server, + profileId: requestOverrides.profile, + rootFolder: requestOverrides.folder, + }; + } const response = await axios.post('/api/v1/request', { mediaId: data?.id, tvdbId: data?.externalIds.tvdbId, mediaType: 'tv', is4k, seasons: selectedSeasons, + ...overrideParams, }); if (response.data) { @@ -99,7 +172,9 @@ const TvRequestModal: React.FC = ({ .reduce((requestedSeasons, request) => { return [ ...requestedSeasons, - ...request.seasons.map((sr) => sr.seasonNumber), + ...request.seasons + .filter((season) => !editingSeasons.includes(season.seasonNumber)) + .map((sr) => sr.seasonNumber), ]; }, [] as number[]); @@ -172,10 +247,6 @@ const TvRequestModal: React.FC = ({ ); }; - const text = hasPermission(Permission.MANAGE_REQUESTS) - ? intl.formatMessage(messages.requestadmin) - : undefined; - const getSeasonRequest = ( seasonNumber: number ): SeasonRequest | undefined => { @@ -205,20 +276,24 @@ const TvRequestModal: React.FC = ({ loading={!data && !error} backgroundClickable onCancel={onCancel} - onOk={() => sendRequest()} + onOk={() => (editRequest ? updateRequest() : sendRequest())} title={intl.formatMessage( is4k ? messages.request4ktitle : messages.requesttitle, { title: data?.name } )} okText={ - selectedSeasons.length === 0 + editRequest && selectedSeasons.length === 0 + ? 'Cancel Request' + : selectedSeasons.length === 0 ? intl.formatMessage(messages.selectseason) : intl.formatMessage(messages.requestseasons, { seasonCount: selectedSeasons.length, }) } - okDisabled={selectedSeasons.length === 0} - okButtonType="primary" + okDisabled={editRequest ? false : selectedSeasons.length === 0} + okButtonType={ + editRequest && selectedSeasons.length === 0 ? 'danger' : `primary` + } iconSvg={ = ({ } > + {(hasPermission(Permission.MANAGE_REQUESTS) || + hasPermission(Permission.AUTO_APPROVE) || + hasPermission(Permission.AUTO_APPROVE_MOVIE)) && + !editRequest && ( +

+ + {intl.formatMessage(messages.requestadmin)} + +

+ )}
-
+
@@ -281,7 +366,7 @@ const TvRequestModal: React.FC = ({ - + {data?.seasons .filter((season) => season.seasonNumber !== 0) .map((season) => { @@ -302,7 +387,10 @@ const TvRequestModal: React.FC = ({ tabIndex={0} aria-checked={ !!mediaSeason || - !!seasonRequest || + (!!seasonRequest && + !editingSeasons.includes( + season.seasonNumber + )) || isSelectedSeason(season.seasonNumber) } onClick={() => toggleSeason(season.seasonNumber)} @@ -312,14 +400,21 @@ const TvRequestModal: React.FC = ({ } }} className={`group relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${ - mediaSeason || seasonRequest ? 'opacity-50' : '' + mediaSeason || + (!!seasonRequest && + !editingSeasons.includes(season.seasonNumber)) + ? 'opacity-50' + : '' }`} >