Override release grab modal

New: Option to override release and grab
New: Option to select download client when multiple of the same type are configured

Closes #4526
Closes #4774
This commit is contained in:
Mark McDowall 2023-03-27 16:49:12 -07:00
parent defdc84b7e
commit 07f0fbf9a5
31 changed files with 1423 additions and 533 deletions

View File

@ -10,6 +10,7 @@ class DescriptionListItem extends Component {
render() {
const {
className,
titleClassName,
descriptionClassName,
title,
@ -17,7 +18,7 @@ class DescriptionListItem extends Component {
} = this.props;
return (
<span>
<span className={className}>
<DescriptionListItemTitle
className={titleClassName}
>
@ -35,6 +36,7 @@ class DescriptionListItem extends Component {
}
DescriptionListItem.propTypes = {
className: PropTypes.string,
titleClassName: PropTypes.string,
descriptionClassName: PropTypes.string,
title: PropTypes.string,

View File

@ -0,0 +1,7 @@
enum DownloadProtocol {
Unknown = 'unknown',
Usenet = 'usenet',
Torrent = 'torrent',
}
export default DownloadProtocol;

View File

@ -39,6 +39,7 @@ import {
faChevronCircleRight as fasChevronCircleRight,
faChevronCircleUp as fasChevronCircleUp,
faCircle as fasCircle,
faCircleDown as fasCircleDown,
faCloud as fasCloud,
faCloudDownloadAlt as fasCloudDownloadAlt,
faCog as fasCog,
@ -121,6 +122,7 @@ export const CHECK_INDETERMINATE = fasMinus;
export const CHECK_CIRCLE = fasCheckCircle;
export const CHECK_SQUARE = fasSquareCheck;
export const CIRCLE = fasCircle;
export const CIRCLE_DOWN = fasCircleDown;
export const CIRCLE_OUTLINE = farCircle;
export const CLEAR = fasTrashAlt;
export const CLIPBOARD = fasCopy;

View File

@ -40,8 +40,7 @@
cursor: default;
}
.rejected,
.download {
.rejected {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 50px;
@ -59,3 +58,34 @@
width: 75px;
}
.download {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 80px;
}
.manualDownloadContent {
position: relative;
display: inline-block;
width: 100%;
height: 20.39px;
vertical-align: middle;
line-height: 20.39px;
}
.interactiveIcon {
position: absolute;
top: 4px;
left: 0;
/* width: 100%; */
text-align: center;
}
.downloadIcon {
position: absolute;
top: 7px;
left: 8px;
/* width: 100%; */
text-align: center;
}

View File

@ -4,8 +4,11 @@ interface CssExports {
'age': string;
'customFormatScore': string;
'download': string;
'downloadIcon': string;
'indexer': string;
'interactiveIcon': string;
'languages': string;
'manualDownloadContent': string;
'peers': string;
'protocol': string;
'quality': string;

View File

@ -1,308 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import Peers from './Peers';
import ReleaseSceneIndicator from './ReleaseSceneIndicator';
import styles from './InteractiveSearchRow.css';
function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
if (isGrabbing) {
return icons.SPINNER;
} else if (isGrabbed) {
return icons.DOWNLOADING;
} else if (grabError) {
return icons.DOWNLOADING;
}
return icons.DOWNLOAD;
}
function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
if (isGrabbing) {
return '';
} else if (isGrabbed) {
return 'Added to download queue';
} else if (grabError) {
return grabError;
}
return 'Add to download queue';
}
class InteractiveSearchRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isConfirmGrabModalOpen: false
};
}
//
// Listeners
onGrabPress = () => {
const {
guid,
indexerId,
onGrabPress
} = this.props;
onGrabPress({
guid,
indexerId
});
};
onConfirmGrabPress = () => {
this.setState({ isConfirmGrabModalOpen: true });
};
onGrabConfirm = () => {
this.setState({ isConfirmGrabModalOpen: false });
const {
guid,
indexerId,
searchPayload,
onGrabPress
} = this.props;
onGrabPress({
guid,
indexerId,
...searchPayload
});
};
onGrabCancel = () => {
this.setState({ isConfirmGrabModalOpen: false });
};
//
// Render
render() {
const {
protocol,
age,
ageHours,
ageMinutes,
publishDate,
title,
infoUrl,
indexer,
size,
seeders,
leechers,
quality,
languages,
customFormatScore,
customFormats,
sceneMapping,
seasonNumber,
episodeNumbers,
absoluteEpisodeNumbers,
mappedSeasonNumber,
mappedEpisodeNumbers,
mappedAbsoluteEpisodeNumbers,
rejections,
episodeRequested,
downloadAllowed,
isDaily,
isGrabbing,
isGrabbed,
longDateFormat,
timeFormat,
grabError
} = this.props;
return (
<TableRow>
<TableRowCell className={styles.protocol}>
<ProtocolLabel
protocol={protocol}
/>
</TableRowCell>
<TableRowCell
className={styles.age}
title={formatDateTime(publishDate, longDateFormat, timeFormat, { includeSeconds: true })}
>
{formatAge(age, ageHours, ageMinutes)}
</TableRowCell>
<TableRowCell>
<div className={styles.titleContent}>
<Link to={infoUrl}>
{title}
</Link>
<ReleaseSceneIndicator
className={styles.sceneMapping}
seasonNumber={mappedSeasonNumber}
episodeNumbers={mappedEpisodeNumbers}
absoluteEpisodeNumbers={mappedAbsoluteEpisodeNumbers}
sceneSeasonNumber={seasonNumber}
sceneEpisodeNumbers={episodeNumbers}
sceneAbsoluteEpisodeNumbers={absoluteEpisodeNumbers}
sceneMapping={sceneMapping}
episodeRequested={episodeRequested}
isDaily={isDaily}
/>
</div>
</TableRowCell>
<TableRowCell className={styles.indexer}>
{indexer}
</TableRowCell>
<TableRowCell className={styles.size}>
{formatBytes(size)}
</TableRowCell>
<TableRowCell className={styles.peers}>
{
protocol === 'torrent' &&
<Peers
seeders={seeders}
leechers={leechers}
/>
}
</TableRowCell>
<TableRowCell className={styles.languages}>
<EpisodeLanguages languages={languages} />
</TableRowCell>
<TableRowCell className={styles.quality}>
<EpisodeQuality quality={quality} />
</TableRowCell>
<TableRowCell className={styles.customFormatScore}>
<Tooltip
anchor={
formatPreferredWordScore(customFormatScore, customFormats.length)
}
tooltip={<EpisodeFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
<TableRowCell className={styles.rejected}>
{
!!rejections.length &&
<Popover
anchor={
<Icon
name={icons.DANGER}
kind={kinds.DANGER}
/>
}
title="Release Rejected"
body={
<ul>
{
rejections.map((rejection, index) => {
return (
<li key={index}>
{rejection}
</li>
);
})
}
</ul>
}
position={tooltipPositions.LEFT}
/>
}
</TableRowCell>
<TableRowCell className={styles.download}>
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isSpinning={isGrabbing}
onPress={downloadAllowed ? this.onGrabPress : this.onConfirmGrabPress}
/>
</TableRowCell>
<ConfirmModal
isOpen={this.state.isConfirmGrabModalOpen}
kind={kinds.WARNING}
title="Grab Release"
message={`Sonarr was unable to determine which series and episode this release was for. Sonarr may be unable to automatically import this release. Do you want to grab '${title}'?`}
confirmLabel="Grab"
onConfirm={this.onGrabConfirm}
onCancel={this.onGrabCancel}
/>
</TableRow>
);
}
}
InteractiveSearchRow.propTypes = {
guid: PropTypes.string.isRequired,
protocol: PropTypes.string.isRequired,
age: PropTypes.number.isRequired,
ageHours: PropTypes.number.isRequired,
ageMinutes: PropTypes.number.isRequired,
publishDate: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
infoUrl: PropTypes.string.isRequired,
indexerId: PropTypes.number.isRequired,
indexer: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
seeders: PropTypes.number,
leechers: PropTypes.number,
quality: PropTypes.object.isRequired,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
sceneMapping: PropTypes.object,
seasonNumber: PropTypes.number,
episodeNumbers: PropTypes.arrayOf(PropTypes.number),
absoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
mappedSeasonNumber: PropTypes.number,
mappedEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
mappedAbsoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
rejections: PropTypes.arrayOf(PropTypes.string).isRequired,
episodeRequested: PropTypes.bool.isRequired,
downloadAllowed: PropTypes.bool.isRequired,
isDaily: PropTypes.bool.isRequired,
isGrabbing: PropTypes.bool.isRequired,
isGrabbed: PropTypes.bool.isRequired,
grabError: PropTypes.string,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
searchPayload: PropTypes.object.isRequired,
onGrabPress: PropTypes.func.isRequired
};
InteractiveSearchRow.defaultProps = {
rejections: [],
isGrabbing: false,
isGrabbed: false
};
export default InteractiveSearchRow;

View File

@ -0,0 +1,314 @@
import React, { useCallback, useState } from 'react';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import type DownloadProtocol from 'DownloadClient/DownloadProtocol';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import OverrideMatchModal from './OverrideMatch/OverrideMatchModal';
import Peers from './Peers';
import ReleaseEpisode from './ReleaseEpisode';
import ReleaseSceneIndicator from './ReleaseSceneIndicator';
import styles from './InteractiveSearchRow.css';
function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
if (isGrabbing) {
return icons.SPINNER;
} else if (isGrabbed) {
return icons.DOWNLOADING;
} else if (grabError) {
return icons.DOWNLOADING;
}
return icons.DOWNLOAD;
}
function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
if (isGrabbing) {
return '';
} else if (isGrabbed) {
return 'Added to download queue';
} else if (grabError) {
return grabError;
}
return 'Add to download queue';
}
interface InteractiveSearchRowProps {
guid: string;
protocol: DownloadProtocol;
age: number;
ageHours: number;
ageMinutes: number;
publishDate: string;
title: string;
infoUrl: string;
indexerId: number;
indexer: string;
size: number;
seeders?: number;
leechers?: number;
quality: QualityModel;
languages: Language[];
customFormats?: object[];
customFormatScore: number;
sceneMapping?: object;
seasonNumber?: number;
episodeNumbers?: number[];
absoluteEpisodeNumbers?: number[];
mappedSeriesId?: number;
mappedSeasonNumber?: number;
mappedEpisodeNumbers?: number[];
mappedAbsoluteEpisodeNumbers?: number[];
mappedEpisodeInfo: ReleaseEpisode[];
rejections: string[];
episodeRequested: boolean;
downloadAllowed: boolean;
isDaily: boolean;
isGrabbing: boolean;
isGrabbed: boolean;
grabError?: string;
longDateFormat: string;
timeFormat: string;
searchPayload: object;
onGrabPress(...args: unknown[]): void;
}
function InteractiveSearchRow(props: InteractiveSearchRowProps) {
const {
guid,
indexerId,
protocol,
age,
ageHours,
ageMinutes,
publishDate,
title,
infoUrl,
indexer,
size,
seeders,
leechers,
quality,
languages,
customFormatScore,
customFormats,
sceneMapping,
seasonNumber,
episodeNumbers,
absoluteEpisodeNumbers,
mappedSeriesId,
mappedSeasonNumber,
mappedEpisodeNumbers,
mappedAbsoluteEpisodeNumbers,
mappedEpisodeInfo,
rejections,
episodeRequested,
downloadAllowed,
isDaily,
isGrabbing,
isGrabbed,
longDateFormat,
timeFormat,
grabError,
searchPayload,
onGrabPress,
} = props;
const [isConfirmGrabModalOpen, setIsConfirmGrabModalOpen] = useState(false);
const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false);
const onGrabPressWrapper = useCallback(() => {
if (downloadAllowed) {
onGrabPress({
guid,
indexerId,
});
return;
}
setIsConfirmGrabModalOpen(true);
}, [
guid,
indexerId,
downloadAllowed,
onGrabPress,
setIsConfirmGrabModalOpen,
]);
const onGrabConfirm = useCallback(() => {
setIsConfirmGrabModalOpen(false);
onGrabPress({
guid,
indexerId,
...searchPayload,
});
}, [guid, indexerId, searchPayload, onGrabPress, setIsConfirmGrabModalOpen]);
const onGrabCancel = useCallback(() => {
setIsConfirmGrabModalOpen(false);
}, [setIsConfirmGrabModalOpen]);
const onOverridePress = useCallback(() => {
setIsOverrideModalOpen(true);
}, [setIsOverrideModalOpen]);
const onOverrideModalClose = useCallback(() => {
setIsOverrideModalOpen(false);
}, [setIsOverrideModalOpen]);
return (
<TableRow>
<TableRowCell className={styles.protocol}>
<ProtocolLabel protocol={protocol} />
</TableRowCell>
<TableRowCell
className={styles.age}
title={formatDateTime(publishDate, longDateFormat, timeFormat, {
includeSeconds: true,
})}
>
{formatAge(age, ageHours, ageMinutes)}
</TableRowCell>
<TableRowCell>
<div className={styles.titleContent}>
<Link to={infoUrl}>{title}</Link>
<ReleaseSceneIndicator
className={styles.sceneMapping}
seasonNumber={mappedSeasonNumber}
episodeNumbers={mappedEpisodeNumbers}
absoluteEpisodeNumbers={mappedAbsoluteEpisodeNumbers}
sceneSeasonNumber={seasonNumber}
sceneEpisodeNumbers={episodeNumbers}
sceneAbsoluteEpisodeNumbers={absoluteEpisodeNumbers}
sceneMapping={sceneMapping}
episodeRequested={episodeRequested}
isDaily={isDaily}
/>
</div>
</TableRowCell>
<TableRowCell className={styles.indexer}>{indexer}</TableRowCell>
<TableRowCell className={styles.size}>{formatBytes(size)}</TableRowCell>
<TableRowCell className={styles.peers}>
{protocol === 'torrent' ? (
<Peers seeders={seeders} leechers={leechers} />
) : null}
</TableRowCell>
<TableRowCell className={styles.languages}>
<EpisodeLanguages languages={languages} />
</TableRowCell>
<TableRowCell className={styles.quality}>
<EpisodeQuality quality={quality} />
</TableRowCell>
<TableRowCell className={styles.customFormatScore}>
<Tooltip
anchor={formatPreferredWordScore(
customFormatScore,
customFormats.length
)}
tooltip={<EpisodeFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
<TableRowCell className={styles.rejected}>
{rejections.length ? (
<Popover
anchor={<Icon name={icons.DANGER} kind={kinds.DANGER} />}
title="Release Rejected"
body={
<ul>
{rejections.map((rejection, index) => {
return <li key={index}>{rejection}</li>;
})}
</ul>
}
position={tooltipPositions.LEFT}
/>
) : null}
</TableRowCell>
<TableRowCell className={styles.download}>
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isSpinning={isGrabbing}
onPress={onGrabPressWrapper}
/>
<Link
title="Override and add to download queue"
onPress={onOverridePress}
>
<div className={styles.manualDownloadContent}>
<Icon
className={styles.interactiveIcon}
name={icons.INTERACTIVE}
size={12}
/>
<Icon
className={styles.downloadIcon}
name={icons.CIRCLE_DOWN}
size={10}
/>
</div>
</Link>
</TableRowCell>
<ConfirmModal
isOpen={isConfirmGrabModalOpen}
kind={kinds.WARNING}
title="Grab Release"
message={`Sonarr was unable to determine which series and episode this release was for. Sonarr may be unable to automatically import this release. Do you want to grab '${title}'?`}
confirmLabel="Grab"
onConfirm={onGrabConfirm}
onCancel={onGrabCancel}
/>
<OverrideMatchModal
isOpen={isOverrideModalOpen}
title={title}
indexerId={indexerId}
guid={guid}
seriesId={mappedSeriesId}
seasonNumber={mappedSeasonNumber}
episodes={mappedEpisodeInfo}
languages={languages}
quality={quality}
protocol={protocol}
isGrabbing={isGrabbing}
grabError={grabError}
onModalClose={onOverrideModalClose}
/>
</TableRow>
);
}
export default InteractiveSearchRow;

View File

@ -0,0 +1,31 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import { sizes } from 'Helpers/Props';
import SelectDownloadClientModalContent from './SelectDownloadClientModalContent';
interface SelectDownloadClientModalProps {
isOpen: boolean;
protocol: DownloadProtocol;
modalTitle: string;
onDownloadClientSelect(downloadClientId: number): void;
onModalClose(): void;
}
function SelectDownloadClientModal(props: SelectDownloadClientModalProps) {
const { isOpen, protocol, modalTitle, onDownloadClientSelect, onModalClose } =
props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}>
<SelectDownloadClientModalContent
protocol={protocol}
modalTitle={modalTitle}
onDownloadClientSelect={onDownloadClientSelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectDownloadClientModal;

View File

@ -0,0 +1,68 @@
import React from 'react';
import { useSelector } from 'react-redux';
import Form from 'Components/Form/Form';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
import translate from 'Utilities/String/translate';
import SelectDownloadClientRow from './SelectDownloadClientRow';
interface SelectDownloadClientModalContentProps {
protocol: DownloadProtocol;
modalTitle: string;
onDownloadClientSelect(downloadClientId: number): void;
onModalClose(): void;
}
function SelectDownloadClientModalContent(
props: SelectDownloadClientModalContentProps
) {
const { modalTitle, protocol, onDownloadClientSelect, onModalClose } = props;
const { isFetching, isPopulated, error, items } = useSelector(
createEnabledDownloadClientsSelector(protocol)
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{modalTitle} - Select Download Client</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<div>Unable to load download clients</div>
) : null}
{isPopulated && !error ? (
<Form>
{items.map((downloadClient) => {
const { id, name, priority } = downloadClient;
return (
<SelectDownloadClientRow
key={id}
id={id}
name={name}
priority={priority}
onDownloadClientSelect={onDownloadClientSelect}
/>
);
})}
</Form>
) : null}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectDownloadClientModalContent;

View File

@ -0,0 +1,6 @@
.downloadClient {
display: flex;
justify-content: space-between;
padding: 8px;
border-bottom: 1px solid var(--borderColor);
}

View File

@ -0,0 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'downloadClient': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@ -0,0 +1,31 @@
import React, { useCallback } from 'react';
import Link from 'Components/Link/Link';
import styles from './SelectDownloadClientRow.css';
interface SelectSeasonRowProps {
id: number;
name: number;
priority: number;
onDownloadClientSelect(downloadClientId: number): unknown;
}
function SelectDownloadClientRow(props: SelectSeasonRowProps) {
const { id, name, priority, onDownloadClientSelect } = props;
const onSeasonSelectWrapper = useCallback(() => {
onDownloadClientSelect(id);
}, [id, onDownloadClientSelect]);
return (
<Link
className={styles.downloadClient}
component="div"
onPress={onSeasonSelectWrapper}
>
<div>{name}</div>
<div>Priority: {priority}</div>
</Link>
);
}
export default SelectDownloadClientRow;

View File

@ -0,0 +1,17 @@
.link {
composes: link from '~Components/Link/Link.css';
width: 100%;
}
.placeholder {
display: inline-block;
margin: -2px 0;
width: 100%;
outline: 2px dashed var(--dangerColor);
outline-offset: -2px;
}
.optional {
outline: 2px dashed var(--gray);
}

View File

@ -0,0 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'link': string;
'optional': string;
'placeholder': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@ -0,0 +1,35 @@
import classNames from 'classnames';
import React from 'react';
import Link from 'Components/Link/Link';
import styles from './OverrideMatchData.css';
interface OverrideMatchDataProps {
value?: string | number | JSX.Element | JSX.Element[];
isDisabled?: boolean;
isOptional?: boolean;
onPress: () => void;
}
function OverrideMatchData(props: OverrideMatchDataProps) {
const { value, isDisabled = false, isOptional, onPress } = props;
return (
<Link className={styles.link} isDisabled={isDisabled} onPress={onPress}>
{(value == null || (Array.isArray(value) && value.length === 0)) &&
!isDisabled ? (
<span
className={classNames(
styles.placeholder,
isOptional && styles.optional
)}
>
&nbsp;
</span>
) : (
value
)}
</Link>
);
}
export default OverrideMatchData;

View File

@ -0,0 +1,63 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import { sizes } from 'Helpers/Props';
import ReleaseEpisode from 'InteractiveSearch/ReleaseEpisode';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import OverrideMatchModalContent from './OverrideMatchModalContent';
interface OverrideMatchModalProps {
isOpen: boolean;
title: string;
indexerId: number;
guid: string;
seriesId?: number;
seasonNumber?: number;
episodes: ReleaseEpisode[];
languages: Language[];
quality: QualityModel;
protocol: DownloadProtocol;
isGrabbing: boolean;
grabError: string;
onModalClose(): void;
}
function OverrideMatchModal(props: OverrideMatchModalProps) {
const {
isOpen,
title,
indexerId,
guid,
seriesId,
seasonNumber,
episodes,
languages,
quality,
protocol,
isGrabbing,
grabError,
onModalClose,
} = props;
return (
<Modal isOpen={isOpen} size={sizes.LARGE} onModalClose={onModalClose}>
<OverrideMatchModalContent
title={title}
indexerId={indexerId}
guid={guid}
seriesId={seriesId}
seasonNumber={seasonNumber}
episodes={episodes}
languages={languages}
quality={quality}
protocol={protocol}
isGrabbing={isGrabbing}
grabError={grabError}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default OverrideMatchModal;

View File

@ -0,0 +1,49 @@
.label {
composes: label from '~Components/Label.css';
cursor: pointer;
}
.item {
display: block;
margin-bottom: 5px;
margin-left: 50px;
}
.footer {
composes: modalFooter from '~Components/Modal/ModalFooter.css';
display: flex;
justify-content: space-between;
overflow: hidden;
}
.error {
margin-right: 20px;
color: var(--dangerColor);
word-break: break-word;
}
.buttons {
display: flex;
}
@media only screen and (max-width: $breakpointSmall) {
.item {
margin-left: 0;
}
.footer {
display: block;
}
.error {
margin-right: 0;
margin-bottom: 10px;
}
.buttons {
justify-content: space-between;
flex-grow: 1;
}
}

View File

@ -0,0 +1,11 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'buttons': string;
'error': string;
'footer': string;
'item': string;
'label': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@ -0,0 +1,391 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
import ReleaseEpisode from 'InteractiveSearch/ReleaseEpisode';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import Series from 'Series/Series';
import { grabRelease } from 'Store/Actions/releaseActions';
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import translate from 'Utilities/String/translate';
import SelectDownloadClientModal from './DownloadClient/SelectDownloadClientModal';
import OverrideMatchData from './OverrideMatchData';
import styles from './OverrideMatchModalContent.css';
type SelectType =
| 'select'
| 'series'
| 'season'
| 'episode'
| 'quality'
| 'language'
| 'downloadClient';
interface OverrideMatchModalContentProps {
indexerId: number;
title: string;
guid: string;
seriesId?: number;
seasonNumber?: number;
episodes: ReleaseEpisode[];
languages: Language[];
quality: QualityModel;
protocol: DownloadProtocol;
isGrabbing: boolean;
grabError: string;
onModalClose(): void;
}
function OverrideMatchModalContent(props: OverrideMatchModalContentProps) {
const modalTitle = 'Manual Grab';
const {
indexerId,
title,
guid,
protocol,
isGrabbing,
grabError,
onModalClose,
} = props;
const [seriesId, setSeriesId] = useState(props.seriesId);
const [seasonNumber, setSeasonNumber] = useState(props.seasonNumber);
const [episodes, setEpisodes] = useState(props.episodes);
const [languages, setLanguages] = useState(props.languages);
const [quality, setQuality] = useState(props.quality);
const [downloadClientId, setDownloadClientId] = useState(null);
const [error, setError] = useState<string | null>(null);
const [selectModalOpen, setSelectModalOpen] = useState<SelectType | null>(
null
);
const dispatch = useDispatch();
const series: Series = useSelector(createSeriesSelector(seriesId));
const { items: downloadClients } = useSelector(
createEnabledDownloadClientsSelector(protocol)
);
const episodeInfo = useMemo(() => {
return episodes.map((episode) => {
return (
<div key={episode.id}>
{episode.episodeNumber}
{series.seriesType === 'anime' &&
episode.absoluteEpisodeNumber != null
? ` (${episode.absoluteEpisodeNumber})`
: ''}
{` - ${episode.title}`}
</div>
);
});
}, [series, episodes]);
const onSelectModalClose = useCallback(() => {
setSelectModalOpen(null);
}, [setSelectModalOpen]);
const onSelectSeriesPress = useCallback(() => {
setSelectModalOpen('series');
}, [setSelectModalOpen]);
const onSeriesSelect = useCallback(
(s: Series) => {
setSeriesId(s.id);
setSeasonNumber(undefined);
setEpisodes([]);
setSelectModalOpen(null);
},
[setSeriesId, setSeasonNumber, setEpisodes, setSelectModalOpen]
);
const onSelectSeasonPress = useCallback(() => {
setSelectModalOpen('season');
}, [setSelectModalOpen]);
const onSeasonSelect = useCallback(
(s: number) => {
setSeasonNumber(s);
setEpisodes([]);
setSelectModalOpen(null);
},
[setSeasonNumber, setEpisodes, setSelectModalOpen]
);
const onSelectEpisodePress = useCallback(() => {
setSelectModalOpen('episode');
}, [setSelectModalOpen]);
const onEpisodesSelect = useCallback(
(episodeMap) => {
setEpisodes(episodeMap[0].episodes);
setSelectModalOpen(null);
},
[setEpisodes, setSelectModalOpen]
);
const onSelectQualityPress = useCallback(() => {
setSelectModalOpen('quality');
}, [setSelectModalOpen]);
const onQualitySelect = useCallback(
(quality) => {
setQuality(quality);
setSelectModalOpen(null);
},
[setQuality, setSelectModalOpen]
);
const onSelectLanguagesPress = useCallback(() => {
setSelectModalOpen('language');
}, [setSelectModalOpen]);
const onLanguagesSelect = useCallback(
(languages) => {
setLanguages(languages);
setSelectModalOpen(null);
},
[setLanguages, setSelectModalOpen]
);
const onSelectDownloadClientPress = useCallback(() => {
setSelectModalOpen('downloadClient');
}, [setSelectModalOpen]);
const onDownloadClientSelect = useCallback(
(downloadClientId) => {
setDownloadClientId(downloadClientId);
setSelectModalOpen(null);
},
[setDownloadClientId, setSelectModalOpen]
);
const onGrabPress = useCallback(() => {
if (!seriesId) {
setError('Series must be selected');
return;
} else if (!episodes.length) {
setError('At least one episode must be selected');
return;
} else if (!quality) {
setError('Quality must be selected');
return;
} else if (!languages.length) {
setError('At least one language must be selected');
return;
}
dispatch(
grabRelease({
indexerId,
guid,
seriesId,
episodeIds: episodes.map((e) => e.id),
quality,
languages,
downloadClientId,
shouldOverride: true,
})
);
}, [
indexerId,
guid,
seriesId,
episodes,
quality,
languages,
downloadClientId,
setError,
dispatch,
]);
useEffect(
() => {
dispatch(fetchDownloadClients());
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('Override and Grab - {title}', { title })}
</ModalHeader>
<ModalBody>
<DescriptionList>
<DescriptionListItem
className={styles.item}
title={translate('Series')}
data={
<OverrideMatchData
value={series?.title}
onPress={onSelectSeriesPress}
/>
}
/>
<DescriptionListItem
className={styles.item}
title={translate('Season Number')}
data={
<OverrideMatchData
value={seasonNumber}
isDisabled={!series}
onPress={onSelectSeasonPress}
/>
}
/>
<DescriptionListItem
className={styles.item}
title={translate('Episodes')}
data={
<OverrideMatchData
value={episodeInfo}
isDisabled={!series || isNaN(seasonNumber)}
onPress={onSelectEpisodePress}
/>
}
/>
<DescriptionListItem
className={styles.item}
title={translate('Quality')}
data={
<OverrideMatchData
value={
<EpisodeQuality className={styles.label} quality={quality} />
}
onPress={onSelectQualityPress}
/>
}
/>
<DescriptionListItem
className={styles.item}
title={translate('Languages')}
data={
<OverrideMatchData
value={
<EpisodeLanguages
className={styles.label}
languages={languages}
/>
}
onPress={onSelectLanguagesPress}
/>
}
/>
{downloadClients.length > 1 ? (
<DescriptionListItem
className={styles.item}
title={translate('Download Client')}
data={
<OverrideMatchData
value={
downloadClients.find(
(downloadClient) => downloadClient.id === downloadClientId
)?.name ?? 'Default'
}
onPress={onSelectDownloadClientPress}
/>
}
/>
) : null}
</DescriptionList>
</ModalBody>
<ModalFooter className={styles.footer}>
<div className={styles.error}>{error || grabError}</div>
<div className={styles.buttons}>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerErrorButton
isSpinning={isGrabbing}
error={grabError}
onPress={onGrabPress}
>
{translate('Grab Release')}
</SpinnerErrorButton>
</div>
</ModalFooter>
<SelectSeriesModal
isOpen={selectModalOpen === 'series'}
modalTitle={modalTitle}
onSeriesSelect={onSeriesSelect}
onModalClose={onSelectModalClose}
/>
<SelectSeasonModal
isOpen={selectModalOpen === 'season'}
modalTitle={modalTitle}
seriesId={seriesId}
onSeasonSelect={onSeasonSelect}
onModalClose={onSelectModalClose}
/>
<SelectEpisodeModal
isOpen={selectModalOpen === 'episode'}
selectedIds={[guid]}
seriesId={seriesId}
isAnime={series.seriesType === 'anime'}
seasonNumber={seasonNumber}
selectedDetails={title}
modalTitle={modalTitle}
onEpisodesSelect={onEpisodesSelect}
onModalClose={onSelectModalClose}
/>
<SelectQualityModal
isOpen={selectModalOpen === 'quality'}
qualityId={quality ? quality.quality.id : 0}
proper={quality ? quality.revision.version > 1 : false}
real={quality ? quality.revision.real > 0 : false}
modalTitle={modalTitle}
onQualitySelect={onQualitySelect}
onModalClose={onSelectModalClose}
/>
<SelectLanguageModal
isOpen={selectModalOpen === 'language'}
languageIds={languages ? languages.map((l) => l.id) : []}
modalTitle={modalTitle}
onLanguagesSelect={onLanguagesSelect}
onModalClose={onSelectModalClose}
/>
<SelectDownloadClientModal
isOpen={selectModalOpen === 'downloadClient'}
protocol={protocol}
modalTitle={modalTitle}
onDownloadClientSelect={onDownloadClientSelect}
onModalClose={onSelectModalClose}
/>
</ModalContent>
);
}
export default OverrideMatchModalContent;

View File

@ -0,0 +1,10 @@
interface ReleaseEpisode {
id: number;
episodeFileId: number;
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber?: number;
title: string;
}
export default ReleaseEpisode;

View File

@ -1,188 +0,0 @@
import classNames from 'classnames';
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import { icons, tooltipPositions } from 'Helpers/Props';
import styles from './ReleaseSceneIndicator.css';
function formatReleaseNumber(seasonNumber, episodeNumbers, absoluteEpisodeNumbers) {
if (episodeNumbers && episodeNumbers.length) {
if (episodeNumbers.length > 1) {
return `${seasonNumber}x${episodeNumbers[0]}-${episodeNumbers[episodeNumbers.length - 1]}`;
}
return `${seasonNumber}x${episodeNumbers[0]}`;
}
if (absoluteEpisodeNumbers && absoluteEpisodeNumbers.length) {
if (absoluteEpisodeNumbers.length > 1) {
return `${absoluteEpisodeNumbers[0]}-${absoluteEpisodeNumbers[absoluteEpisodeNumbers.length - 1]}`;
}
return absoluteEpisodeNumbers[0];
}
if (seasonNumber !== undefined) {
return `Season ${seasonNumber}`;
}
return null;
}
function ReleaseSceneIndicator(props) {
const {
className,
seasonNumber,
episodeNumbers,
absoluteEpisodeNumbers,
sceneSeasonNumber,
sceneEpisodeNumbers,
sceneAbsoluteEpisodeNumbers,
sceneMapping,
episodeRequested,
isDaily
} = props;
const {
sceneOrigin,
title,
comment
} = sceneMapping || {};
if (isDaily) {
return null;
}
let mappingDifferent = (sceneSeasonNumber !== undefined && seasonNumber !== sceneSeasonNumber);
if (sceneEpisodeNumbers !== undefined) {
mappingDifferent = mappingDifferent || !_.isEqual(sceneEpisodeNumbers, episodeNumbers);
} else if (sceneAbsoluteEpisodeNumbers !== undefined) {
mappingDifferent = mappingDifferent || !_.isEqual(sceneAbsoluteEpisodeNumbers, absoluteEpisodeNumbers);
}
if (!sceneMapping && !mappingDifferent) {
return null;
}
const releaseNumber = formatReleaseNumber(sceneSeasonNumber, sceneEpisodeNumbers, sceneAbsoluteEpisodeNumbers);
const mappedNumber = formatReleaseNumber(seasonNumber, episodeNumbers, absoluteEpisodeNumbers);
const messages = [];
const isMixed = (sceneOrigin === 'mixed');
const isUnknown = (sceneOrigin === 'unknown' || sceneOrigin === 'unknown:tvdb');
let level = styles.levelNone;
if (isMixed) {
level = styles.levelMixed;
messages.push(<div key="source">{comment ?? 'Source'} releases exist with ambiguous numbering, unable to reliably identify episode.</div>);
} else if (isUnknown) {
level = styles.levelUnknown;
messages.push(<div key="unknown">Numbering varies for this episode and release does not match any known mappings.</div>);
if (sceneOrigin === 'unknown') {
messages.push(<div key="origin">Assuming Scene numbering.</div>);
} else if (sceneOrigin === 'unknown:tvdb') {
messages.push(<div key="origin">Assuming TheTVDB numbering.</div>);
}
} else if (mappingDifferent) {
level = styles.levelMapped;
} else if (sceneOrigin) {
level = styles.levelNormal;
}
if (!episodeRequested) {
if (!isMixed && !isUnknown) {
level = styles.levelNotRequested;
}
if (mappedNumber) {
messages.push(<div key="not-requested">Mapped episode wasn't requested in this search.</div>);
} else {
messages.push(<div key="unknown-series">Unknown episode or series.</div>);
}
}
const table = (
<DescriptionList className={styles.descriptionList}>
{
comment !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="Mapping"
data={comment}
/>
}
{
title !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="Title"
data={title}
/>
}
{
releaseNumber !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="Release"
data={releaseNumber ?? 'unknown'}
/>
}
{
releaseNumber !== undefined &&
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="TheTVDB"
data={mappedNumber ?? 'unknown'}
/>
}
</DescriptionList>
);
return (
<Popover
anchor={
<div className={classNames(level, styles.container, className)}>
<Icon name={icons.SCENE_MAPPING} />
</div>
}
title="Scene Info"
body={
<div>
{table}
{
messages.length &&
<div className={styles.messages}>
{messages}
</div> || null
}
</div>
}
position={tooltipPositions.RIGHT}
/>
);
}
ReleaseSceneIndicator.propTypes = {
className: PropTypes.string.isRequired,
seasonNumber: PropTypes.number,
episodeNumbers: PropTypes.arrayOf(PropTypes.number),
absoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
sceneSeasonNumber: PropTypes.number,
sceneEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
sceneAbsoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
sceneMapping: PropTypes.object.isRequired,
episodeRequested: PropTypes.bool.isRequired,
isDaily: PropTypes.bool.isRequired
};
export default ReleaseSceneIndicator;

View File

@ -0,0 +1,215 @@
import classNames from 'classnames';
import _ from 'lodash';
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import { icons, tooltipPositions } from 'Helpers/Props';
import styles from './ReleaseSceneIndicator.css';
function formatReleaseNumber(
seasonNumber,
episodeNumbers,
absoluteEpisodeNumbers
) {
if (episodeNumbers && episodeNumbers.length) {
if (episodeNumbers.length > 1) {
return `${seasonNumber}x${episodeNumbers[0]}-${
episodeNumbers[episodeNumbers.length - 1]
}`;
}
return `${seasonNumber}x${episodeNumbers[0]}`;
}
if (absoluteEpisodeNumbers && absoluteEpisodeNumbers.length) {
if (absoluteEpisodeNumbers.length > 1) {
return `${absoluteEpisodeNumbers[0]}-${
absoluteEpisodeNumbers[absoluteEpisodeNumbers.length - 1]
}`;
}
return absoluteEpisodeNumbers[0];
}
if (seasonNumber !== undefined) {
return `Season ${seasonNumber}`;
}
return null;
}
interface ReleaseSceneIndicatorProps {
className: string;
seasonNumber?: number;
episodeNumbers?: number[];
absoluteEpisodeNumbers?: number[];
sceneSeasonNumber?: number;
sceneEpisodeNumbers?: number[];
sceneAbsoluteEpisodeNumbers?: number[];
sceneMapping?: {
sceneOrigin?: string;
title?: string;
comment?: string;
};
episodeRequested: boolean;
isDaily: boolean;
}
function ReleaseSceneIndicator(props: ReleaseSceneIndicatorProps) {
const {
className,
seasonNumber,
episodeNumbers,
absoluteEpisodeNumbers,
sceneSeasonNumber,
sceneEpisodeNumbers,
sceneAbsoluteEpisodeNumbers,
sceneMapping = {},
episodeRequested,
isDaily,
} = props;
const { sceneOrigin, title, comment } = sceneMapping;
if (isDaily) {
return null;
}
let mappingDifferent =
sceneSeasonNumber !== undefined && seasonNumber !== sceneSeasonNumber;
if (sceneEpisodeNumbers !== undefined) {
mappingDifferent =
mappingDifferent || !_.isEqual(sceneEpisodeNumbers, episodeNumbers);
} else if (sceneAbsoluteEpisodeNumbers !== undefined) {
mappingDifferent =
mappingDifferent ||
!_.isEqual(sceneAbsoluteEpisodeNumbers, absoluteEpisodeNumbers);
}
if (!sceneMapping && !mappingDifferent) {
return null;
}
const releaseNumber = formatReleaseNumber(
sceneSeasonNumber,
sceneEpisodeNumbers,
sceneAbsoluteEpisodeNumbers
);
const mappedNumber = formatReleaseNumber(
seasonNumber,
episodeNumbers,
absoluteEpisodeNumbers
);
const messages = [];
const isMixed = sceneOrigin === 'mixed';
const isUnknown = sceneOrigin === 'unknown' || sceneOrigin === 'unknown:tvdb';
let level = styles.levelNone;
if (isMixed) {
level = styles.levelMixed;
messages.push(
<div key="source">
{comment ?? 'Source'} releases exist with ambiguous numbering, unable to
reliably identify episode.
</div>
);
} else if (isUnknown) {
level = styles.levelUnknown;
messages.push(
<div key="unknown">
Numbering varies for this episode and release does not match any known
mappings.
</div>
);
if (sceneOrigin === 'unknown') {
messages.push(<div key="origin">Assuming Scene numbering.</div>);
} else if (sceneOrigin === 'unknown:tvdb') {
messages.push(<div key="origin">Assuming TheTVDB numbering.</div>);
}
} else if (mappingDifferent) {
level = styles.levelMapped;
} else if (sceneOrigin) {
level = styles.levelNormal;
}
if (!episodeRequested) {
if (!isMixed && !isUnknown) {
level = styles.levelNotRequested;
}
if (mappedNumber) {
messages.push(
<div key="not-requested">
Mapped episode wasn't requested in this search.
</div>
);
} else {
messages.push(<div key="unknown-series">Unknown episode or series.</div>);
}
}
const table = (
<DescriptionList className={styles.descriptionList}>
{comment !== undefined && (
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="Mapping"
data={comment}
/>
)}
{title !== undefined && (
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="Title"
data={title}
/>
)}
{releaseNumber !== undefined && (
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="Release"
data={releaseNumber ?? 'unknown'}
/>
)}
{releaseNumber !== undefined && (
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title="TheTVDB"
data={mappedNumber ?? 'unknown'}
/>
)}
</DescriptionList>
);
return (
<Popover
anchor={
<div className={classNames(level, styles.container, className)}>
<Icon name={icons.SCENE_MAPPING} />
</div>
}
title="Scene Info"
body={
<div>
{table}
{(messages.length && (
<div className={styles.messages}>{messages}</div>
)) ||
null}
</div>
}
position={tooltipPositions.RIGHT}
/>
);
}
export default ReleaseSceneIndicator;

View File

@ -1,5 +1,7 @@
import ModelBase from 'App/ModelBase';
export type SeriesType = 'anime' | 'daily' | 'standard';
export interface Image {
coverType: string;
url: string;
@ -59,7 +61,7 @@ interface Series extends ModelBase {
runtime: number;
seasonFolder: boolean;
seasons: Season[];
seriesType: string;
seriesType: SeriesType;
sortTitle: string;
statistics: Statistics;
status: string;

View File

@ -0,0 +1,21 @@
import { createSelector } from 'reselect';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByName from 'Utilities/Array/sortByName';
export default function createEnabledDownloadClientsSelector(
protocol: DownloadProtocol
) {
return createSelector(
createSortedSectionSelector('settings.downloadClients', sortByName),
(downloadClients) => {
const { isFetching, isPopulated, error, items } = downloadClients;
const clients = items.filter(
(item) => item.protocol === protocol && item.enable
);
return { isFetching, isPopulated, error, items: clients };
}
);
}

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using FizzWare.NBuilder;
using FluentAssertions;
@ -59,7 +59,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
}
[Test]
public void should_download_report_if_epsiode_was_not_already_downloaded()
public void should_download_report_if_episode_was_not_already_downloaded()
{
var episodes = new List<Episode> { GetEpisode(1) };
var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p));
@ -68,7 +68,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteEpisode));
Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>()), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>(), null), Times.Once());
}
[Test]
@ -82,7 +82,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteEpisode));
Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>()), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>(), null), Times.Once());
}
[Test]
@ -101,7 +101,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteEpisode2));
Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>()), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>(), null), Times.Once());
}
[Test]
@ -166,7 +166,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteEpisode));
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteEpisode>())).Throws(new Exception());
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteEpisode>(), null)).Throws(new Exception());
Subject.ProcessDecisions(decisions).Grabbed.Should().BeEmpty();
ExceptionVerification.ExpectedWarns(1);
}
@ -191,7 +191,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary)));
Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>()), Times.Never());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>(), null), Times.Never());
}
[Test]
@ -232,11 +232,11 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteEpisode));
decisions.Add(new DownloadDecision(remoteEpisode));
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteEpisode>()))
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteEpisode>(), null))
.Throws(new DownloadClientUnavailableException("Download client failed"));
Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>()), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>(), null), Times.Once());
}
[Test]
@ -250,12 +250,12 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteEpisode));
decisions.Add(new DownloadDecision(remoteEpisode2));
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.Is<RemoteEpisode>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet)))
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.Is<RemoteEpisode>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet), null))
.Throws(new DownloadClientUnavailableException("Download client failed"));
Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteEpisode>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet)), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteEpisode>(r => r.Release.DownloadProtocol == DownloadProtocol.Torrent)), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteEpisode>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet), null), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteEpisode>(r => r.Release.DownloadProtocol == DownloadProtocol.Torrent), null), Times.Once());
}
[Test]
@ -268,7 +268,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteEpisode));
Mocker.GetMock<IDownloadService>()
.Setup(s => s.DownloadReport(It.IsAny<RemoteEpisode>()))
.Setup(s => s.DownloadReport(It.IsAny<RemoteEpisode>(), null))
.Throws(new ReleaseUnavailableException(remoteEpisode.Release, "That 404 Error is not just a Quirk"));
var result = Subject.ProcessDecisions(decisions);

