feat: 4K Requests (#559)

This commit is contained in:
sct
2021-01-11 23:42:33 +09:00
committed by GitHub
parent 79629645aa
commit 6b2df24a2e
30 changed files with 1384 additions and 467 deletions

View File

@@ -0,0 +1,582 @@
import axios from 'axios';
import React, { useContext, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
MediaRequestStatus,
MediaStatus,
} from '../../../../server/constants/media';
import Media from '../../../../server/entity/Media';
import { MediaRequest } from '../../../../server/entity/MediaRequest';
import { SettingsContext } from '../../../context/SettingsContext';
import { Permission, useUser } from '../../../hooks/useUser';
import ButtonWithDropdown from '../../Common/ButtonWithDropdown';
import RequestModal from '../../RequestModal';
const messages = defineMessages({
viewrequest: 'View Request',
viewrequest4k: 'View 4K Request',
request: 'Request',
request4k: 'Request 4K',
requestmore: 'Request More',
requestmore4k: 'Request More 4K',
approverequest: 'Approve Request',
approverequest4k: 'Approve 4K Request',
declinerequest: 'Decline Request',
declinerequest4k: 'Decline 4K Request',
approverequests:
'Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}',
declinerequests:
'Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}',
approve4krequests:
'Approve {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}',
decline4krequests:
'Decline {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}',
});
interface ButtonOption {
id: string;
text: string;
action: () => void;
svg?: React.ReactNode;
}
interface RequestButtonProps {
mediaType: 'movie' | 'tv';
onUpdate: () => void;
tmdbId: number;
media?: Media;
isShowComplete?: boolean;
is4kShowComplete?: boolean;
}
const RequestButton: React.FC<RequestButtonProps> = ({
tmdbId,
onUpdate,
media,
mediaType,
isShowComplete = false,
is4kShowComplete = false,
}) => {
const intl = useIntl();
const settings = useContext(SettingsContext);
const { hasPermission } = useUser();
const [showRequestModal, setShowRequestModal] = useState(false);
const [showRequest4kModal, setShowRequest4kModal] = useState(false);
const activeRequest = media?.requests.find(
(request) => request.status === MediaRequestStatus.PENDING && !request.is4k
);
const active4kRequest = media?.requests.find(
(request) => request.status === MediaRequestStatus.PENDING && request.is4k
);
// All pending
const activeRequests = media?.requests.filter(
(request) => request.status === MediaRequestStatus.PENDING && !request.is4k
);
const active4kRequests = media?.requests.filter(
(request) => request.status === MediaRequestStatus.PENDING && request.is4k
);
const modifyRequest = async (
request: MediaRequest,
type: 'approve' | 'decline'
) => {
const response = await axios.get(`/api/v1/request/${request.id}/${type}`);
if (response) {
onUpdate();
}
};
const modifyRequests = async (
requests: MediaRequest[],
type: 'approve' | 'decline'
): Promise<void> => {
if (!requests) {
return;
}
await Promise.all(
requests.map(async (request) => {
return axios.get(`/api/v1/request/${request.id}/${type}`);
})
);
onUpdate();
};
const buttons: ButtonOption[] = [];
if (
(!media || media.status === MediaStatus.UNKNOWN) &&
hasPermission(Permission.REQUEST)
) {
buttons.push({
id: 'request',
text: intl.formatMessage(messages.request),
action: () => {
setShowRequestModal(true);
},
svg: (
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
),
});
}
if (
hasPermission(Permission.REQUEST) &&
mediaType === 'tv' &&
media &&
media.status !== MediaStatus.AVAILABLE &&
media.status !== MediaStatus.UNKNOWN &&
!isShowComplete
) {
buttons.push({
id: 'request-more',
text: intl.formatMessage(messages.requestmore),
action: () => {
setShowRequestModal(true);
},
svg: (
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
),
});
}
if (
(!media || media.status4k === MediaStatus.UNKNOWN) &&
(hasPermission(Permission.REQUEST_4K) ||
(mediaType === 'movie' && hasPermission(Permission.REQUEST_4K_MOVIE)) ||
(mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) &&
((settings.currentSettings.movie4kEnabled && mediaType === 'movie') ||
(settings.currentSettings.series4kEnabled && mediaType === 'tv'))
) {
buttons.push({
id: 'request4k',
text: intl.formatMessage(messages.request4k),
action: () => {
setShowRequest4kModal(true);
},
svg: (
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
),
});
}
if (
mediaType === 'tv' &&
(hasPermission(Permission.REQUEST_4K) ||
(mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) &&
media &&
media.status4k !== MediaStatus.AVAILABLE &&
media.status4k !== MediaStatus.UNKNOWN &&
!is4kShowComplete &&
settings.currentSettings.series4kEnabled
) {
buttons.push({
id: 'request-more-4k',
text: intl.formatMessage(messages.requestmore4k),
action: () => {
setShowRequest4kModal(true);
},
svg: (
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
),
});
}
if (
activeRequest &&
mediaType === 'movie' &&
hasPermission(Permission.REQUEST)
) {
buttons.push({
id: 'active-request',
text: intl.formatMessage(messages.viewrequest),
action: () => setShowRequestModal(true),
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
),
});
}
if (
active4kRequest &&
mediaType === 'movie' &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_MOVIE))
) {
buttons.push({
id: 'active-4k-request',
text: intl.formatMessage(messages.viewrequest4k),
action: () => setShowRequest4kModal(true),
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
),
});
}
if (
activeRequest &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'movie'
) {
buttons.push(
{
id: 'approve-request',
text: intl.formatMessage(messages.approverequest),
action: () => {
modifyRequest(activeRequest, 'approve');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
),
},
{
id: 'decline-request',
text: intl.formatMessage(messages.declinerequest),
action: () => {
modifyRequest(activeRequest, 'decline');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
),
}
);
}
if (
activeRequests &&
activeRequests.length > 0 &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'tv'
) {
buttons.push(
{
id: 'approve-request-batch',
text: intl.formatMessage(messages.approverequests, {
requestCount: activeRequests.length,
}),
action: () => {
modifyRequests(activeRequests, 'approve');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
),
},
{
id: 'decline-request-batch',
text: intl.formatMessage(messages.declinerequests, {
requestCount: activeRequests.length,
}),
action: () => {
modifyRequests(activeRequests, 'decline');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
),
}
);
}
if (
active4kRequest &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'movie'
) {
buttons.push(
{
id: 'approve-4k-request',
text: intl.formatMessage(messages.approverequest4k),
action: () => {
modifyRequest(active4kRequest, 'approve');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
),
},
{
id: 'decline-4k-request',
text: intl.formatMessage(messages.declinerequest4k),
action: () => {
modifyRequest(active4kRequest, 'decline');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
),
}
);
}
if (
active4kRequests &&
active4kRequests.length > 0 &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'tv'
) {
buttons.push(
{
id: 'approve-request-batch',
text: intl.formatMessage(messages.approve4krequests, {
requestCount: active4kRequests.length,
}),
action: () => {
modifyRequests(active4kRequests, 'approve');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
),
},
{
id: 'decline-request-batch',
text: intl.formatMessage(messages.decline4krequests, {
requestCount: active4kRequests.length,
}),
action: () => {
modifyRequests(active4kRequests, 'decline');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
),
}
);
}
const [buttonOne, ...others] = buttons;
if (!buttonOne) {
return null;
}
return (
<>
<RequestModal
tmdbId={tmdbId}
show={showRequestModal}
type={mediaType}
onComplete={() => {
onUpdate();
setShowRequestModal(false);
}}
onCancel={() => setShowRequestModal(false)}
/>
<RequestModal
tmdbId={tmdbId}
show={showRequest4kModal}
type={mediaType}
is4k
onComplete={() => {
onUpdate();
setShowRequest4kModal(false);
}}
onCancel={() => setShowRequest4kModal(false)}
/>
<ButtonWithDropdown
text={
<>
{buttonOne.svg ?? null}
{buttonOne.text}
</>
}
onClick={buttonOne.action}
className="ml-2"
>
{others && others.length > 0
? others.map((button) => (
<ButtonWithDropdown.Item
onClick={button.action}
key={`request-option-${button.id}`}
>
{button.svg}
{button.text}
</ButtonWithDropdown.Item>
))
: null}
{/* {hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<ButtonWithDropdown.Item onClick={() => modifyRequest('approve')}>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
{intl.formatMessage(messages.approve)}
</ButtonWithDropdown.Item>
</>
)} */}
</ButtonWithDropdown>
</>
);
};
export default RequestButton;

