diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index bfd654e9..e2952623 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -1,14 +1,11 @@ -import { DownloadIcon, DuplicateIcon } from '@heroicons/react/outline'; -import axios from 'axios'; +import { DownloadIcon } from '@heroicons/react/outline'; import { uniq } from 'lodash'; import Link from 'next/link'; import { useRouter } from 'next/router'; import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import { MediaStatus } from '../../../server/constants/media'; -import type { MediaRequest } from '../../../server/entity/MediaRequest'; import type { Collection } from '../../../server/models/Collection'; import useSettings from '../../hooks/useSettings'; import { Permission, useUser } from '../../hooks/useUser'; @@ -17,23 +14,17 @@ import Error from '../../pages/_error'; import ButtonWithDropdown from '../Common/ButtonWithDropdown'; import CachedImage from '../Common/CachedImage'; import LoadingSpinner from '../Common/LoadingSpinner'; -import Modal from '../Common/Modal'; import PageTitle from '../Common/PageTitle'; +import RequestModal from '../RequestModal'; import Slider from '../Slider'; import StatusBadge from '../StatusBadge'; import TitleCard from '../TitleCard'; -import Transition from '../Transition'; const messages = defineMessages({ overview: 'Overview', numberofmovies: '{count} Movies', requestcollection: 'Request Collection', - requestswillbecreated: - 'The following titles will have requests created for them:', requestcollection4k: 'Request Collection in 4K', - requestswillbecreated4k: - 'The following titles will have 4K requests created for them:', - requestSuccess: '{title} requested successfully!', }); interface CollectionDetailsProps { @@ -46,10 +37,8 @@ const CollectionDetails: React.FC = ({ const intl = useIntl(); const router = useRouter(); const settings = useSettings(); - const { addToast } = useToasts(); const { hasPermission } = useUser(); const [requestModal, setRequestModal] = useState(false); - const [isRequesting, setRequesting] = useState(false); const [is4k, setIs4k] = useState(false); const { data, error, revalidate } = useSWR( @@ -124,48 +113,6 @@ const CollectionDetails: React.FC = ({ !part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN ).length > 0; - const requestableParts = data.parts.filter( - (part) => - !part.mediaInfo || - part.mediaInfo[is4k ? 'status4k' : 'status'] === MediaStatus.UNKNOWN - ); - - const requestBundle = async () => { - try { - setRequesting(true); - await Promise.all( - requestableParts.map(async (part) => { - await axios.post('/api/v1/request', { - mediaId: part.id, - mediaType: 'movie', - is4k, - }); - }) - ); - - addToast( - - {intl.formatMessage(messages.requestSuccess, { - title: data?.name, - strong: function strong(msg) { - return {msg}; - }, - })} - , - { appearance: 'success', autoDismiss: true } - ); - } catch (e) { - addToast('Something went wrong requesting the collection.', { - appearance: 'error', - autoDismiss: true, - }); - } finally { - setRequesting(false); - setRequestModal(false); - revalidate(); - } - }; - const collectionAttributes: React.ReactNode[] = []; collectionAttributes.push( @@ -229,53 +176,17 @@ const CollectionDetails: React.FC = ({ )} - - requestBundle()} - okText={ - isRequesting - ? intl.formatMessage(globalMessages.requesting) - : intl.formatMessage( - is4k ? globalMessages.request4k : globalMessages.request - ) - } - okDisabled={isRequesting} - okButtonType="primary" - onCancel={() => setRequestModal(false)} - title={intl.formatMessage( - is4k ? messages.requestcollection4k : messages.requestcollection - )} - iconSvg={} - > -

- {intl.formatMessage( - is4k - ? messages.requestswillbecreated4k - : messages.requestswillbecreated - )} -

-
    - {data.parts - .filter( - (part) => - !part.mediaInfo || - part.mediaInfo[is4k ? 'status4k' : 'status'] === - MediaStatus.UNKNOWN - ) - .map((part) => ( -
  • {part.title}
  • - ))} -
-
-
+ type="collection" + is4k={is4k} + onComplete={() => { + revalidate(); + setRequestModal(false); + }} + onCancel={() => setRequestModal(false)} + />
= ({ return ( <> -
+
{intl.formatMessage(messages.advancedoptions)}
diff --git a/src/components/RequestModal/CollectionRequestModal.tsx b/src/components/RequestModal/CollectionRequestModal.tsx new file mode 100644 index 00000000..aab799dc --- /dev/null +++ b/src/components/RequestModal/CollectionRequestModal.tsx @@ -0,0 +1,454 @@ +import { DownloadIcon } from '@heroicons/react/outline'; +import axios from 'axios'; +import React, { useCallback, useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import { + MediaRequestStatus, + MediaStatus, +} from '../../../server/constants/media'; +import { MediaRequest } from '../../../server/entity/MediaRequest'; +import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces'; +import { Permission } from '../../../server/lib/permissions'; +import { Collection } from '../../../server/models/Collection'; +import { useUser } from '../../hooks/useUser'; +import globalMessages from '../../i18n/globalMessages'; +import Alert from '../Common/Alert'; +import Badge from '../Common/Badge'; +import Modal from '../Common/Modal'; +import AdvancedRequester, { RequestOverrides } from './AdvancedRequester'; +import QuotaDisplay from './QuotaDisplay'; + +const messages = defineMessages({ + requestadmin: 'This request will be approved automatically.', + requestSuccess: '{title} requested successfully!', + requesttitle: 'Request {title}', + request4ktitle: 'Request {title} in 4K', + requesterror: 'Something went wrong while submitting the request.', + selectmovies: 'Select Movie(s)', + requestmovies: 'Request {count} {count, plural, one {Movie} other {Movies}}', + requestmovies4k: + 'Request {count} {count, plural, one {Movie} other {Movies}} in 4K', +}); + +interface RequestModalProps extends React.HTMLAttributes { + tmdbId: number; + is4k?: boolean; + onCancel?: () => void; + onComplete?: (newStatus: MediaStatus) => void; + onUpdating?: (isUpdating: boolean) => void; +} + +const CollectionRequestModal: React.FC = ({ + onCancel, + onComplete, + tmdbId, + onUpdating, + is4k = false, +}) => { + const [isUpdating, setIsUpdating] = useState(false); + const [requestOverrides, setRequestOverrides] = + useState(null); + const [selectedParts, setSelectedParts] = useState([]); + const { addToast } = useToasts(); + const { data, error } = useSWR(`/api/v1/collection/${tmdbId}`, { + revalidateOnMount: true, + }); + const intl = useIntl(); + const { user, hasPermission } = useUser(); + const { data: quota } = useSWR( + user && + (!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS)) + ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` + : null + ); + + const currentlyRemaining = + (quota?.movie.remaining ?? 0) - selectedParts.length; + + const getAllParts = (): number[] => { + return (data?.parts ?? []).map((part) => part.id); + }; + + const getAllRequestedParts = (): number[] => { + const requestedParts = (data?.parts ?? []).reduce( + (requestedParts, part) => { + return [ + ...requestedParts, + ...(part.mediaInfo?.requests ?? []) + .filter( + (request) => + request.is4k === is4k && + request.status !== MediaRequestStatus.DECLINED + ) + .map((part) => part.id), + ]; + }, + [] as number[] + ); + + const availableParts = (data?.parts ?? []) + .filter( + (part) => + part.mediaInfo && + (part.mediaInfo[is4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE || + part.mediaInfo[is4k ? 'status4k' : 'status'] === + MediaStatus.PROCESSING) && + !requestedParts.includes(part.id) + ) + .map((part) => part.id); + + return [...requestedParts, ...availableParts]; + }; + + const isSelectedPart = (tmdbId: number): boolean => + selectedParts.includes(tmdbId); + + const togglePart = (tmdbId: number): void => { + // If this part already has a pending request, don't allow it to be toggled + if (getAllRequestedParts().includes(tmdbId)) { + return; + } + + // If there are no more remaining requests available, block toggle + if ( + quota?.movie.limit && + currentlyRemaining <= 0 && + !isSelectedPart(tmdbId) + ) { + return; + } + + if (selectedParts.includes(tmdbId)) { + setSelectedParts((parts) => parts.filter((partId) => partId !== tmdbId)); + } else { + setSelectedParts((parts) => [...parts, tmdbId]); + } + }; + + const unrequestedParts = getAllParts().filter( + (tmdbId) => !getAllRequestedParts().includes(tmdbId) + ); + + const toggleAllParts = (): void => { + // If the user has a quota and not enough requests for all parts, block toggleAllParts + if ( + quota?.movie.limit && + (quota?.movie.remaining ?? 0) < unrequestedParts.length + ) { + return; + } + + if ( + data && + selectedParts.length >= 0 && + selectedParts.length < unrequestedParts.length + ) { + setSelectedParts(unrequestedParts); + } else { + setSelectedParts([]); + } + }; + + const isAllParts = (): boolean => { + if (!data) { + return false; + } + + return ( + selectedParts.length === + getAllParts().filter((part) => !getAllRequestedParts().includes(part)) + .length + ); + }; + + const getPartRequest = (tmdbId: number): MediaRequest | undefined => { + const part = (data?.parts ?? []).find((part) => part.id === tmdbId); + + return (part?.mediaInfo?.requests ?? []).find( + (request) => + request.is4k === is4k && request.status !== MediaRequestStatus.DECLINED + ); + }; + + useEffect(() => { + if (onUpdating) { + onUpdating(isUpdating); + } + }, [isUpdating, onUpdating]); + + const sendRequest = useCallback(async () => { + setIsUpdating(true); + + try { + let overrideParams = {}; + if (requestOverrides) { + overrideParams = { + serverId: requestOverrides.server, + profileId: requestOverrides.profile, + rootFolder: requestOverrides.folder, + userId: requestOverrides.user?.id, + tags: requestOverrides.tags, + }; + } + + await Promise.all( + ( + data?.parts.filter((part) => selectedParts.includes(part.id)) ?? [] + ).map(async (part) => { + await axios.post('/api/v1/request', { + mediaId: part.id, + mediaType: 'movie', + is4k, + ...overrideParams, + }); + }) + ); + + if (onComplete) { + onComplete( + selectedParts.length === (data?.parts ?? []).length + ? MediaStatus.UNKNOWN + : MediaStatus.PARTIALLY_AVAILABLE + ); + } + + addToast( + + {intl.formatMessage(messages.requestSuccess, { + title: data?.name, + strong: function strong(msg) { + return {msg}; + }, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } catch (e) { + addToast(intl.formatMessage(messages.requesterror), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsUpdating(false); + } + }, [requestOverrides, data, onComplete, addToast, intl, selectedParts, is4k]); + + const hasAutoApprove = hasPermission( + [ + Permission.MANAGE_REQUESTS, + is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE, + is4k ? Permission.AUTO_APPROVE_4K_MOVIE : Permission.AUTO_APPROVE_MOVIE, + ], + { type: 'or' } + ); + + return ( + } + backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`} + > + {hasAutoApprove && !quota?.movie.restricted && ( +
+ +
+ )} + {(quota?.movie.limit ?? 0) > 0 && ( + + )} +
+
+
+
+ + + + + + + + + + {data?.parts.map((part) => { + const partRequest = getPartRequest(part.id); + const partMedia = + part.mediaInfo && + part.mediaInfo[is4k ? 'status4k' : 'status'] !== + MediaStatus.UNKNOWN + ? part.mediaInfo + : undefined; + + return ( + + + + + + ); + })} + +
+ toggleAllParts()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + toggleAllParts(); + } + }} + className={`relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 pt-2 cursor-pointer focus:outline-none ${ + quota?.movie.limit && + (quota.movie.remaining ?? 0) < unrequestedParts.length + ? 'opacity-50' + : '' + }`} + > + + + + + {intl.formatMessage(globalMessages.movie)} + + {intl.formatMessage(globalMessages.status)} +
+ togglePart(part.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + togglePart(part.id); + } + }} + className={`pt-2 relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${ + !!partMedia || + partRequest || + (quota?.movie.limit && + currentlyRemaining <= 0 && + !isSelectedPart(part.id)) + ? 'opacity-50' + : '' + }`} + > + + + + + {part.title} + + {!partMedia && !partRequest && ( + + {intl.formatMessage(globalMessages.notrequested)} + + )} + {!partMedia && + partRequest?.status === + MediaRequestStatus.PENDING && ( + + {intl.formatMessage(globalMessages.pending)} + + )} + {((!partMedia && + partRequest?.status === + MediaRequestStatus.APPROVED) || + partMedia?.[is4k ? 'status4k' : 'status'] === + MediaStatus.PROCESSING) && ( + + {intl.formatMessage(globalMessages.requested)} + + )} + {partMedia?.[is4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE && ( + + {intl.formatMessage(globalMessages.available)} + + )} +
+
+
+
+
+ {(hasPermission(Permission.REQUEST_ADVANCED) || + hasPermission(Permission.MANAGE_REQUESTS)) && ( + { + setRequestOverrides(overrides); + }} + /> + )} +
+ ); +}; + +export default CollectionRequestModal; diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index ccfa4f1f..08c22023 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -60,7 +60,10 @@ const MovieRequestModal: React.FC = ({ const intl = useIntl(); const { user, hasPermission } = useUser(); const { data: quota } = useSWR( - user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null + user && + (!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS)) + ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` + : null ); useEffect(() => { @@ -244,22 +247,20 @@ const MovieRequestModal: React.FC = ({ })} {(hasPermission(Permission.REQUEST_ADVANCED) || hasPermission(Permission.MANAGE_REQUESTS)) && ( -
- { - setRequestOverrides(overrides); - }} - /> -
+ { + setRequestOverrides(overrides); + }} + /> )} ); diff --git a/src/components/RequestModal/QuotaDisplay/index.tsx b/src/components/RequestModal/QuotaDisplay/index.tsx index a044f954..0b73c1b8 100644 --- a/src/components/RequestModal/QuotaDisplay/index.tsx +++ b/src/components/RequestModal/QuotaDisplay/index.tsx @@ -99,8 +99,8 @@ const QuotaDisplay: React.FC = ({
{intl.formatMessage( userOverride - ? messages.requiredquota - : messages.requiredquotaUser, + ? messages.requiredquotaUser + : messages.requiredquota, { seasons: overLimit, strong: function strong(msg) { diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 1d6a9e64..8a521b81 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -36,7 +36,8 @@ const messages = defineMessages({ requestfrom: "{username}'s request is pending approval.", requestseasons: 'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}', - requestall: 'Request All Seasons', + requestseasons4k: + 'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}} in 4K', alreadyrequested: 'Already Requested', selectseason: 'Select Season(s)', season: 'Season', @@ -88,7 +89,10 @@ const TvRequestModal: React.FC = ({ }); const [tvdbId, setTvdbId] = useState(undefined); const { data: quota } = useSWR( - user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null + user && + (!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS)) + ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` + : null ); const currentlyRemaining = @@ -387,12 +391,17 @@ const TvRequestModal: React.FC = ({ : getAllRequestedSeasons().length >= getAllSeasons().length ? intl.formatMessage(messages.alreadyrequested) : !settings.currentSettings.partialRequestsEnabled - ? intl.formatMessage(messages.requestall) + ? intl.formatMessage( + is4k ? globalMessages.request4k : globalMessages.request + ) : selectedSeasons.length === 0 ? intl.formatMessage(messages.selectseason) - : intl.formatMessage(messages.requestseasons, { - seasonCount: selectedSeasons.length, - }) + : intl.formatMessage( + is4k ? messages.requestseasons4k : messages.requestseasons, + { + seasonCount: selectedSeasons.length, + } + ) } okDisabled={ editRequest @@ -440,7 +449,7 @@ const TvRequestModal: React.FC = ({ !( quota?.tv.limit && !settings.currentSettings.partialRequestsEnabled && - unrequestedSeasons.length > (quota?.tv.limit ?? 0) + unrequestedSeasons.length > (quota?.tv.remaining ?? 0) ) && getAllRequestedSeasons().length < getAllSeasons().length && !editRequest && ( @@ -457,7 +466,7 @@ const TvRequestModal: React.FC = ({ quota={quota?.tv} remaining={ !settings.currentSettings.partialRequestsEnabled && - unrequestedSeasons.length > (quota?.tv.limit ?? 0) + unrequestedSeasons.length > (quota?.tv.remaining ?? 0) ? 0 : currentlyRemaining } @@ -468,7 +477,7 @@ const TvRequestModal: React.FC = ({ } overLimit={ !settings.currentSettings.partialRequestsEnabled && - unrequestedSeasons.length > (quota?.tv.limit ?? 0) + unrequestedSeasons.length > (quota?.tv.remaining ?? 0) ? unrequestedSeasons.length : undefined } @@ -667,28 +676,26 @@ const TvRequestModal: React.FC = ({
{(hasPermission(Permission.REQUEST_ADVANCED) || hasPermission(Permission.MANAGE_REQUESTS)) && ( -
- keyword.id === ANIME_KEYWORD_ID - )} - onChange={(overrides) => setRequestOverrides(overrides)} - requestUser={editRequest?.requestedBy} - defaultOverrides={ - editRequest - ? { - folder: editRequest.rootFolder, - profile: editRequest.profileId, - server: editRequest.serverId, - language: editRequest.languageProfileId, - tags: editRequest.tags, - } - : undefined - } - /> -
+ keyword.id === ANIME_KEYWORD_ID + )} + onChange={(overrides) => setRequestOverrides(overrides)} + requestUser={editRequest?.requestedBy} + defaultOverrides={ + editRequest + ? { + folder: editRequest.rootFolder, + profile: editRequest.profileId, + server: editRequest.serverId, + language: editRequest.languageProfileId, + tags: editRequest.tags, + } + : undefined + } + /> )} ); diff --git a/src/components/RequestModal/index.tsx b/src/components/RequestModal/index.tsx index 7ba09bde..dfbb715e 100644 --- a/src/components/RequestModal/index.tsx +++ b/src/components/RequestModal/index.tsx @@ -1,13 +1,14 @@ import React from 'react'; -import MovieRequestModal from './MovieRequestModal'; import type { MediaStatus } from '../../../server/constants/media'; -import TvRequestModal from './TvRequestModal'; -import Transition from '../Transition'; import { MediaRequest } from '../../../server/entity/MediaRequest'; +import Transition from '../Transition'; +import CollectionRequestModal from './CollectionRequestModal'; +import MovieRequestModal from './MovieRequestModal'; +import TvRequestModal from './TvRequestModal'; interface RequestModalProps { show: boolean; - type: 'movie' | 'tv'; + type: 'movie' | 'tv' | 'collection'; tmdbId: number; is4k?: boolean; editRequest?: MediaRequest; @@ -26,29 +27,6 @@ const RequestModal: React.FC = ({ onUpdating, onCancel, }) => { - if (type === 'tv') { - return ( - - - - ); - } - return ( = ({ leaveTo="opacity-0" show={show} > - + {type === 'movie' ? ( + + ) : type === 'tv' ? ( + + ) : ( + + )} ); }; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 74bec8f8..10b9a897 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -2,11 +2,8 @@ "components.AppDataWarning.dockerVolumeMissingDescription": "The {appDataPath} volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.", "components.CollectionDetails.numberofmovies": "{count} Movies", "components.CollectionDetails.overview": "Overview", - "components.CollectionDetails.requestSuccess": "{title} requested successfully!", "components.CollectionDetails.requestcollection": "Request Collection", "components.CollectionDetails.requestcollection4k": "Request Collection in 4K", - "components.CollectionDetails.requestswillbecreated": "The following titles will have requests created for them:", - "components.CollectionDetails.requestswillbecreated4k": "The following titles will have 4K requests created for them:", "components.Discover.DiscoverMovieGenre.genreMovies": "{genre} Movies", "components.Discover.DiscoverMovieLanguage.languageMovies": "{language} Movies", "components.Discover.DiscoverNetwork.networkSeries": "{network} Series", @@ -336,15 +333,18 @@ "components.RequestModal.requestCancel": "Request for {title} canceled.", "components.RequestModal.requestSuccess": "{title} requested successfully!", "components.RequestModal.requestadmin": "This request will be approved automatically.", - "components.RequestModal.requestall": "Request All Seasons", "components.RequestModal.requestcancelled": "Request for {title} canceled.", "components.RequestModal.requestedited": "Request for {title} edited successfully!", "components.RequestModal.requesterror": "Something went wrong while submitting the request.", "components.RequestModal.requestfrom": "{username}'s request is pending approval.", + "components.RequestModal.requestmovies": "Request {count} {count, plural, one {Movie} other {Movies}}", + "components.RequestModal.requestmovies4k": "Request {count} {count, plural, one {Movie} other {Movies}} in 4K", "components.RequestModal.requestseasons": "Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}", + "components.RequestModal.requestseasons4k": "Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}} in 4K", "components.RequestModal.requesttitle": "Request {title}", "components.RequestModal.season": "Season", "components.RequestModal.seasonnumber": "Season {number}", + "components.RequestModal.selectmovies": "Select Movie(s)", "components.RequestModal.selectseason": "Select Season(s)", "components.ResetPassword.confirmpassword": "Confirm Password", "components.ResetPassword.email": "Email Address",