feat: Radarr & Sonarr Sync (#734)

This commit is contained in:
sct
2021-01-27 23:52:37 +09:00
committed by GitHub
parent 86efcd82c3
commit ec5fb83678
32 changed files with 2394 additions and 425 deletions

21
src/assets/spinner.svg Normal file
View File

@@ -0,0 +1,21 @@
<svg
viewBox="-2 -2 42 42"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<g fill="none" fillRule="evenodd">
<g transform="translate(1 1)" stroke-width="6">
<circle stroke-opacity=".5" cx="18" cy="18" r="18" />
<path d="M36 18c0-9.94-8.06-18-18-18">
<animateTransform
attributeName="transform"
type="rotate"
from="0 18 18"
to="360 18 18"
dur="1s"
repeatCount="indefinite"
/>
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 524 B

View File

@@ -2,11 +2,16 @@ import React from 'react';
interface BadgeProps {
badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
className?: string;
}
const Badge: React.FC<BadgeProps> = ({ badgeType = 'default', children }) => {
const Badge: React.FC<BadgeProps> = ({
badgeType = 'default',
className,
children,
}) => {
const badgeStyle = [
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full',
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full cursor-default',
];
switch (badgeType) {
@@ -23,6 +28,10 @@ const Badge: React.FC<BadgeProps> = ({ badgeType = 'default', children }) => {
badgeStyle.push('bg-indigo-500 text-indigo-100');
}
if (className) {
badgeStyle.push(className);
}
return <span className={badgeStyle.join(' ')}>{children}</span>;
};

View File

@@ -0,0 +1,57 @@
import React, { useRef, useState } from 'react';
import useClickOutside from '../../../hooks/useClickOutside';
import Button from '../Button';
interface ConfirmButtonProps {
onClick: () => void;
confirmText: React.ReactNode;
className?: string;
}
const ConfirmButton: React.FC<ConfirmButtonProps> = ({
onClick,
children,
confirmText,
className,
}) => {
const ref = useRef(null);
useClickOutside(ref, () => setIsClicked(false));
const [isClicked, setIsClicked] = useState(false);
return (
<Button
buttonType="danger"
className={`relative overflow-hidden ${className}`}
onClick={(e) => {
e.preventDefault();
if (!isClicked) {
setIsClicked(true);
} else {
onClick();
}
}}
>
&nbsp;
<div
ref={ref}
className={`absolute flex items-center justify-center inset-0 w-full h-full duration-300 transition transform-gpu ${
isClicked
? '-translate-y-full opacity-0'
: 'translate-y-0 opacity-100'
}`}
>
{children}
</div>
<div
ref={ref}
className={`absolute flex items-center justify-center inset-0 w-full h-full duration-300 transition transform-gpu ${
isClicked ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0'
}`}
>
{confirmText}
</div>
</Button>
);
};
export default ConfirmButton;

View File

@@ -53,6 +53,9 @@ const ListView: React.FC<ListViewProps> = ({
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
inProgress={
(title.mediaInfo?.downloadStatus ?? []).length > 0
}
canExpand
/>
);
@@ -68,6 +71,9 @@ const ListView: React.FC<ListViewProps> = ({
userScore={title.voteAverage}
year={title.firstAirDate}
mediaType={title.mediaType}
inProgress={
(title.mediaInfo?.downloadStatus ?? []).length > 0
}
canExpand
/>
);
@@ -87,7 +93,7 @@ const ListView: React.FC<ListViewProps> = ({
return (
<li
key={title.id}
className="col-span-1 flex flex-col text-center items-center"
className="flex flex-col items-center col-span-1 text-center"
>
{titleCard}
</li>
@@ -98,7 +104,7 @@ const ListView: React.FC<ListViewProps> = ({
[...Array(20)].map((_item, i) => (
<li
key={`placeholder-${i}`}
className="col-span-1 flex flex-col text-center items-center"
className="flex flex-col items-center col-span-1 text-center"
>
<TitleCard.Placeholder canExpand />
</li>

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { FormattedRelativeTime } from 'react-intl';
import { DownloadingItem } from '../../../server/lib/downloadtracker';
import Badge from '../Common/Badge';
interface DownloadBlockProps {
downloadItem: DownloadingItem;
}
const DownloadBlock: React.FC<DownloadBlockProps> = ({ downloadItem }) => {
return (
<div className="p-4">
<div className="w-56 mb-2 text-sm truncate sm:w-80 md:w-full">
{downloadItem.title}
</div>
<div className="relative h-6 min-w-0 mb-2 overflow-hidden bg-gray-700 rounded-full">
<div
className="h-8 transition-all duration-200 ease-in-out bg-indigo-600"
style={{
width: `${Math.round(
((downloadItem.size - downloadItem.sizeLeft) /
downloadItem.size) *
100
)}%`,
}}
/>
<div className="absolute inset-0 flex items-center justify-center w-full h-6 text-xs">
<span>
{Math.round(
((downloadItem.size - downloadItem.sizeLeft) /
downloadItem.size) *
100
)}
%
</span>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<Badge className="capitalize">{downloadItem.status}</Badge>
<span>
ETA{' '}
{downloadItem.estimatedCompletionTime ? (
<FormattedRelativeTime
value={Math.floor(
(new Date(downloadItem.estimatedCompletionTime).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
/>
) : (
'N/A'
)}
</span>
</div>
</div>
);
};
export default DownloadBlock;

View File

@@ -92,6 +92,7 @@ const MediaSlider: React.FC<MediaSliderProps> = ({
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0}
/>
);
case 'tv':
@@ -105,6 +106,7 @@ const MediaSlider: React.FC<MediaSliderProps> = ({
userScore={title.voteAverage}
year={title.firstAirDate}
mediaType={title.mediaType}
inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0}
/>
);
case 'person':

View File

@@ -33,6 +33,8 @@ import { sortCrewPriority } from '../../utils/creditHelpers';
import StatusBadge from '../StatusBadge';
import RequestButton from '../RequestButton';
import MediaSlider from '../MediaSlider';
import ConfirmButton from '../Common/ConfirmButton';
import DownloadBlock from '../DownloadBlock';
const messages = defineMessages({
releasedate: 'Release Date',
@@ -63,6 +65,10 @@ const messages = defineMessages({
studio: 'Studio',
viewfullcrew: 'View Full Crew',
view: 'View',
areyousure: 'Are you sure?',
openradarr: 'Open Movie in Radarr',
openradarr4k: 'Open Movie in 4K Radarr',
downloadstatus: 'Download Status',
});
interface MovieDetailsProps {
@@ -127,6 +133,26 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
onClose={() => setShowManager(false)}
subText={data.title}
>
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
<>
<h3 className="mb-2 text-xl">
{intl.formatMessage(messages.downloadstatus)}
</h3>
<div className="mb-6 overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.downloadStatus?.map((status, index) => (
<li
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
>
<DownloadBlock downloadItem={status} />
</li>
))}
</ul>
</div>
</>
)}
<h3 className="mb-2 text-xl">
{intl.formatMessage(messages.manageModalRequests)}
</h3>
@@ -147,15 +173,60 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
)}
</ul>
</div>
{(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && (
<div className="mt-8">
{data?.mediaInfo?.serviceUrl && (
<a
href={data?.mediaInfo?.serviceUrl}
target="_blank"
rel="noreferrer"
className="block mb-2 last:mb-0"
>
<Button buttonType="ghost" className="w-full">
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
<span>{intl.formatMessage(messages.openradarr)}</span>
</Button>
</a>
)}
{data?.mediaInfo?.serviceUrl4k && (
<a
href={data?.mediaInfo?.serviceUrl4k}
target="_blank"
rel="noreferrer"
>
<Button buttonType="ghost" className="w-full">
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
<span>{intl.formatMessage(messages.openradarr4k)}</span>
</Button>
</a>
)}
</div>
)}
{data?.mediaInfo && (
<div className="mt-8">
<Button
buttonType="danger"
className="w-full text-center"
<ConfirmButton
onClick={() => deleteMedia()}
confirmText={intl.formatMessage(messages.areyousure)}
className="w-full"
>
{intl.formatMessage(messages.manageModalClearMedia)}
</Button>
</ConfirmButton>
<div className="mt-2 text-sm text-gray-400">
{intl.formatMessage(messages.manageModalClearMediaWarning)}
</div>
@@ -178,11 +249,18 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<div className="mb-2">
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
<span className="mr-2">
<StatusBadge status={data.mediaInfo?.status} />
<StatusBadge
status={data.mediaInfo?.status}
inProgress={(data.mediaInfo.downloadStatus ?? []).length > 0}
/>
</span>
)}
<span>
<StatusBadge status={data.mediaInfo?.status4k} is4k />
<StatusBadge
status={data.mediaInfo?.status4k}
is4k
inProgress={(data.mediaInfo?.downloadStatus4k ?? []).length > 0}
/>
</span>
</div>
<h1 className="text-2xl lg:text-4xl">

View File

@@ -120,6 +120,13 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
: requestData.media.status
}
is4k={requestData.is4k}
inProgress={
(
requestData.media[
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ?? []
).length > 0
}
/>
</div>
)}

View File

@@ -192,7 +192,16 @@ const RequestItem: React.FC<RequestItemProps> = ({
: intl.formatMessage(globalMessages.failed)}
</Badge>
) : (
<StatusBadge status={requestData.media.status} />
<StatusBadge
status={requestData.media.status}
inProgress={
(
requestData.media[
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ?? []
).length > 0
}
/>
)}
</Table.TD>
<Table.TD>

View File

@@ -210,7 +210,8 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
(season) =>
(season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
season[is4k ? 'status4k' : 'status'] ===
MediaStatus.PARTIALLY_AVAILABLE) &&
MediaStatus.PARTIALLY_AVAILABLE ||
season[is4k ? 'status4k' : 'status'] === MediaStatus.PROCESSING) &&
!requestedSeasons.includes(season.seasonNumber)
)
.map((season) => season.seasonNumber);
@@ -509,13 +510,15 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
{intl.formatMessage(globalMessages.pending)}
</Badge>
)}
{!mediaSeason &&
{((!mediaSeason &&
seasonRequest?.status ===
MediaRequestStatus.APPROVED && (
<Badge badgeType="primary">
{intl.formatMessage(globalMessages.requested)}
</Badge>
)}
MediaRequestStatus.APPROVED) ||
mediaSeason?.[is4k ? 'status4k' : 'status'] ===
MediaStatus.PROCESSING) && (
<Badge badgeType="primary">
{intl.formatMessage(globalMessages.requested)}
</Badge>
)}
{!mediaSeason &&
seasonRequest?.status ===
MediaRequestStatus.AVAILABLE && (

View File

@@ -35,6 +35,9 @@ const messages = defineMessages({
apiKeyPlaceholder: 'Your Radarr API key',
baseUrl: 'Base URL',
baseUrlPlaceholder: 'Example: /radarr',
syncEnabled: 'Enable Sync',
externalUrl: 'External URL',
externalUrlPlaceholder: 'External URL pointing to your Radarr server',
qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder',
minimumAvailability: 'Minimum Availability',
@@ -46,6 +49,7 @@ const messages = defineMessages({
testFirstQualityProfiles: 'Test connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
testFirstRootFolders: 'Test connection to load root folders',
preventSearch: 'Disable Auto-Search',
});
interface TestResponse {
@@ -188,6 +192,9 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
minimumAvailability: radarr?.minimumAvailability ?? 'released',
isDefault: radarr?.isDefault ?? false,
is4k: radarr?.is4k ?? false,
externalUrl: radarr?.externalUrl,
syncEnabled: radarr?.syncEnabled,
preventSearch: radarr?.preventSearch,
}}
validationSchema={RadarrSettingsSchema}
onSubmit={async (values) => {
@@ -209,6 +216,9 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
is4k: values.is4k,
minimumAvailability: values.minimumAvailability,
isDefault: values.isDefault,
externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled,
preventSearch: values.preventSearch,
};
if (!radarr) {
await axios.post('/api/v1/settings/radarr', submission);
@@ -290,6 +300,22 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="is4k"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.server4k)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="is4k"
name="is4k"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="name"
@@ -567,18 +593,60 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="is4k"
htmlFor="externalUrl"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.server4k)}
{intl.formatMessage(messages.externalUrl)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="externalUrl"
name="externalUrl"
type="text"
placeholder={intl.formatMessage(
messages.externalUrlPlaceholder
)}
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
{errors.externalUrl && touched.externalUrl && (
<div className="mt-2 text-red-500">
{errors.externalUrl}
</div>
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="syncEnabled"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.syncEnabled)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="is4k"
name="is4k"
id="syncEnabled"
name="syncEnabled"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="preventSearch"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.preventSearch)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="preventSearch"
name="preventSearch"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
</div>

View File

@@ -4,35 +4,87 @@ import LoadingSpinner from '../Common/LoadingSpinner';
import { FormattedRelativeTime, defineMessages, useIntl } from 'react-intl';
import Button from '../Common/Button';
import Table from '../Common/Table';
import Spinner from '../../assets/spinner.svg';
import axios from 'axios';
import { useToasts } from 'react-toast-notifications';
import Badge from '../Common/Badge';
const messages = defineMessages({
jobname: 'Job Name',
jobtype: 'Type',
nextexecution: 'Next Execution',
runnow: 'Run Now',
canceljob: 'Cancel Job',
jobstarted: '{jobname} started.',
jobcancelled: '{jobname} cancelled.',
});
interface Job {
id: string;
name: string;
type: 'process' | 'command';
nextExecutionTime: string;
running: boolean;
}
const SettingsJobs: React.FC = () => {
const intl = useIntl();
const { data, error } = useSWR<{ name: string; nextExecutionTime: string }[]>(
'/api/v1/settings/jobs'
);
const { addToast } = useToasts();
const { data, error, revalidate } = useSWR<Job[]>('/api/v1/settings/jobs', {
refreshInterval: 5000,
});
if (!data && !error) {
return <LoadingSpinner />;
}
const runJob = async (job: Job) => {
await axios.get(`/api/v1/settings/jobs/${job.id}/run`);
addToast(
intl.formatMessage(messages.jobstarted, {
jobname: job.name,
}),
{
appearance: 'success',
autoDismiss: true,
}
);
revalidate();
};
const cancelJob = async (job: Job) => {
await axios.get(`/api/v1/settings/jobs/${job.id}/cancel`);
addToast(intl.formatMessage(messages.jobcancelled, { jobname: job.name }), {
appearance: 'error',
autoDismiss: true,
});
revalidate();
};
return (
<Table>
<thead>
<Table.TH>{intl.formatMessage(messages.jobname)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.jobname)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.nextexecution)}</Table.TH>
<Table.TH></Table.TH>
</thead>
<Table.TBody>
{data?.map((job, index) => (
<tr key={`job-list-${index}`}>
{data?.map((job) => (
<tr key={`job-list-${job.id}`}>
<Table.TD>
<div className="text-sm leading-5 text-white">{job.name}</div>
<div className="flex items-center text-sm leading-5 text-white">
{job.running && <Spinner className="w-5 h-5 mr-2" />}
<span>{job.name}</span>
</div>
</Table.TD>
<Table.TD>
<Badge
badgeType={job.type === 'process' ? 'primary' : 'warning'}
className="uppercase"
>
{job.type}
</Badge>
</Table.TD>
<Table.TD>
<div className="text-sm leading-5 text-white">
@@ -46,9 +98,15 @@ const SettingsJobs: React.FC = () => {
</div>
</Table.TD>
<Table.TD alignText="right">
<Button buttonType="primary" disabled>
{intl.formatMessage(messages.runnow)}
</Button>
{job.running ? (
<Button buttonType="danger" onClick={() => cancelJob(job)}>
{intl.formatMessage(messages.canceljob)}
</Button>
) : (
<Button buttonType="primary" onClick={() => runJob(job)}>
{intl.formatMessage(messages.runnow)}
</Button>
)}
</Table.TD>
</tr>
))}

View File

@@ -46,6 +46,10 @@ const messages = defineMessages({
testFirstQualityProfiles: 'Test connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
testFirstRootFolders: 'Test connection to load root folders',
syncEnabled: 'Enable Sync',
externalUrl: 'External URL',
externalUrlPlaceholder: 'External URL pointing to your Sonarr server',
preventSearch: 'Disable Auto-Search',
});
interface TestResponse {
@@ -189,6 +193,9 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
isDefault: sonarr?.isDefault ?? false,
is4k: sonarr?.is4k ?? false,
enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
externalUrl: sonarr?.externalUrl,
syncEnabled: sonarr?.syncEnabled ?? false,
preventSearch: sonarr?.preventSearch ?? false,
}}
validationSchema={SonarrSettingsSchema}
onSubmit={async (values) => {
@@ -218,6 +225,9 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
is4k: values.is4k,
isDefault: values.isDefault,
enableSeasonFolders: values.enableSeasonFolders,
externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled,
preventSearch: values.preventSearch,
};
if (!sonarr) {
await axios.post('/api/v1/settings/sonarr', submission);
@@ -299,6 +309,22 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="is4k"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.server4k)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="is4k"
name="is4k"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="name"
@@ -632,22 +658,6 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="is4k"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.server4k)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="is4k"
name="is4k"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="enableSeasonFolders"
@@ -664,6 +674,64 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="externalUrl"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.externalUrl)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="externalUrl"
name="externalUrl"
type="text"
placeholder={intl.formatMessage(
messages.externalUrlPlaceholder
)}
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
{errors.externalUrl && touched.externalUrl && (
<div className="mt-2 text-red-500">
{errors.externalUrl}
</div>
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="syncEnabled"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.syncEnabled)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="syncEnabled"
name="syncEnabled"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="preventSearch"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.preventSearch)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="preventSearch"
name="preventSearch"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
</div>
</div>
</div>
</Modal>
);

View File

@@ -3,6 +3,7 @@ import { MediaStatus } from '../../../server/constants/media';
import Badge from '../Common/Badge';
import { defineMessages, useIntl } from 'react-intl';
import globalMessages from '../../i18n/globalMessages';
import Spinner from '../../assets/spinner.svg';
const messages = defineMessages({
status4k: '4K {status}',
@@ -11,9 +12,14 @@ const messages = defineMessages({
interface StatusBadgeProps {
status?: MediaStatus;
is4k?: boolean;
inProgress?: boolean;
}
const StatusBadge: React.FC<StatusBadgeProps> = ({ status, is4k }) => {
const StatusBadge: React.FC<StatusBadgeProps> = ({
status,
is4k = false,
inProgress = false,
}) => {
const intl = useIntl();
if (is4k) {
@@ -37,9 +43,16 @@ const StatusBadge: React.FC<StatusBadgeProps> = ({ status, is4k }) => {
case MediaStatus.PROCESSING:
return (
<Badge badgeType="primary">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.requested),
})}
<div className="flex items-center">
<span>
{intl.formatMessage(messages.status4k, {
status: inProgress
? intl.formatMessage(globalMessages.processing)
: intl.formatMessage(globalMessages.requested),
})}
</span>
{inProgress && <Spinner className="w-3 h-3 ml-1" />}
</div>
</Badge>
);
case MediaStatus.PENDING:
@@ -59,19 +72,32 @@ const StatusBadge: React.FC<StatusBadgeProps> = ({ status, is4k }) => {
case MediaStatus.AVAILABLE:
return (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
<div className="flex items-center">
<span>{intl.formatMessage(globalMessages.available)}</span>
{inProgress && <Spinner className="w-3 h-3 ml-1" />}
</div>
</Badge>
);
case MediaStatus.PARTIALLY_AVAILABLE:
return (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.partiallyavailable)}
<div className="flex items-center">
<span>{intl.formatMessage(globalMessages.partiallyavailable)}</span>
{inProgress && <Spinner className="w-3 h-3 ml-1" />}
</div>
</Badge>
);
case MediaStatus.PROCESSING:
return (
<Badge badgeType="primary">
{intl.formatMessage(globalMessages.requested)}
<div className="flex items-center">
<span>
{inProgress
? intl.formatMessage(globalMessages.processing)
: intl.formatMessage(globalMessages.requested)}
</span>
{inProgress && <Spinner className="w-3 h-3 ml-1" />}
</div>
</Badge>
);
case MediaStatus.PENDING:

View File

@@ -9,6 +9,7 @@ import RequestModal from '../RequestModal';
import { defineMessages, useIntl } from 'react-intl';
import { useIsTouch } from '../../hooks/useIsTouch';
import globalMessages from '../../i18n/globalMessages';
import Spinner from '../../assets/spinner.svg';
const messages = defineMessages({
movie: 'Movie',
@@ -25,6 +26,7 @@ interface TitleCardProps {
mediaType: MediaType;
status?: MediaStatus;
canExpand?: boolean;
inProgress?: boolean;
}
const TitleCard: React.FC<TitleCardProps> = ({
@@ -35,6 +37,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
title,
status,
mediaType,
inProgress = false,
canExpand = false,
}) => {
const isTouch = useIsTouch();
@@ -146,18 +149,22 @@ const TitleCard: React.FC<TitleCardProps> = ({
)}
{currentStatus === MediaStatus.PROCESSING && (
<div className="flex items-center justify-center w-4 h-4 text-white bg-indigo-500 rounded-full shadow sm:w-5 sm:h-5">
<svg
className="w-3 h-3 sm:w-4 sm:h-4"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
clipRule="evenodd"
/>
</svg>
{inProgress ? (
<Spinner className="w-3 h-3" />
) : (
<svg
className="w-3 h-3 sm:w-4 sm:h-4"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
clipRule="evenodd"
/>
</svg>
)}
</div>
)}
</div>

View File

@@ -35,6 +35,8 @@ import { Crew } from '../../../server/models/common';
import StatusBadge from '../StatusBadge';
import RequestButton from '../RequestButton';
import MediaSlider from '../MediaSlider';
import ConfirmButton from '../Common/ConfirmButton';
import DownloadBlock from '../DownloadBlock';
const messages = defineMessages({
firstAirDate: 'First Air Date',
@@ -63,6 +65,10 @@ const messages = defineMessages({
anime: 'Anime',
network: 'Network',
viewfullcrew: 'View Full Crew',
areyousure: 'Are you sure?',
opensonarr: 'Open Series in Sonarr',
opensonarr4k: 'Open Series in 4K Sonarr',
downloadstatus: 'Download Status',
});
interface TvDetailsProps {
@@ -154,6 +160,26 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
onClose={() => setShowManager(false)}
subText={data.name}
>
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
<>
<h3 className="mb-2 text-xl">
{intl.formatMessage(messages.downloadstatus)}
</h3>
<div className="mb-6 overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.downloadStatus?.map((status, index) => (
<li
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
>
<DownloadBlock downloadItem={status} />
</li>
))}
</ul>
</div>
</>
)}
<h3 className="mb-2 text-xl">
{intl.formatMessage(messages.manageModalRequests)}
</h3>
@@ -174,15 +200,60 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
)}
</ul>
</div>
{(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && (
<div className="mt-8">
{data?.mediaInfo?.serviceUrl && (
<a
href={data?.mediaInfo?.serviceUrl}
target="_blank"
rel="noreferrer"
className="block mb-2 last:mb-0"
>
<Button buttonType="ghost" className="w-full">
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
<span>{intl.formatMessage(messages.opensonarr)}</span>
</Button>
</a>
)}
{data?.mediaInfo?.serviceUrl4k && (
<a
href={data?.mediaInfo?.serviceUrl4k}
target="_blank"
rel="noreferrer"
>
<Button buttonType="ghost" className="w-full">
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
<span>{intl.formatMessage(messages.opensonarr4k)}</span>
</Button>
</a>
)}
</div>
)}
{data?.mediaInfo && (
<div className="mt-8">
<Button
buttonType="danger"
className="w-full text-center"
<ConfirmButton
onClick={() => deleteMedia()}
confirmText={intl.formatMessage(messages.areyousure)}
className="w-full"
>
{intl.formatMessage(messages.manageModalClearMedia)}
</Button>
</ConfirmButton>
<div className="mt-2 text-sm text-gray-400">
{intl.formatMessage(messages.manageModalClearMediaWarning)}
</div>
@@ -205,11 +276,18 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
<div className="mb-2">
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
<span className="mr-2">
<StatusBadge status={data.mediaInfo?.status} />
<StatusBadge
status={data.mediaInfo?.status}
inProgress={(data.mediaInfo.downloadStatus ?? []).length > 0}
/>
</span>
)}
<span>
<StatusBadge status={data.mediaInfo?.status4k} is4k />
<StatusBadge
status={data.mediaInfo?.status4k}
is4k
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
/>
</span>
</div>
<h1 className="text-2xl lg:text-4xl">

View File

@@ -41,16 +41,20 @@
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
"components.MovieDetails.MovieCrew.fullcrew": "Full Crew",
"components.MovieDetails.approve": "Approve",
"components.MovieDetails.areyousure": "Are you sure?",
"components.MovieDetails.available": "Available",
"components.MovieDetails.budget": "Budget",
"components.MovieDetails.cancelrequest": "Cancel Request",
"components.MovieDetails.cast": "Cast",
"components.MovieDetails.decline": "Decline",
"components.MovieDetails.downloadstatus": "Download Status",
"components.MovieDetails.manageModalClearMedia": "Clear All Media Data",
"components.MovieDetails.manageModalClearMediaWarning": "This will irreversibly remove all data for this movie, including any requests. If this item exists in your Plex library, the media information will be recreated during the next sync.",
"components.MovieDetails.manageModalNoRequests": "No Requests",
"components.MovieDetails.manageModalRequests": "Requests",
"components.MovieDetails.manageModalTitle": "Manage Movie",
"components.MovieDetails.openradarr": "Open Movie in Radarr",
"components.MovieDetails.openradarr4k": "Open Movie in 4K Radarr",
"components.MovieDetails.originallanguage": "Original Language",
"components.MovieDetails.overview": "Overview",
"components.MovieDetails.overviewunavailable": "Overview unavailable.",
@@ -284,11 +288,14 @@
"components.Settings.RadarrModal.createradarr": "Create New Radarr Server",
"components.Settings.RadarrModal.defaultserver": "Default Server",
"components.Settings.RadarrModal.editradarr": "Edit Radarr Server",
"components.Settings.RadarrModal.externalUrl": "External URL",
"components.Settings.RadarrModal.externalUrlPlaceholder": "External URL pointing to your Radarr server",
"components.Settings.RadarrModal.hostname": "Hostname",
"components.Settings.RadarrModal.loadingprofiles": "Loading quality profiles…",
"components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
"components.Settings.RadarrModal.port": "Port",
"components.Settings.RadarrModal.preventSearch": "Disable Auto-Search",
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
"components.Settings.RadarrModal.rootfolder": "Root Folder",
"components.Settings.RadarrModal.save": "Save Changes",
@@ -300,6 +307,7 @@
"components.Settings.RadarrModal.servername": "Server Name",
"components.Settings.RadarrModal.servernamePlaceholder": "A Radarr Server",
"components.Settings.RadarrModal.ssl": "SSL",
"components.Settings.RadarrModal.syncEnabled": "Enable Sync",
"components.Settings.RadarrModal.test": "Test",
"components.Settings.RadarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
"components.Settings.RadarrModal.testFirstRootFolders": "Test connection to load root folders",
@@ -343,10 +351,13 @@
"components.Settings.SonarrModal.createsonarr": "Create New Sonarr Server",
"components.Settings.SonarrModal.defaultserver": "Default Server",
"components.Settings.SonarrModal.editsonarr": "Edit Sonarr Server",
"components.Settings.SonarrModal.externalUrl": "External URL",
"components.Settings.SonarrModal.externalUrlPlaceholder": "External URL pointing to your Sonarr server",
"components.Settings.SonarrModal.hostname": "Hostname",
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.SonarrModal.port": "Port",
"components.Settings.SonarrModal.preventSearch": "Disable Auto-Search",
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
"components.Settings.SonarrModal.rootfolder": "Root Folder",
"components.Settings.SonarrModal.save": "Save Changes",
@@ -358,6 +369,7 @@
"components.Settings.SonarrModal.servername": "Server Name",
"components.Settings.SonarrModal.servernamePlaceholder": "A Sonarr Server",
"components.Settings.SonarrModal.ssl": "SSL",
"components.Settings.SonarrModal.syncEnabled": "Enable Sync",
"components.Settings.SonarrModal.test": "Test",
"components.Settings.SonarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
"components.Settings.SonarrModal.testFirstRootFolders": "Test connection to load root folders",
@@ -377,6 +389,7 @@
"components.Settings.apikey": "API Key",
"components.Settings.applicationurl": "Application URL",
"components.Settings.autoapprovedrequests": "Send Notifications for Auto-Approved Requests",
"components.Settings.canceljob": "Cancel Job",
"components.Settings.cancelscan": "Cancel Scan",
"components.Settings.copied": "Copied API key to clipboard.",
"components.Settings.csrfProtection": "Enable CSRF Protection",
@@ -393,7 +406,10 @@
"components.Settings.generalsettingsDescription": "Configure global and default settings for Overseerr.",
"components.Settings.hideAvailable": "Hide Available Media",
"components.Settings.hostname": "Hostname/IP",
"components.Settings.jobcancelled": "{jobname} cancelled.",
"components.Settings.jobname": "Job Name",
"components.Settings.jobstarted": "{jobname} started.",
"components.Settings.jobtype": "Type",
"components.Settings.librariesRemaining": "Libraries Remaining: {count}",
"components.Settings.manualscan": "Manual Library Scan",
"components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Overseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!",
@@ -478,10 +494,12 @@
"components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew",
"components.TvDetails.anime": "Anime",
"components.TvDetails.approve": "Approve",
"components.TvDetails.areyousure": "Are you sure?",
"components.TvDetails.available": "Available",
"components.TvDetails.cancelrequest": "Cancel Request",
"components.TvDetails.cast": "Cast",
"components.TvDetails.decline": "Decline",
"components.TvDetails.downloadstatus": "Download Status",
"components.TvDetails.firstAirDate": "First Air Date",
"components.TvDetails.manageModalClearMedia": "Clear All Media Data",
"components.TvDetails.manageModalClearMediaWarning": "This will irreversibly remove all data for this TV series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next sync.",
@@ -489,6 +507,8 @@
"components.TvDetails.manageModalRequests": "Requests",
"components.TvDetails.manageModalTitle": "Manage Series",
"components.TvDetails.network": "Network",
"components.TvDetails.opensonarr": "Open Series in Sonarr",
"components.TvDetails.opensonarr4k": "Open Series in 4K Sonarr",
"components.TvDetails.originallanguage": "Original Language",
"components.TvDetails.overview": "Overview",
"components.TvDetails.overviewunavailable": "Overview unavailable.",
@@ -507,6 +527,7 @@
"components.UserEdit.edituser": "Edit User",
"components.UserEdit.email": "Email",
"components.UserEdit.permissions": "Permissions",
"components.UserEdit.plexUsername": "Plex Username",
"components.UserEdit.save": "Save",
"components.UserEdit.saving": "Saving…",
"components.UserEdit.userfail": "Something went wrong while saving the user.",