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

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">