Files
channels-seerr/src/components/RequestModal/CollectionRequestModal.tsx
Gauthier 4e48fdf2cb fix: rewrite avatarproxy and CachedImage (#1016)
* fix: rewrite avatarproxy and CachedImage

Avatar proxy was allowing every request to be proxied, no matter the original ressource's origin or
filetype. This PR fixes it be allowing only relevant resources to be cached, i.e. Jellyfin/Emby
images and TMDB images.

fix #1012, #1013

* fix: resolve CodeQL error

* fix: resolve CodeQL error

* fix: resolve review comments

* fix: resolve review comment

* fix: resolve CodeQL error

* fix: update imageproxy path
2024-10-17 21:24:15 +08:00

526 lines
20 KiB
TypeScript

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 defineMessages from '@app/utils/defineMessages';
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 { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('components.RequestModal', {
requestadmin: 'This request will be approved automatically.',
requestSuccess: '<strong>{title}</strong> 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<HTMLDivElement> {
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<RequestOverrides | null>(null);
const [selectedParts, setSelectedParts] = useState<number[]>([]);
const { addToast } = useToasts();
const { data, error } = useSWR<Collection>(`/api/v1/collection/${tmdbId}`, {
revalidateOnMount: true,
});
const intl = useIntl();
const { user, hasPermission } = useUser();
const { data: quota } = useSWR<QuotaResponse>(
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 ?? [])
.filter((part) => part.mediaInfo?.status !== MediaStatus.BLACKLISTED)
.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) => {
const res = await fetch('/api/v1/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
mediaId: part.id,
mediaType: 'movie',
is4k,
...overrideParams,
}),
});
if (!res.ok) throw new Error();
})
);
if (onComplete) {
onComplete(
selectedParts.length === (data?.parts ?? []).length
? MediaStatus.UNKNOWN
: MediaStatus.PARTIALLY_AVAILABLE
);
}
addToast(
<span>
{intl.formatMessage(messages.requestSuccess, {
title: data?.name,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ 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' }
);
const blacklistVisibility = hasPermission(
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
{ type: 'or' }
);
return (
<Modal
loading={(!data && !error) || !quota}
backgroundClickable
onCancel={onCancel}
onOk={sendRequest}
title={intl.formatMessage(
is4k
? messages.requestcollection4ktitle
: messages.requestcollectiontitle
)}
subTitle={data?.name}
okText={
isUpdating
? intl.formatMessage(globalMessages.requesting)
: selectedParts.length === 0
? intl.formatMessage(messages.selectmovies)
: intl.formatMessage(
is4k ? messages.requestmovies4k : messages.requestmovies,
{
count: selectedParts.length,
}
)
}
okDisabled={selectedParts.length === 0}
okButtonType={'primary'}
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
>
{hasAutoApprove && !quota?.movie.restricted && (
<div className="mt-6">
<Alert
title={intl.formatMessage(messages.requestadmin)}
type="info"
/>
</div>
)}
{(quota?.movie.limit ?? 0) > 0 && (
<QuotaDisplay
mediaType="movie"
quota={quota?.movie}
remaining={currentlyRemaining}
userOverride={
requestOverrides?.user && requestOverrides.user.id !== user?.id
? requestOverrides?.user?.id
: undefined
}
/>
)}
<div className="flex flex-col">
<div className="-mx-4 sm:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden border border-gray-700 backdrop-blur sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="w-16 bg-gray-700 bg-opacity-80 px-4 py-3">
<span
role="checkbox"
tabIndex={0}
aria-checked={isAllParts()}
onClick={() => 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'
: ''
}`}
>
<span
aria-hidden="true"
className={`${
isAllParts() ? 'bg-indigo-500' : 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isAllParts() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
<th className="bg-gray-700 bg-opacity-80 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(globalMessages.movie)}
</th>
<th className="bg-gray-700 bg-opacity-80 px-2 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(globalMessages.status)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{data?.parts
.filter((part) => {
if (!blacklistVisibility)
return (
part.mediaInfo?.status !== MediaStatus.BLACKLISTED
);
return part;
})
.map((part) => {
const partRequest = getPartRequest(part.id);
const partMedia =
part.mediaInfo &&
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
MediaStatus.UNKNOWN
? part.mediaInfo
: undefined;
return (
<tr key={`part-${part.id}`}>
<td
className={`whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100 ${
partMedia?.status === MediaStatus.BLACKLISTED &&
'pointer-events-none opacity-50'
}`}
>
<span
role="checkbox"
tabIndex={0}
aria-checked={
(!!partMedia &&
partMedia.status !==
MediaStatus.BLACKLISTED) ||
isSelectedPart(part.id)
}
onClick={() => 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 &&
partMedia.status !==
MediaStatus.BLACKLISTED) ||
partRequest ||
(quota?.movie.limit &&
currentlyRemaining <= 0 &&
!isSelectedPart(part.id))
? 'opacity-50'
: ''
}`}
>
<span
aria-hidden="true"
className={`${
(!!partMedia &&
partMedia.status !==
MediaStatus.BLACKLISTED) ||
partRequest ||
isSelectedPart(part.id)
? 'bg-indigo-500'
: 'bg-gray-700'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
(!!partMedia &&
partMedia.status !==
MediaStatus.BLACKLISTED) ||
partRequest ||
isSelectedPart(part.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</td>
<td
className={`flex items-center px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6 ${
partMedia?.status === MediaStatus.BLACKLISTED &&
'pointer-events-none opacity-50'
}`}
>
<div className="relative h-auto w-10 flex-shrink-0 overflow-hidden rounded-md">
<CachedImage
type="tmdb"
src={
part.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"
style={{
width: '100%',
height: 'auto',
objectFit: 'cover',
}}
width={600}
height={900}
/>
</div>
<div className="flex flex-col justify-center pl-2">
<div className="text-xs font-medium">
{part.releaseDate?.slice(0, 4)}
</div>
<div className="text-base font-bold">
{part.title}
</div>
</div>
</td>
<td className="whitespace-nowrap py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6">
{!partMedia && !partRequest && (
<Badge>
{intl.formatMessage(
globalMessages.notrequested
)}
</Badge>
)}
{!partMedia &&
partRequest?.status ===
MediaRequestStatus.PENDING && (
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)}
</Badge>
)}
{((!partMedia &&
partRequest?.status ===
MediaRequestStatus.APPROVED) ||
partMedia?.[is4k ? 'status4k' : 'status'] ===
MediaStatus.PROCESSING) && (
<Badge badgeType="primary">
{intl.formatMessage(globalMessages.requested)}
</Badge>
)}
{partMedia?.[is4k ? 'status4k' : 'status'] ===
MediaStatus.AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
)}
{partMedia?.status === MediaStatus.BLACKLISTED && (
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.blacklisted)}
</Badge>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
<AdvancedRequester
type="movie"
is4k={is4k}
onChange={(overrides) => {
setRequestOverrides(overrides);
}}
/>
)}
</Modal>
);
};
export default CollectionRequestModal;