import Alert from '@app/components/Common/Alert'; import Badge from '@app/components/Common/Badge'; import CachedImage from '@app/components/Common/CachedImage'; import Modal from '@app/components/Common/Modal'; import type { RequestOverrides } from '@app/components/RequestModal/AdvancedRequester'; import AdvancedRequester from '@app/components/RequestModal/AdvancedRequester'; import QuotaDisplay from '@app/components/RequestModal/QuotaDisplay'; import { useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; import { Permission } from '@server/lib/permissions'; import type { Collection } from '@server/models/Collection'; import axios from 'axios'; import { useCallback, useEffect, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; const messages = defineMessages({ requestadmin: 'This request will be approved automatically.', requestSuccess: '{title} requested successfully!', requestcollectiontitle: 'Request Collection', requestcollection4ktitle: 'Request Collection 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 = ({ onCancel, onComplete, tmdbId, onUpdating, is4k = false, }: RequestModalProps) => { 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: (msg: React.ReactNode) => {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 ( {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 h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 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={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${ !!partMedia || partRequest || (quota?.movie.limit && currentlyRemaining <= 0 && !isSelectedPart(part.id)) ? 'opacity-50' : '' }`} >
{part.releaseDate?.slice(0, 4)}
{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;