View File

@ -84,7 +84,7 @@ namespace NzbDrone.Core.Test.Download
var mock = WithUsenetClient();
mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>()));
Subject.DownloadReport(_parseResult);
Subject.DownloadReport(_parseResult, null);
VerifyEventPublished<EpisodeGrabbedEvent>();
}
@ -95,7 +95,7 @@ namespace NzbDrone.Core.Test.Download
var mock = WithUsenetClient();
mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>()));
Subject.DownloadReport(_parseResult);
Subject.DownloadReport(_parseResult, null);
mock.Verify(s => s.Download(It.IsAny<RemoteEpisode>()), Times.Once());
}
@ -107,7 +107,7 @@ namespace NzbDrone.Core.Test.Download
mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>()))
.Throws(new WebException());
Assert.Throws<WebException>(() => Subject.DownloadReport(_parseResult));
Assert.Throws<WebException>(() => Subject.DownloadReport(_parseResult, null));
VerifyEventNotPublished<EpisodeGrabbedEvent>();
}
@ -122,7 +122,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseDownloadException(v.Release, "Error", new WebException());
});
Assert.Throws<ReleaseDownloadException>(() => Subject.DownloadReport(_parseResult));
Assert.Throws<ReleaseDownloadException>(() => Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Once());
@ -142,7 +142,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response));
});
Assert.Throws<ReleaseDownloadException>(() => Subject.DownloadReport(_parseResult));
Assert.Throws<ReleaseDownloadException>(() => Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), TimeSpan.FromMinutes(5.0)), Times.Once());
@ -162,7 +162,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response));
});
Assert.Throws<ReleaseDownloadException>(() => Subject.DownloadReport(_parseResult));
Assert.Throws<ReleaseDownloadException>(() => Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(),
@ -176,7 +176,7 @@ namespace NzbDrone.Core.Test.Download
mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>()))
.Throws(new DownloadClientException("Some Error"));
Assert.Throws<DownloadClientException>(() => Subject.DownloadReport(_parseResult));
Assert.Throws<DownloadClientException>(() => Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Never());
@ -192,7 +192,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseUnavailableException(v.Release, "Error", new WebException());
});
Assert.Throws<ReleaseUnavailableException>(() => Subject.DownloadReport(_parseResult));
Assert.Throws<ReleaseUnavailableException>(() => Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Never());
@ -201,7 +201,7 @@ namespace NzbDrone.Core.Test.Download
[Test]
public void should_not_attempt_download_if_client_isnt_configured()
{
Assert.Throws<DownloadClientUnavailableException>(() => Subject.DownloadReport(_parseResult));
Assert.Throws<DownloadClientUnavailableException>(() => Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IDownloadClient>().Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Never());
VerifyEventNotPublished<EpisodeGrabbedEvent>();
@ -223,7 +223,7 @@ namespace NzbDrone.Core.Test.Download
}
});
Subject.DownloadReport(_parseResult);
Subject.DownloadReport(_parseResult, null);
Mocker.GetMock<IDownloadClientStatusService>().Verify(c => c.GetBlockedProviders(), Times.Never());
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Once());
@ -236,7 +236,7 @@ namespace NzbDrone.Core.Test.Download
var mockTorrent = WithTorrentClient();
var mockUsenet = WithUsenetClient();
Subject.DownloadReport(_parseResult);
Subject.DownloadReport(_parseResult, null);
mockTorrent.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Never());
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Once());
@ -250,7 +250,7 @@ namespace NzbDrone.Core.Test.Download
_parseResult.Release.DownloadProtocol = DownloadProtocol.Torrent;
Subject.DownloadReport(_parseResult);
Subject.DownloadReport(_parseResult, null);
mockTorrent.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Once());
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Never());