View File

@@ -18,12 +18,7 @@ import PersonCard from '../PersonCard';
import { LanguageContext } from '../../context/LanguageContext';
import LoadingSpinner from '../Common/LoadingSpinner';
import { useUser, Permission } from '../../hooks/useUser';
import {
MediaStatus,
MediaRequestStatus,
} from '../../../server/constants/media';
import RequestModal from '../RequestModal';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import { MediaStatus } from '../../../server/constants/media';
import axios from 'axios';
import SlideOver from '../Common/SlideOver';
import RequestBlock from '../RequestBlock';
@@ -38,6 +33,7 @@ import Head from 'next/head';
import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers';
import StatusBadge from '../StatusBadge';
import RequestButton from './RequestButton';
const messages = defineMessages({
releasedate: 'Release Date',
@@ -55,8 +51,6 @@ const messages = defineMessages({
cancelrequest: 'Cancel Request',
available: 'Available',
unavailable: 'Unavailable',
request: 'Request',
viewrequest: 'View Request',
pending: 'Pending',
overviewunavailable: 'Overview unavailable',
manageModalTitle: 'Manage Movie',
@@ -88,7 +82,6 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const router = useRouter();
const intl = useIntl();
const { locale } = useContext(LanguageContext);
const [showRequestModal, setShowRequestModal] = useState(false);
const [showManager, setShowManager] = useState(false);
const { data, error, revalidate } = useSWR<MovieDetailsType>(
`/api/v1/movie/${router.query.movieId}?language=${locale}`,
@@ -118,25 +111,11 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
return <Error statusCode={404} />;
}
const activeRequest = data?.mediaInfo?.requests?.find(
(request) => request.status === MediaRequestStatus.PENDING
);
const trailerUrl = data.relatedVideos
?.filter((r) => r.type === 'Trailer')
.sort((a, b) => a.size - b.size)
.pop()?.url;
const modifyRequest = async (type: 'approve' | 'decline') => {
const response = await axios.get(
`/api/v1/request/${activeRequest?.id}/${type}`
);
if (response) {
revalidate();
}
};
const deleteMedia = async () => {
if (data?.mediaInfo?.id) {
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
@@ -155,16 +134,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<Head>
<title>{data.title} - Overseerr</title>
</Head>
<RequestModal
tmdbId={data.id}
show={showRequestModal}
type="movie"
onComplete={() => {
revalidate();
setShowRequestModal(false);
}}
onCancel={() => setShowRequestModal(false)}
/>
<SlideOver
show={showManager}
title={intl.formatMessage(messages.manageModalTitle)}
@@ -216,7 +186,14 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div>
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
<div className="mb-2">
<StatusBadge status={data.mediaInfo?.status} />
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
<span className="mr-2">
<StatusBadge status={data.mediaInfo?.status} />
</span>
)}
<span>
<StatusBadge status={data.mediaInfo?.status4k} is4k />
</span>
</div>
<h1 className="text-2xl lg:text-4xl">
{data.title}{' '}
@@ -263,121 +240,12 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</Button>
</a>
)}
{(!data.mediaInfo ||
data.mediaInfo?.status === MediaStatus.UNKNOWN) && (
<Button
buttonType="primary"
className="ml-2"
onClick={() => setShowRequestModal(true)}
>
{activeRequest ? (
<svg
className="w-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="w-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
)}
<FormattedMessage {...messages.request} />
</Button>
)}
{activeRequest && (
<ButtonWithDropdown
dropdownIcon={
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
clipRule="evenodd"
/>
</svg>
}
text={
<>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage {...messages.viewrequest} />
</>
}
onClick={() => setShowRequestModal(true)}
className="ml-2"
>
{hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<ButtonWithDropdown.Item
onClick={() => modifyRequest('approve')}
>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
{intl.formatMessage(messages.approve)}
</ButtonWithDropdown.Item>
<ButtonWithDropdown.Item
onClick={() => modifyRequest('decline')}
>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
{intl.formatMessage(messages.decline)}
</ButtonWithDropdown.Item>
</>
)}
</ButtonWithDropdown>
)}
<RequestButton
mediaType="movie"
media={data.mediaInfo}
tmdbId={data.id}
onUpdate={() => revalidate()}
/>
{hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="default"

View File

@@ -45,8 +45,8 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
<div className="block">
<div className="px-4 py-4">
<div className="flex items-center justify-between">
<div className="mr-6 flex-col items-center text-sm leading-5 text-gray-300 flex-1 min-w-0">
<div className="flex flex-nowrap mb-1 white">
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5 text-gray-300">
<div className="flex mb-1 flex-nowrap white">
<svg
className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
fill="currentColor"
@@ -59,7 +59,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
clipRule="evenodd"
/>
</svg>
<span className="truncate w-40 md:w-auto">
<span className="w-40 truncate md:w-auto">
{request.requestedBy.username}
</span>
</div>
@@ -78,13 +78,13 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
clipRule="evenodd"
/>
</svg>
<span className="truncate w-40 md:w-auto">
<span className="w-40 truncate md:w-auto">
{request.modifiedBy?.username}
</span>
</div>
)}
</div>
<div className="ml-2 flex-shrink-0 flex flex-wrap">
<div className="flex flex-wrap flex-shrink-0 ml-2">
{request.status === MediaRequestStatus.PENDING && (
<>
<span className="mr-1">
@@ -153,11 +153,11 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex">
<div className="mr-6 flex items-center text-sm leading-5 text-gray-300">
{request.status === MediaRequestStatus.AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
<div className="flex items-center mr-6 text-sm leading-5 text-gray-300">
{request.is4k && (
<span className="mr-1">
<Badge badgeType="warning">4K</Badge>
</span>
)}
{request.status === MediaRequestStatus.APPROVED && (
<Badge badgeType="success">
@@ -176,7 +176,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
)}
</div>
</div>
<div className="mt-2 flex items-center text-sm leading-5 text-gray-300 sm:mt-0">
<div className="flex items-center mt-2 text-sm leading-5 text-gray-300 sm:mt-0">
<svg
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
xmlns="http://www.w3.org/2000/svg"
@@ -195,13 +195,13 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
</div>
</div>
{(request.seasons ?? []).length > 0 && (
<div className="mt-2 text-sm flex flex-col">
<div className="flex flex-col mt-2 text-sm">
<div className="mb-2">{intl.formatMessage(messages.seasons)}</div>
<div>
{request.seasons.map((season) => (
<span
key={`season-${season.id}`}
className="mr-2 mb-1 inline-block"
className="inline-block mb-1 mr-2"
>
<Badge>{season.seasonNumber}</Badge>
</span>

View File

@@ -28,7 +28,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
const RequestCardPlaceholder: React.FC = () => {
return (
<div className="w-72 sm:w-96 relative animate-pulse rounded-lg bg-gray-700 p-4">
<div className="relative p-4 bg-gray-700 rounded-lg w-72 sm:w-96 animate-pulse">
<div className="w-20 sm:w-28">
<div className="w-full" style={{ paddingBottom: '150%' }} />
</div>
@@ -88,13 +88,13 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
return (
<div
className="relative w-72 sm:w-96 p-4 bg-gray-800 rounded-md flex bg-cover bg-center text-gray-400"
className="relative flex p-4 text-gray-400 bg-gray-800 bg-center bg-cover rounded-md w-72 sm:w-96"
style={{
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath})`,
}}
>
<div className="flex-1 pr-4 min-w-0 flex flex-col">
<h2 className="text-base sm:text-lg overflow-ellipsis overflow-hidden whitespace-nowrap text-white cursor-pointer hover:underline">
<div className="flex flex-col flex-1 min-w-0 pr-4">
<h2 className="overflow-hidden text-base text-white cursor-pointer sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
<Link
href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'}
as={
@@ -106,18 +106,21 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
{isMovie(title) ? title.title : title.name}
</Link>
</h2>
<div className="text-xs sm:text-sm truncate">
<div className="text-xs truncate sm:text-sm">
{intl.formatMessage(messages.requestedby, {
username: requestData.requestedBy.username,
})}
</div>
{requestData.media.status && (
<div className="mt-1 sm:mt-2">
<StatusBadge status={requestData.media.status} />
<StatusBadge
status={requestData.media.status}
is4k={requestData.is4k}
/>
</div>
)}
{request.seasons.length > 0 && (
<div className="hidden mt-2 text-sm sm:flex items-center">
<div className="items-center hidden mt-2 text-sm sm:flex">
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span>
{!isMovie(title) &&
title.seasons.filter((season) => season.seasonNumber !== 0)
@@ -126,7 +129,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
<Badge>{intl.formatMessage(messages.all)}</Badge>
</span>
) : (
<div className="hide-scrollbar overflow-x-scroll">
<div className="overflow-x-scroll hide-scrollbar">
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
@@ -138,7 +141,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
)}
{requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<div className="flex-1 flex items-end">
<div className="flex items-end flex-1">
<span className="mr-2">
<Button
buttonType="success"
@@ -200,7 +203,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`}
alt=""
className="w-20 sm:w-28 rounded-md shadow-sm cursor-pointer transition transform-gpu duration-300 scale-100 hover:scale-105 hover:shadow-md"
className="w-20 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md"
/>
</Link>
</div>

View File

@@ -22,17 +22,22 @@ const messages = defineMessages({
requestSuccess: '<strong>{title}</strong> successfully requested!',
requestCancel: 'Request for <strong>{title}</strong> cancelled',
requesttitle: 'Request {title}',
request4ktitle: 'Request {title} in 4K',
close: 'Close',
cancel: 'Cancel Request',
cancelling: 'Cancelling...',
pendingrequest: 'Pending request for {title}',
pending4krequest: 'Pending request for {title} in 4K',
requesting: 'Requesting...',
request: 'Request',
request4k: 'Request 4K',
requestfrom: 'There is currently a pending request from {username}',
request4kfrom: 'There is currently a pending 4K request from {username}',
});
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
tmdbId: number;
is4k?: boolean;
onCancel?: () => void;
onComplete?: (newStatus: MediaStatus) => void;
onUpdating?: (isUpdating: boolean) => void;
@@ -43,6 +48,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
onComplete,
tmdbId,
onUpdating,
is4k,
}) => {
const [isUpdating, setIsUpdating] = useState(false);
const { addToast } = useToasts();
@@ -63,6 +69,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
const response = await axios.post<MediaRequest>('/api/v1/request', {
mediaId: data?.id,
mediaType: 'movie',
is4k,
});
if (response.data) {
@@ -89,7 +96,9 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
}
}, [data, onComplete, addToast]);
const activeRequest = data?.mediaInfo?.requests?.[0];
const activeRequest = data?.mediaInfo?.requests?.find(
(request) => request.is4k === !!is4k
);
const cancelRequest = async () => {
setIsUpdating(true);
@@ -133,9 +142,12 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
onCancel={onCancel}
onOk={isOwner ? () => cancelRequest() : undefined}
okDisabled={isUpdating}
title={intl.formatMessage(messages.pendingrequest, {
title: data?.title,
})}
title={intl.formatMessage(
is4k ? messages.pending4krequest : messages.pendingrequest,
{
title: data?.title,
}
)}
okText={
isUpdating
? intl.formatMessage(messages.cancelling)
@@ -145,9 +157,12 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
cancelText={intl.formatMessage(messages.close)}
iconSvg={<DownloadIcon className="w-6 h-6" />}
>
{intl.formatMessage(messages.requestfrom, {
username: activeRequest.requestedBy.username,
})}
{intl.formatMessage(
is4k ? messages.request4kfrom : messages.requestfrom,
{
username: activeRequest.requestedBy.username,
}
)}
</Modal>
);
}
@@ -159,11 +174,14 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
onCancel={onCancel}
onOk={sendRequest}
okDisabled={isUpdating}
title={intl.formatMessage(messages.requesttitle, { title: data?.title })}
title={intl.formatMessage(
is4k ? messages.request4ktitle : messages.requesttitle,
{ title: data?.title }
)}
okText={
isUpdating
? intl.formatMessage(messages.requesting)
: intl.formatMessage(messages.request)
: intl.formatMessage(is4k ? messages.request4k : messages.request)
}
okButtonType={'primary'}
iconSvg={<DownloadIcon className="w-6 h-6" />}

View File

@@ -23,6 +23,7 @@ const messages = defineMessages({
requestSuccess: '<strong>{title}</strong> successfully requested!',
requestCancel: 'Request for <strong>{title}</strong> cancelled',
requesttitle: 'Request {title}',
request4ktitle: 'Request {title} in 4K',
requesting: 'Requesting...',
requestseasons:
'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}',
@@ -40,6 +41,7 @@ interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
onCancel?: () => void;
onComplete?: (newStatus: MediaStatus) => void;
onUpdating?: (isUpdating: boolean) => void;
is4k?: boolean;
}
const TvRequestModal: React.FC<RequestModalProps> = ({
@@ -47,6 +49,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
onComplete,
tmdbId,
onUpdating,
is4k = false,
}) => {
const { addToast } = useToasts();
const { data, error } = useSWR<TvDetails>(`/api/v1/tv/${tmdbId}`);
@@ -65,6 +68,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
mediaId: data?.id,
tvdbId: data?.externalIds.tvdbId,
mediaType: 'tv',
is4k,
seasons: selectedSeasons,
});
@@ -90,21 +94,21 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
};
const getAllRequestedSeasons = (): number[] => {
const requestedSeasons = (data?.mediaInfo?.requests ?? []).reduce(
(requestedSeasons, request) => {
const requestedSeasons = (data?.mediaInfo?.requests ?? [])
.filter((request) => request.is4k === is4k)
.reduce((requestedSeasons, request) => {
return [
...requestedSeasons,
...request.seasons.map((sr) => sr.seasonNumber),
];
},
[] as number[]
);
}, [] as number[]);
const availableSeasons = (data?.mediaInfo?.seasons ?? [])
.filter(
(season) =>
(season.status === MediaStatus.AVAILABLE ||
season.status === MediaStatus.PARTIALLY_AVAILABLE) &&
(season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
season[is4k ? 'status4k' : 'status'] ===
MediaStatus.PARTIALLY_AVAILABLE) &&
!requestedSeasons.includes(season.seasonNumber)
)
.map((season) => season.seasonNumber);
@@ -176,14 +180,21 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
seasonNumber: number
): SeasonRequest | undefined => {
let seasonRequest: SeasonRequest | undefined;
if (data?.mediaInfo && (data.mediaInfo.requests || []).length > 0) {
data.mediaInfo.requests.forEach((request) => {
if (!seasonRequest) {
seasonRequest = request.seasons.find(
(season) => season.seasonNumber === seasonNumber
);
}
});
if (
data?.mediaInfo &&
(data.mediaInfo.requests || []).filter((request) => request.is4k === is4k)
.length > 0
) {
data.mediaInfo.requests
.filter((request) => request.is4k === is4k)
.forEach((request) => {
if (!seasonRequest) {
seasonRequest = request.seasons.find(
(season) => season.seasonNumber === seasonNumber
);
}
});
}
return seasonRequest;
@@ -195,7 +206,10 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
backgroundClickable
onCancel={onCancel}
onOk={() => sendRequest()}
title={intl.formatMessage(messages.requesttitle, { title: data?.name })}
title={intl.formatMessage(
is4k ? messages.request4ktitle : messages.requesttitle,
{ title: data?.name }
)}
okText={
selectedSeasons.length === 0
? intl.formatMessage(messages.selectseason)
@@ -256,13 +270,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
></span>
</span>
</th>
<th className="px-1 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
<th className="px-1 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
{intl.formatMessage(messages.season)}
</th>
<th className="px-5 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
<th className="px-5 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
{intl.formatMessage(messages.numberofepisodes)}
</th>
<th className="px-2 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
<th className="px-2 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
{intl.formatMessage(messages.status)}
</th>
</tr>
@@ -275,7 +289,10 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
season.seasonNumber
);
const mediaSeason = data?.mediaInfo?.seasons.find(
(sn) => sn.seasonNumber === season.seasonNumber
(sn) =>
sn.seasonNumber === season.seasonNumber &&
sn[is4k ? 'status4k' : 'status'] !==
MediaStatus.UNKNOWN
);
return (
<tr key={`season-${season.id}`}>
@@ -320,17 +337,17 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
></span>
</span>
</td>
<td className="px-1 md:px-6 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap">
<td className="px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6 whitespace-nowrap">
{season.seasonNumber === 0
? intl.formatMessage(messages.extras)
: intl.formatMessage(messages.seasonnumber, {
number: season.seasonNumber,
})}
</td>
<td className="px-5 md:px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap">
<td className="px-5 py-4 text-sm leading-5 text-gray-200 md:px-6 whitespace-nowrap">
{season.episodeCount}
</td>
<td className="pr-2 md:px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap">
<td className="py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6 whitespace-nowrap">
{!seasonRequest && !mediaSeason && (
<Badge>
{intl.formatMessage(messages.notrequested)}
@@ -357,7 +374,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
{intl.formatMessage(globalMessages.available)}
</Badge>
)}
{mediaSeason?.status ===
{mediaSeason?.[is4k ? 'status4k' : 'status'] ===
MediaStatus.PARTIALLY_AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(
@@ -365,7 +382,8 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
)}
</Badge>
)}
{mediaSeason?.status === MediaStatus.AVAILABLE && (
{mediaSeason?.[is4k ? 'status4k' : 'status'] ===
MediaStatus.AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>

View File

@@ -8,6 +8,7 @@ interface RequestModalProps {
show: boolean;
type: 'movie' | 'tv';
tmdbId: number;
is4k?: boolean;
onComplete?: (newStatus: MediaStatus) => void;
onError?: (error: string) => void;
onCancel?: () => void;
@@ -18,6 +19,7 @@ const RequestModal: React.FC<RequestModalProps> = ({
type,
show,
tmdbId,
is4k,
onComplete,
onUpdating,
onCancel,
@@ -38,6 +40,7 @@ const RequestModal: React.FC<RequestModalProps> = ({
onCancel={onCancel}
tmdbId={tmdbId}
onUpdating={onUpdating}
is4k={is4k}
/>
</Transition>
);
@@ -58,6 +61,7 @@ const RequestModal: React.FC<RequestModalProps> = ({
onCancel={onCancel}
tmdbId={tmdbId}
onUpdating={onUpdating}
is4k={is4k}
/>
</Transition>
);

View File

@@ -89,6 +89,30 @@ const SettingsMain: React.FC = () => {
description: intl.formatMessage(permissionMessages.requestDescription),
permission: Permission.REQUEST,
},
{
id: 'request4k',
name: intl.formatMessage(permissionMessages.request4k),
description: intl.formatMessage(permissionMessages.request4kDescription),
permission: Permission.REQUEST_4K,
children: [
{
id: 'request4k-movies',
name: intl.formatMessage(permissionMessages.request4kMovies),
description: intl.formatMessage(
permissionMessages.request4kMoviesDescription
),
permission: Permission.REQUEST_4K_MOVIE,
},
{
id: 'request4k-tv',
name: intl.formatMessage(permissionMessages.request4kTv),
description: intl.formatMessage(
permissionMessages.request4kTvDescription
),
permission: Permission.REQUEST_4K_TV,
},
],
},
{
id: 'autoapprove',
name: intl.formatMessage(permissionMessages.autoapprove),

View File

@@ -35,7 +35,6 @@ const messages = defineMessages({
nodefault: 'No default server selected!',
nodefaultdescription:
'At least one server must be marked as default before any requests will make it to your services.',
no4kimplemented: '(Default 4K servers are not currently implemented)',
});
interface ServerInstanceProps {
@@ -63,10 +62,10 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
}) => {
return (
<li className="col-span-1 bg-gray-700 rounded-lg shadow">
<div className="w-full flex items-center justify-between p-6 space-x-6">
<div className="flex items-center justify-between w-full p-6 space-x-6">
<div className="flex-1 truncate">
<div className="flex items-center space-x-3 mb-2">
<h3 className="text-white text-sm leading-5 font-medium truncate">
<div className="flex items-center mb-2 space-x-3">
<h3 className="text-sm font-medium leading-5 text-white truncate">
{name}
</h3>
{isDefault && (
@@ -85,31 +84,31 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
</Badge>
)}
</div>
<p className="mt-1 text-gray-300 text-sm leading-5 truncate">
<span className="font-bold mr-2">
<p className="mt-1 text-sm leading-5 text-gray-300 truncate">
<span className="mr-2 font-bold">
<FormattedMessage {...messages.address} />
</span>
{address}
</p>
<p className="mt-1 text-gray-300 text-sm leading-5 truncate">
<span className="font-bold mr-2">
<p className="mt-1 text-sm leading-5 text-gray-300 truncate">
<span className="mr-2 font-bold">
<FormattedMessage {...messages.activeProfile} />
</span>{' '}
{profileName}
</p>
</div>
<img
className="w-10 h-10 flex-shrink-0"
className="flex-shrink-0 w-10 h-10"
src={`/images/${isSonarr ? 'sonarr' : 'radarr'}_logo.png`}
alt=""
/>
</div>
<div className="border-t border-gray-800">
<div className="-mt-px flex">
<div className="w-0 flex-1 flex border-r border-gray-800">
<div className="flex -mt-px">
<div className="flex flex-1 w-0 border-r border-gray-800">
<button
onClick={() => onEdit()}
className="relative -mr-px w-0 flex-1 inline-flex items-center justify-center py-4 text-sm leading-5 text-gray-200 font-medium border border-transparent rounded-bl-lg hover:text-white focus:outline-none focus:ring-blue focus:border-gray-500 focus:z-10 transition ease-in-out duration-150"
className="relative inline-flex items-center justify-center flex-1 w-0 py-4 -mr-px text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out border border-transparent rounded-bl-lg hover:text-white focus:outline-none focus:ring-blue focus:border-gray-500 focus:z-10"
>
<svg
className="w-5 h-5"
@@ -124,10 +123,10 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
</span>
</button>
</div>
<div className="-ml-px w-0 flex-1 flex">
<div className="flex flex-1 w-0 -ml-px">
<button
onClick={() => onDelete()}
className="relative w-0 flex-1 inline-flex items-center justify-center py-4 text-sm leading-5 text-gray-200 font-medium border border-transparent rounded-br-lg hover:text-white focus:outline-none focus:ring-blue focus:border-gray-500 focus:z-10 transition ease-in-out duration-150"
className="relative inline-flex items-center justify-center flex-1 w-0 py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out border border-transparent rounded-br-lg hover:text-white focus:outline-none focus:ring-blue focus:border-gray-500 focus:z-10"
>
<svg
className="w-5 h-5"
@@ -200,10 +199,10 @@ const SettingsServices: React.FC = () => {
return (
<>
<div>
<h3 className="text-lg leading-6 font-medium text-gray-200">
<h3 className="text-lg font-medium leading-6 text-gray-200">
<FormattedMessage {...messages.radarrsettings} />
</h3>
<p className="mt-1 max-w-2xl text-sm leading-5 text-gray-500">
<p className="max-w-2xl mt-1 text-sm leading-5 text-gray-500">
<FormattedMessage {...messages.radarrSettingsDescription} />
</p>
</div>
@@ -262,9 +261,6 @@ const SettingsServices: React.FC = () => {
) && (
<Alert title={intl.formatMessage(messages.nodefault)}>
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
<p className="mt-2">
{intl.formatMessage(messages.no4kimplemented)}
</p>
</Alert>
)}
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
@@ -287,7 +283,7 @@ const SettingsServices: React.FC = () => {
}
/>
))}
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
<li className="h-32 col-span-1 border-2 border-gray-400 border-dashed rounded-lg shadow sm:h-32">
<div className="flex items-center justify-center w-full h-full">
<Button
buttonType="ghost"
@@ -316,10 +312,10 @@ const SettingsServices: React.FC = () => {
)}
</div>
<div className="mt-10">
<h3 className="text-lg leading-6 font-medium text-gray-200">
<h3 className="text-lg font-medium leading-6 text-gray-200">
<FormattedMessage {...messages.sonarrsettings} />
</h3>
<p className="mt-1 max-w-2xl text-sm leading-5 text-gray-500">
<p className="max-w-2xl mt-1 text-sm leading-5 text-gray-500">
<FormattedMessage {...messages.sonarrSettingsDescription} />
</p>
</div>
@@ -333,9 +329,6 @@ const SettingsServices: React.FC = () => {
) && (
<Alert title={intl.formatMessage(messages.nodefault)}>
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
<p className="mt-2">
{intl.formatMessage(messages.no4kimplemented)}
</p>
</Alert>
)}
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
@@ -359,7 +352,7 @@ const SettingsServices: React.FC = () => {
}
/>
))}
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
<li className="h-32 col-span-1 border-2 border-gray-400 border-dashed rounded-lg shadow sm:h-32">
<div className="flex items-center justify-center w-full h-full">
<Button
buttonType="ghost"

View File

@@ -1,16 +1,60 @@
import React from 'react';
import { MediaStatus } from '../../../server/constants/media';
import Badge from '../Common/Badge';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import globalMessages from '../../i18n/globalMessages';
const messages = defineMessages({
status4k: '4K {status}',
});
interface StatusBadgeProps {
status?: MediaStatus;
is4k?: boolean;
}
const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
const StatusBadge: React.FC<StatusBadgeProps> = ({ status, is4k }) => {
const intl = useIntl();
if (is4k) {
switch (status) {
case MediaStatus.AVAILABLE:
return (
<Badge badgeType="success">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.available),
})}
</Badge>
);
case MediaStatus.PARTIALLY_AVAILABLE:
return (
<Badge badgeType="success">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.partiallyavailable),
})}
</Badge>
);
case MediaStatus.PROCESSING:
return (
<Badge badgeType="primary">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.requested),
})}
</Badge>
);
case MediaStatus.PENDING:
return (
<Badge badgeType="warning">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.pending),
})}
</Badge>
);
default:
return null;
}
}
switch (status) {
case MediaStatus.AVAILABLE:
return (

View File

@@ -19,7 +19,6 @@ import { useUser, Permission } from '../../hooks/useUser';
import { TvDetails as TvDetailsType } from '../../../server/models/Tv';
import { MediaStatus } from '../../../server/constants/media';
import RequestModal from '../RequestModal';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import axios from 'axios';
import SlideOver from '../Common/SlideOver';
import RequestBlock from '../RequestBlock';
@@ -36,6 +35,7 @@ import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers';
import { Crew } from '../../../server/models/common';
import StatusBadge from '../StatusBadge';
import RequestButton from '../MovieDetails/RequestButton';
const messages = defineMessages({
firstAirDate: 'First Air Date',
@@ -50,14 +50,8 @@ const messages = defineMessages({
watchtrailer: 'Watch Trailer',
available: 'Available',
unavailable: 'Unavailable',
request: 'Request',
requestmore: 'Request More',
pending: 'Pending',
overviewunavailable: 'Overview unavailable',
approverequests:
'Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}',
declinerequests:
'Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}',
manageModalTitle: 'Manage Series',
manageModalRequests: 'Requests',
manageModalNoRequests: 'No Requests',
@@ -83,13 +77,6 @@ interface SearchResult {
results: TvResult[];
}
enum MediaRequestStatus {
PENDING = 1,
APPROVED,
DECLINED,
AVAILABLE,
}
const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
const { hasPermission } = useUser();
const router = useRouter();
@@ -126,29 +113,11 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
return <Error statusCode={404} />;
}
const activeRequests = data.mediaInfo?.requests?.filter(
(request) => request.status === MediaRequestStatus.PENDING
);
const trailerUrl = data.relatedVideos
?.filter((r) => r.type === 'Trailer')
.sort((a, b) => a.size - b.size)
.pop()?.url;
const modifyRequests = async (type: 'approve' | 'decline'): Promise<void> => {
if (!activeRequests) {
return;
}
await Promise.all(
activeRequests.map(async (request) => {
return axios.get(`/api/v1/request/${request.id}/${type}`);
})
);
revalidate();
};
const deleteMedia = async () => {
if (data?.mediaInfo?.id) {
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
@@ -164,6 +133,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
) ?? []
).length;
const is4kComplete =
data.seasons.filter((season) => season.seasonNumber !== 0).length <=
(
data.mediaInfo?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
return (
<div
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover"
@@ -236,7 +213,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</div>
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
<div className="mb-2">
<StatusBadge status={data.mediaInfo?.status} />
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
<span className="mr-2">
<StatusBadge status={data.mediaInfo?.status} />
</span>
)}
<span>
<StatusBadge status={data.mediaInfo?.status4k} is4k />
</span>
</div>
<h1 className="text-2xl lg:text-4xl">
<span>{data.name}</span>
@@ -278,116 +262,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</Button>
</a>
)}
{(!data.mediaInfo ||
data.mediaInfo.status === MediaStatus.UNKNOWN) && (
<Button
className="ml-2"
buttonType="primary"
onClick={() => setShowRequestModal(true)}
>
<svg
className="w-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<FormattedMessage {...messages.request} />
</Button>
)}
{data.mediaInfo &&
data.mediaInfo.status !== MediaStatus.UNKNOWN &&
!isComplete && (
<ButtonWithDropdown
dropdownIcon={
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
clipRule="evenodd"
/>
</svg>
}
text={
<>
<svg
className="w-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage {...messages.requestmore} />
</>
}
className="ml-2"
onClick={() => setShowRequestModal(true)}
>
{hasPermission(Permission.MANAGE_REQUESTS) &&
activeRequests &&
activeRequests.length > 0 && (
<>
<ButtonWithDropdown.Item
onClick={() => modifyRequests('approve')}
>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage
{...messages.approverequests}
values={{ requestCount: activeRequests.length }}
/>
</ButtonWithDropdown.Item>
<ButtonWithDropdown.Item
onClick={() => modifyRequests('decline')}
>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage
{...messages.declinerequests}
values={{ requestCount: activeRequests.length }}
/>
</ButtonWithDropdown.Item>
</>
)}
</ButtonWithDropdown>
)}
<RequestButton
mediaType="tv"
onUpdate={() => revalidate()}
tmdbId={data?.id}
media={data?.mediaInfo}
isShowComplete={isComplete}
is4kShowComplete={is4kComplete}
/>
{hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="default"

View File

@@ -41,6 +41,12 @@ export const messages = defineMessages({
autoapproveSeries: 'Auto Approve Series',
autoapproveSeriesDescription:
'Grants auto approve for series requests made by this user.',
request4k: 'Request 4K',
request4kDescription: 'Grants permission to request 4K movies and series.',
request4kMovies: 'Request 4K Movies',
request4kMoviesDescription: 'Grants permission to request 4K movies.',
request4kTv: 'Request 4K Series',
request4kTvDescription: 'Grants permission to request 4K Series.',
save: 'Save',
saving: 'Saving...',
usersaved: 'User saved',
@@ -127,6 +133,26 @@ const UserEdit: React.FC = () => {
description: intl.formatMessage(messages.requestDescription),
permission: Permission.REQUEST,
},
{
id: 'request4k',
name: intl.formatMessage(messages.request4k),
description: intl.formatMessage(messages.request4kDescription),
permission: Permission.REQUEST_4K,
children: [
{
id: 'request4k-movies',
name: intl.formatMessage(messages.request4kMovies),
description: intl.formatMessage(messages.request4kMoviesDescription),
permission: Permission.REQUEST_4K_MOVIE,
},
{
id: 'request4k-tv',
name: intl.formatMessage(messages.request4kTv),
description: intl.formatMessage(messages.request4kTvDescription),
permission: Permission.REQUEST_4K_TV,
},
],
},
{
id: 'autoapprove',
name: intl.formatMessage(messages.autoapprove),

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces';
import useSWR from 'swr';
interface SettingsContextProps {
currentSettings: PublicSettingsResponse;
}
const defaultSettings = {
initialized: false,
movie4kEnabled: false,
series4kEnabled: false,
};
export const SettingsContext = React.createContext<SettingsContextProps>({
currentSettings: defaultSettings,
});
export const SettingsProvider: React.FC<SettingsContextProps> = ({
children,
currentSettings,
}) => {
const { data, error } = useSWR<PublicSettingsResponse>(
'/api/v1/settings/public',
{ initialData: currentSettings }
);
let newSettings = defaultSettings;
if (data && !error) {
newSettings = data;
}
return (
<SettingsContext.Provider value={{ currentSettings: newSettings }}>
{children}
</SettingsContext.Provider>
);
};

View File

@@ -29,6 +29,20 @@
"components.Login.signinplex": "Sign in to continue",
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
"components.MovieDetails.MovieCrew.fullcrew": "Full Crew",
"components.MovieDetails.RequestButton.approve4krequests": "Approve {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}",
"components.MovieDetails.RequestButton.approverequest": "Approve Request",
"components.MovieDetails.RequestButton.approverequest4k": "Approve 4K Request",
"components.MovieDetails.RequestButton.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.MovieDetails.RequestButton.decline4krequests": "Decline {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}",
"components.MovieDetails.RequestButton.declinerequest": "Decline Request",
"components.MovieDetails.RequestButton.declinerequest4k": "Decline 4K Request",
"components.MovieDetails.RequestButton.declinerequests": "Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.MovieDetails.RequestButton.request": "Request",
"components.MovieDetails.RequestButton.request4k": "Request 4K",
"components.MovieDetails.RequestButton.requestmore": "Request More",
"components.MovieDetails.RequestButton.requestmore4k": "Request More 4K",
"components.MovieDetails.RequestButton.viewrequest": "View Request",
"components.MovieDetails.RequestButton.viewrequest4k": "View 4K Request",
"components.MovieDetails.approve": "Approve",
"components.MovieDetails.available": "Available",
"components.MovieDetails.budget": "Budget",
@@ -47,7 +61,6 @@
"components.MovieDetails.recommendations": "Recommendations",
"components.MovieDetails.recommendationssubtext": "If you liked {title}, you might also like…",
"components.MovieDetails.releasedate": "Release Date",
"components.MovieDetails.request": "Request",
"components.MovieDetails.revenue": "Revenue",
"components.MovieDetails.runtime": "{minutes} minutes",
"components.MovieDetails.similar": "Similar Titles",
@@ -58,7 +71,6 @@
"components.MovieDetails.userrating": "User Rating",
"components.MovieDetails.view": "View",
"components.MovieDetails.viewfullcrew": "View Full Crew",
"components.MovieDetails.viewrequest": "View Request",
"components.MovieDetails.watchtrailer": "Watch Trailer",
"components.NotificationTypeSelector.mediaapproved": "Media Approved",
"components.NotificationTypeSelector.mediaapprovedDescription": "Sends a notification when media is approved.",
@@ -105,8 +117,12 @@
"components.RequestModal.extras": "Extras",
"components.RequestModal.notrequested": "Not Requested",
"components.RequestModal.numberofepisodes": "# of Episodes",
"components.RequestModal.pending4krequest": "Pending request for {title} in 4K",
"components.RequestModal.pendingrequest": "Pending request for {title}",
"components.RequestModal.request": "Request",
"components.RequestModal.request4k": "Request 4K",
"components.RequestModal.request4kfrom": "There is currently a pending 4K request from {username}",
"components.RequestModal.request4ktitle": "Request {title} in 4K",
"components.RequestModal.requestCancel": "Request for <strong>{title}</strong> cancelled",
"components.RequestModal.requestSuccess": "<strong>{title}</strong> requested.",
"components.RequestModal.requestadmin": "Your request will be immediately approved.",
@@ -303,7 +319,6 @@
"components.Settings.menuPlexSettings": "Plex",
"components.Settings.menuServices": "Services",
"components.Settings.nextexecution": "Next Execution",
"components.Settings.no4kimplemented": "(Default 4K servers are not currently implemented)",
"components.Settings.nodefault": "No default server selected!",
"components.Settings.nodefaultdescription": "At least one server must be marked as default before any requests will make it to your services.",
"components.Settings.notificationsettings": "Notification Settings",
@@ -344,6 +359,7 @@
"components.Setup.tip": "Tip",
"components.Setup.welcome": "Welcome to Overseerr",
"components.Slider.noresults": "No Results",
"components.StatusBadge.status4k": "4K {status}",
"components.StatusChacker.newversionDescription": "An update is now available. Click the button below to reload the application.",
"components.StatusChacker.newversionavailable": "New Version Available",
"components.StatusChacker.reloadOverseerr": "Reload Overseerr",
@@ -353,12 +369,10 @@
"components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew",
"components.TvDetails.anime": "Anime",
"components.TvDetails.approve": "Approve",
"components.TvDetails.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.TvDetails.available": "Available",
"components.TvDetails.cancelrequest": "Cancel Request",
"components.TvDetails.cast": "Cast",
"components.TvDetails.decline": "Decline",
"components.TvDetails.declinerequests": "Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.TvDetails.firstAirDate": "First Air Date",
"components.TvDetails.manageModalClearMedia": "Clear All Media Data",
"components.TvDetails.manageModalClearMediaWarning": "This will remove all media data including all requests for this item, irreversibly. If this item exists in your Plex library, the media info will be recreated next sync.",
@@ -372,8 +386,6 @@
"components.TvDetails.pending": "Pending",
"components.TvDetails.recommendations": "Recommendations",
"components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like…",
"components.TvDetails.request": "Request",
"components.TvDetails.requestmore": "Request More",
"components.TvDetails.showtype": "Show Type",
"components.TvDetails.similar": "Similar Series",
"components.TvDetails.similarsubtext": "Other series similar to {title}",
@@ -397,6 +409,12 @@
"components.UserEdit.managerequestsDescription": "Grants permission to manage Overseerr requests. This includes approving and denying requests.",
"components.UserEdit.permissions": "Permissions",
"components.UserEdit.request": "Request",
"components.UserEdit.request4k": "Request 4K",
"components.UserEdit.request4kDescription": "Grants permission to request 4K movies and series.",
"components.UserEdit.request4kMovies": "Request 4K Movies",
"components.UserEdit.request4kMoviesDescription": "Grants permission to request 4K movies.",
"components.UserEdit.request4kTv": "Request 4K Series",
"components.UserEdit.request4kTvDescription": "Grants permission to request 4K Series.",
"components.UserEdit.requestDescription": "Grants permission to request movies and series.",
"components.UserEdit.save": "Save",
"components.UserEdit.saving": "Saving…",

View File

@@ -14,6 +14,8 @@ import Head from 'next/head';
import Toast from '../components/Toast';
import { InteractionProvider } from '../context/InteractionContext';
import StatusChecker from '../components/StatusChacker';
import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces';
import { SettingsProvider } from '../context/SettingsContext';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadLocaleData = (locale: string): Promise<any> => {
@@ -55,6 +57,7 @@ interface ExtendedAppProps extends AppProps {
user: User;
messages: MessagesType;
locale: AvailableLocales;
currentSettings: PublicSettingsResponse;
}
if (typeof window === 'undefined') {
@@ -68,6 +71,7 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
user,
messages,
locale,
currentSettings,
}: ExtendedAppProps) => {
let component: React.ReactNode;
const [loadedMessages, setMessages] = useState<MessagesType>(messages);
@@ -103,15 +107,17 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
defaultLocale="en"
messages={loadedMessages}
>
<InteractionProvider>
<ToastProvider components={{ Toast }}>
<Head>
<title>Overseerr</title>
</Head>
<StatusChecker />
<UserContext initialUser={user}>{component}</UserContext>
</ToastProvider>
</InteractionProvider>
<SettingsProvider currentSettings={currentSettings}>
<InteractionProvider>
<ToastProvider components={{ Toast }}>
<Head>
<title>Overseerr</title>
</Head>
<StatusChecker />
<UserContext initialUser={user}>{component}</UserContext>
</ToastProvider>
</InteractionProvider>
</SettingsProvider>
</IntlProvider>
</LanguageContext.Provider>
</SWRConfig>
@@ -121,15 +127,22 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
CoreApp.getInitialProps = async (initialProps) => {
const { ctx, router } = initialProps;
let user = undefined;
let currentSettings: PublicSettingsResponse = {
initialized: false,
movie4kEnabled: false,
series4kEnabled: false,
};
let locale = 'en';
if (ctx.res) {
// Check if app is initialized and redirect if necessary
const response = await axios.get<{ initialized: boolean }>(
const response = await axios.get<PublicSettingsResponse>(
`http://localhost:${process.env.PORT || 5055}/api/v1/settings/public`
);
currentSettings = response.data;
const initialized = response.data.initialized;
if (!initialized) {
@@ -181,7 +194,7 @@ CoreApp.getInitialProps = async (initialProps) => {
const messages = await loadLocaleData(locale);
return { ...appInitialProps, user, messages, locale };
return { ...appInitialProps, user, messages, locale, currentSettings };
};
export default CoreApp;