View File

@ -15,7 +15,7 @@ namespace NzbDrone.Core.Download
{
public interface IDownloadService
{
void DownloadReport(RemoteEpisode remoteEpisode);
void DownloadReport(RemoteEpisode remoteEpisode, int? downloadClientId);
}
public class DownloadService : IDownloadService
@ -45,13 +45,21 @@ namespace NzbDrone.Core.Download
_logger = logger;
}
public void DownloadReport(RemoteEpisode remoteEpisode)
public void DownloadReport(RemoteEpisode remoteEpisode, int? downloadClientId)
{
var downloadClient = downloadClientId.HasValue
? _downloadClientProvider.Get(downloadClientId.Value)
: _downloadClientProvider.GetDownloadClient(remoteEpisode.Release.DownloadProtocol, remoteEpisode.Release.IndexerId);
DownloadReport(remoteEpisode, downloadClient);
}
private void DownloadReport(RemoteEpisode remoteEpisode, IDownloadClient downloadClient)
{
Ensure.That(remoteEpisode.Series, () => remoteEpisode.Series).IsNotNull();
Ensure.That(remoteEpisode.Episodes, () => remoteEpisode.Episodes).HasItems();
var downloadTitle = remoteEpisode.Release.Title;
var downloadClient = _downloadClientProvider.GetDownloadClient(remoteEpisode.Release.DownloadProtocol, remoteEpisode.Release.IndexerId);
if (downloadClient == null)
{

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
@ -74,7 +74,7 @@ namespace NzbDrone.Core.Download
try
{
_logger.Trace("Grabbing from Indexer {0} at priority {1}.", remoteEpisode.Release.Indexer, remoteEpisode.Release.IndexerPriority);
_downloadService.DownloadReport(remoteEpisode);
_downloadService.DownloadReport(remoteEpisode, null);
grabbed.Add(report);
}
catch (ReleaseUnavailableException)

View File

@ -4,6 +4,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using NLog;
using NzbDrone.Common.Cache;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Download;
@ -79,6 +80,23 @@ namespace Sonarr.Api.V3.Indexers
try
{
if (release.ShouldOverride == true)
{
Ensure.That(release.SeriesId, () => release.SeriesId).IsNotNull();
Ensure.That(release.EpisodeIds, () => release.EpisodeIds).IsNotNull();
Ensure.That(release.EpisodeIds, () => release.EpisodeIds).HasItems();
Ensure.That(release.Quality, () => release.Quality).IsNotNull();
Ensure.That(release.Languages, () => release.Languages).IsNotNull();
// Clone the remote episode so we don't overwrite anything on the original
remoteEpisode = remoteEpisode.JsonClone();
remoteEpisode.Series = _seriesService.GetSeries(release.SeriesId.Value);
remoteEpisode.Episodes = _episodeService.GetEpisodes(release.EpisodeIds);
remoteEpisode.ParsedEpisodeInfo.Quality = release.Quality;
remoteEpisode.Languages = release.Languages;
}
if (remoteEpisode.Series == null)
{
if (release.EpisodeId.HasValue)
@ -125,7 +143,7 @@ namespace Sonarr.Api.V3.Indexers
throw new NzbDroneClientException(HttpStatusCode.NotFound, "Unable to parse episodes in the release");
}
_downloadService.DownloadReport(remoteEpisode);
_downloadService.DownloadReport(remoteEpisode, release.DownloadClientId);
}
catch (ReleaseDownloadException ex)
{

View File

@ -7,6 +7,7 @@ using NzbDrone.Core.Indexers;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Tv;
using Sonarr.Api.V3.CustomFormats;
using Sonarr.Api.V3.Series;
using Sonarr.Http.REST;
@ -40,6 +41,8 @@ namespace Sonarr.Api.V3.Indexers
public int? MappedSeasonNumber { get; set; }
public int[] MappedEpisodeNumbers { get; set; }
public int[] MappedAbsoluteEpisodeNumbers { get; set; }
public int? MappedSeriesId { get; set; }
public IEnumerable<ReleaseEpisodeResource> MappedEpisodeInfo { get; set; }
public bool Approved { get; set; }
public bool TemporarilyRejected { get; set; }
public bool Rejected { get; set; }
@ -75,6 +78,15 @@ namespace Sonarr.Api.V3.Indexers
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int? EpisodeId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public List<int> EpisodeIds { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int? DownloadClientId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool? ShouldOverride { get; set; }
}
public static class ReleaseResourceMapper
@ -109,9 +121,11 @@ namespace Sonarr.Api.V3.Indexers
SeriesTitle = parsedEpisodeInfo.SeriesTitle,
EpisodeNumbers = parsedEpisodeInfo.EpisodeNumbers,
AbsoluteEpisodeNumbers = parsedEpisodeInfo.AbsoluteEpisodeNumbers,
MappedSeriesId = remoteEpisode.Series?.Id,
MappedSeasonNumber = remoteEpisode.Episodes.FirstOrDefault()?.SeasonNumber,
MappedEpisodeNumbers = remoteEpisode.Episodes.Select(v => v.EpisodeNumber).ToArray(),
MappedAbsoluteEpisodeNumbers = remoteEpisode.Episodes.Where(v => v.AbsoluteEpisodeNumber.HasValue).Select(v => v.AbsoluteEpisodeNumber.Value).ToArray(),
MappedEpisodeInfo = remoteEpisode.Episodes.Select(v => new ReleaseEpisodeResource(v)),
Approved = model.Approved,
TemporarilyRejected = model.TemporarilyRejected,
Rejected = model.Rejected,
@ -178,4 +192,26 @@ namespace Sonarr.Api.V3.Indexers
return model;
}
}
public class ReleaseEpisodeResource
{
public int Id { get; set; }
public int SeasonNumber { get; set; }
public int EpisodeNumber { get; set; }
public int? AbsoluteEpisodeNumber { get; set; }
public string Title { get; set; }
public ReleaseEpisodeResource()
{
}
public ReleaseEpisodeResource(Episode episode)
{
Id = episode.Id;
SeasonNumber = episode.SeasonNumber;
EpisodeNumber = episode.EpisodeNumber;
AbsoluteEpisodeNumber = episode.AbsoluteEpisodeNumber;
Title = episode.Title;
}
}
}

View File

@ -29,7 +29,7 @@ namespace Sonarr.Api.V3.Queue
throw new NotFoundException();
}
_downloadService.DownloadReport(pendingRelease.RemoteEpisode);
_downloadService.DownloadReport(pendingRelease.RemoteEpisode, null);
return new { };
}
@ -47,7 +47,7 @@ namespace Sonarr.Api.V3.Queue
throw new NotFoundException();
}
_downloadService.DownloadReport(pendingRelease.RemoteEpisode);
_downloadService.DownloadReport(pendingRelease.RemoteEpisode, null);
}
return new { };