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

(cherry picked from commit 07f0fbf9a51d54e44681fd0f74df4e048bff561a)
pull/9066/head
Mark McDowall 1 year ago committed by Bogdan
parent 07b69e665d
commit 9b4f80535e

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

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

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

@ -32,7 +32,11 @@ const columns = [
}, },
{ {
name: 'rejections', name: 'rejections',
label: React.createElement(Icon, { name: icons.DANGER }), columnLabel: () => translate('Rejections'),
label: React.createElement(Icon, {
name: icons.DANGER,
title: () => translate('Rejections')
}),
isSortable: true, isSortable: true,
fixedSortDirection: sortDirections.ASCENDING, fixedSortDirection: sortDirections.ASCENDING,
isVisible: true isVisible: true
@ -88,6 +92,7 @@ const columns = [
}, },
{ {
name: 'customFormatScore', name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, { label: React.createElement(Icon, {
name: icons.SCORE, name: icons.SCORE,
title: () => translate('CustomFormatScore') title: () => translate('CustomFormatScore')
@ -97,7 +102,11 @@ const columns = [
}, },
{ {
name: 'indexerFlags', name: 'indexerFlags',
label: React.createElement(Icon, { name: icons.FLAG }), columnLabel: () => translate('IndexerFlags'),
label: React.createElement(Icon, {
name: icons.FLAG,
title: () => translate('IndexerFlags')
}),
isSortable: true, isSortable: true,
isVisible: true isVisible: true
} }

@ -16,11 +16,11 @@
.quality, .quality,
.customFormat, .customFormat,
.language { .languages {
composes: cell; composes: cell;
} }
.language { .languages {
width: 100px; width: 100px;
} }
@ -33,8 +33,7 @@
} }
.rejected, .rejected,
.indexerFlags, .indexerFlags {
.download {
composes: cell; composes: cell;
width: 50px; width: 50px;
@ -70,3 +69,39 @@
.blocklist { .blocklist {
margin-left: 5px; margin-left: 5px;
} }
.download {
composes: cell;
width: 80px;
}
.manualDownloadContent {
position: relative;
display: inline-block;
margin: 0 2px;
width: 22px;
height: 20.39px;
vertical-align: middle;
line-height: 20.39px;
&:hover {
color: var(--iconButtonHoverColor);
}
}
.interactiveIcon {
position: absolute;
top: 4px;
left: 0;
/* width: 100%; */
text-align: center;
}
.downloadIcon {
position: absolute;
top: 7px;
left: 8px;
/* width: 100%; */
text-align: center;
}

@ -7,10 +7,13 @@ interface CssExports {
'customFormat': string; 'customFormat': string;
'customFormatScore': string; 'customFormatScore': string;
'download': string; 'download': string;
'downloadIcon': string;
'history': string; 'history': string;
'indexer': string; 'indexer': string;
'indexerFlags': string; 'indexerFlags': string;
'language': string; 'interactiveIcon': string;
'languages': string;
'manualDownloadContent': string;
'peers': string; 'peers': string;
'protocol': string; 'protocol': string;
'quality': string; 'quality': string;

@ -1,365 +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 { icons, kinds, tooltipPositions } from 'Helpers/Props';
import MovieFormats from 'Movie/MovieFormats';
import MovieLanguage from 'Movie/MovieLanguage';
import MovieQuality from 'Movie/MovieQuality';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import Peers from './Peers';
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 getDownloadKind(isGrabbed, grabError) {
if (isGrabbed) {
return kinds.SUCCESS;
}
if (grabError) {
return kinds.DANGER;
}
return kinds.DEFAULT;
}
function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
if (isGrabbing) {
return '';
} else if (isGrabbed) {
return translate('AddedToDownloadQueue');
} else if (grabError) {
return grabError;
}
return translate('AddToDownloadQueue');
}
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,
customFormats,
customFormatScore,
languages,
indexerFlags,
rejections,
downloadAllowed,
isGrabbing,
isGrabbed,
longDateFormat,
timeFormat,
grabError,
historyGrabbedData,
historyFailedData,
blocklistData
} = 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 className={styles.download}>
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={getDownloadKind(isGrabbed, grabError)}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isDisabled={isGrabbed}
isSpinning={isGrabbing}
onPress={downloadAllowed ? this.onGrabPress : this.onConfirmGrabPress}
/>
</TableRowCell>
<TableRowCell className={styles.rejected}>
{
!!rejections.length &&
<Popover
anchor={
<Icon
name={icons.DANGER}
kind={kinds.DANGER}
/>
}
title={translate('ReleaseRejected')}
body={
<ul>
{
rejections.map((rejection, index) => {
return (
<li key={index}>
{rejection}
</li>
);
})
}
</ul>
}
position={tooltipPositions.BOTTOM}
/>
}
</TableRowCell>
<TableRowCell className={styles.title}>
<Link
to={infoUrl}
title={title}
>
<div>
{title}
</div>
</Link>
</TableRowCell>
<TableRowCell className={styles.indexer}>
{indexer}
</TableRowCell>
<TableRowCell className={styles.history}>
{
historyGrabbedData?.date && !historyFailedData?.date &&
<Icon
name={icons.DOWNLOADING}
kind={kinds.DEFAULT}
title={`${translate('Grabbed')}: ${formatDateTime(historyGrabbedData.date, longDateFormat, timeFormat, { includeSeconds: true })}`}
/>
}
{
historyFailedData?.date &&
<Icon
className={styles.failed}
name={icons.DOWNLOADING}
kind={kinds.DANGER}
title={`${translate('Failed')}: ${formatDateTime(historyFailedData.date, longDateFormat, timeFormat, { includeSeconds: true })}`}
/>
}
{
blocklistData?.date &&
<Icon
className={historyGrabbedData || historyFailedData ? styles.blocklist : ''}
name={icons.BLOCKLIST}
kind={kinds.DANGER}
title={`${translate('Blocklisted')}: ${formatDateTime(blocklistData.date, longDateFormat, timeFormat, { includeSeconds: true })}`}
/>
}
</TableRowCell>
<TableRowCell className={styles.size}>
{formatBytes(size)}
</TableRowCell>
<TableRowCell className={styles.peers}>
{
protocol === 'torrent' &&
<Peers
seeders={seeders}
leechers={leechers}
/>
}
</TableRowCell>
<TableRowCell className={styles.language}>
<MovieLanguage
languages={languages}
/>
</TableRowCell>
<TableRowCell className={styles.quality}>
<MovieQuality
quality={quality}
/>
</TableRowCell>
<TableRowCell className={styles.customFormat}>
<MovieFormats
formats={customFormats}
/>
</TableRowCell>
<TableRowCell className={styles.customFormatScore}>
{customFormatScore > 0 && `+${customFormatScore}`}
{customFormatScore < 0 && customFormatScore}
</TableRowCell>
<TableRowCell className={styles.indexerFlags}>
{
!!indexerFlags.length &&
<Popover
anchor={
<Icon
name={icons.FLAG}
kind={kinds.PRIMARY}
/>
}
title={translate('IndexerFlags')}
body={
<ul>
{
indexerFlags.map((flag, index) => {
return (
<li key={index}>
{flag}
</li>
);
})
}
</ul>
}
position={tooltipPositions.BOTTOM}
/>
}
</TableRowCell>
<ConfirmModal
isOpen={this.state.isConfirmGrabModalOpen}
kind={kinds.WARNING}
title={translate('GrabRelease')}
message={translate('GrabReleaseMessageText', [title])}
confirmLabel={translate('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,
customFormats: PropTypes.arrayOf(PropTypes.object).isRequired,
customFormatScore: PropTypes.number.isRequired,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
rejections: PropTypes.arrayOf(PropTypes.string).isRequired,
indexerFlags: PropTypes.arrayOf(PropTypes.string).isRequired,
downloadAllowed: 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,
historyFailedData: PropTypes.object,
historyGrabbedData: PropTypes.object,
blocklistData: PropTypes.object
};
InteractiveSearchRow.defaultProps = {
rejections: [],
isGrabbing: false,
isGrabbed: false
};
export default InteractiveSearchRow;

@ -0,0 +1,378 @@
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 { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import MovieFormats from 'Movie/MovieFormats';
import MovieLanguage from 'Movie/MovieLanguage';
import MovieQuality from 'Movie/MovieQuality';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
import MovieBlocklist from 'typings/MovieBlocklist';
import MovieHistory from 'typings/MovieHistory';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import OverrideMatchModal from './OverrideMatch/OverrideMatchModal';
import Peers from './Peers';
import styles from './InteractiveSearchRow.css';
function getDownloadIcon(
isGrabbing: boolean,
isGrabbed: boolean,
grabError?: string
) {
if (isGrabbing) {
return icons.SPINNER;
} else if (isGrabbed) {
return icons.DOWNLOADING;
} else if (grabError) {
return icons.DOWNLOADING;
}
return icons.DOWNLOAD;
}
function getDownloadKind(isGrabbed: boolean, grabError?: string) {
if (isGrabbed) {
return kinds.SUCCESS;
}
if (grabError) {
return kinds.DANGER;
}
return kinds.DEFAULT;
}
function getDownloadTooltip(
isGrabbing: boolean,
isGrabbed: boolean,
grabError?: string
) {
if (isGrabbing) {
return '';
} else if (isGrabbed) {
return translate('AddToDownloadQueue');
} else if (grabError) {
return grabError;
}
return translate('AddedToDownloadQueue');
}
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: CustomFormat[];
customFormatScore: number;
mappedMovieId?: number;
rejections: string[];
indexerFlags: string[];
episodeRequested: boolean;
downloadAllowed: boolean;
isDaily: boolean;
isGrabbing: boolean;
isGrabbed: boolean;
grabError?: string;
historyFailedData?: MovieHistory;
historyGrabbedData?: MovieHistory;
blocklistData?: MovieBlocklist;
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,
mappedMovieId,
rejections = [],
indexerFlags = [],
downloadAllowed,
isGrabbing = false,
isGrabbed = false,
longDateFormat,
timeFormat,
grabError,
historyGrabbedData,
historyFailedData,
blocklistData,
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 className={styles.download}>
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={getDownloadKind(isGrabbed, grabError)}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isSpinning={isGrabbing}
onPress={onGrabPressWrapper}
/>
<Link
className={styles.manualDownloadContent}
title={translate('OverrideAndAddToDownloadQueue')}
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>
<TableRowCell className={styles.rejected}>
{rejections.length ? (
<Popover
anchor={<Icon name={icons.DANGER} kind={kinds.DANGER} />}
title={translate('ReleaseRejected')}
body={
<ul>
{rejections.map((rejection, index) => {
return <li key={index}>{rejection}</li>;
})}
</ul>
}
position={tooltipPositions.RIGHT}
/>
) : null}
</TableRowCell>
<TableRowCell className={styles.title}>
<Link to={infoUrl} title={title}>
<div>{title}</div>
</Link>
</TableRowCell>
<TableRowCell className={styles.indexer}>{indexer}</TableRowCell>
<TableRowCell className={styles.history}>
{historyGrabbedData?.date && !historyFailedData?.date ? (
<Icon
name={icons.DOWNLOADING}
kind={kinds.DEFAULT}
title={`${translate('Grabbed')}: ${formatDateTime(
historyGrabbedData.date,
longDateFormat,
timeFormat,
{ includeSeconds: true }
)}`}
/>
) : null}
{historyFailedData?.date ? (
<Icon
name={icons.DOWNLOADING}
kind={kinds.DANGER}
title={`${translate('Failed')}: ${formatDateTime(
historyFailedData.date,
longDateFormat,
timeFormat,
{ includeSeconds: true }
)}`}
/>
) : null}
{blocklistData?.date ? (
<Icon
className={
historyGrabbedData || historyFailedData ? styles.blocklist : ''
}
name={icons.BLOCKLIST}
kind={kinds.DANGER}
title={`${translate('Blocklisted')}: ${formatDateTime(
blocklistData.date,
longDateFormat,
timeFormat,
{ includeSeconds: true }
)}`}
/>
) : null}
</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}>
<MovieLanguage languages={languages} />
</TableRowCell>
<TableRowCell className={styles.quality}>
<MovieQuality quality={quality} />
</TableRowCell>
<TableRowCell className={styles.customFormat}>
<MovieFormats formats={customFormats} />
</TableRowCell>
<TableRowCell className={styles.customFormatScore}>
<Tooltip
anchor={formatCustomFormatScore(
customFormatScore,
customFormats.length
)}
tooltip={<MovieFormats formats={customFormats} />}
position={tooltipPositions.TOP}
/>
</TableRowCell>
<TableRowCell className={styles.indexerFlags}>
{indexerFlags.length ? (
<Popover
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
title={translate('IndexerFlags')}
body={
<ul>
{indexerFlags.map((flag, index) => {
return <li key={index}>{flag}</li>;
})}
</ul>
}
position={tooltipPositions.LEFT}
/>
) : null}
</TableRowCell>
<ConfirmModal
isOpen={isConfirmGrabModalOpen}
kind={kinds.WARNING}
title={translate('GrabRelease')}
message={translate('GrabReleaseMessageText', { title })}
confirmLabel={translate('Grab')}
onConfirm={onGrabConfirm}
onCancel={onGrabCancel}
/>
<OverrideMatchModal
isOpen={isOverrideModalOpen}
title={title}
indexerId={indexerId}
guid={guid}
movieId={mappedMovieId}
languages={languages}
quality={quality}
protocol={protocol}
isGrabbing={isGrabbing}
grabError={grabError}
onModalClose={onOverrideModalClose}
/>
</TableRow>
);
}
export default InteractiveSearchRow;

@ -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;

@ -0,0 +1,74 @@
import React from 'react';
import { useSelector } from 'react-redux';
import Alert from 'Components/Alert';
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 { kinds } from 'Helpers/Props';
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>
{translate('SelectDownloadClientModalTitle', { modalTitle })}
</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>
{translate('DownloadClientsLoadError')}
</Alert>
) : 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;

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

@ -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;

@ -0,0 +1,32 @@
import React, { useCallback } from 'react';
import Link from 'Components/Link/Link';
import translate from 'Utilities/String/translate';
import styles from './SelectDownloadClientRow.css';
interface SelectSeasonRowProps {
id: number;
name: string;
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>{translate('PrioritySettings', { priority })}</div>
</Link>
);
}
export default SelectDownloadClientRow;

@ -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);
}

@ -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;

@ -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;

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

@ -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;
}
}

@ -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;

@ -0,0 +1,299 @@
import React, { useCallback, useEffect, 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 usePrevious from 'Helpers/Hooks/usePrevious';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import SelectMovieModal from 'InteractiveImport/Movie/SelectMovieModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import Language from 'Language/Language';
import Movie from 'Movie/Movie';
import MovieLanguage from 'Movie/MovieLanguage';
import MovieQuality from 'Movie/MovieQuality';
import { QualityModel } from 'Quality/Quality';
import { grabRelease } from 'Store/Actions/releaseActions';
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
import { createMovieSelectorForHook } from 'Store/Selectors/createMovieSelector';
import translate from 'Utilities/String/translate';
import SelectDownloadClientModal from './DownloadClient/SelectDownloadClientModal';
import OverrideMatchData from './OverrideMatchData';
import styles from './OverrideMatchModalContent.css';
type SelectType =
| 'select'
| 'movie'
| 'quality'
| 'language'
| 'downloadClient';
interface OverrideMatchModalContentProps {
indexerId: number;
title: string;
guid: string;
movieId?: number;
languages: Language[];
quality: QualityModel;
protocol: DownloadProtocol;
isGrabbing: boolean;
grabError?: string;
onModalClose(): void;
}
function OverrideMatchModalContent(props: OverrideMatchModalContentProps) {
const modalTitle = translate('ManualGrab');
const {
indexerId,
title,
guid,
protocol,
isGrabbing,
grabError,
onModalClose,
} = props;
const [movieId, setMovieId] = useState(props.movieId);
const [languages, setLanguages] = useState(props.languages);
const [quality, setQuality] = useState(props.quality);
const [downloadClientId, setDownloadClientId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectModalOpen, setSelectModalOpen] = useState<SelectType | null>(
null
);
const previousIsGrabbing = usePrevious(isGrabbing);
const dispatch = useDispatch();
const movie: Movie | undefined = useSelector(
createMovieSelectorForHook(movieId)
);
const { items: downloadClients } = useSelector(
createEnabledDownloadClientsSelector(protocol)
);
const onSelectModalClose = useCallback(() => {
setSelectModalOpen(null);
}, [setSelectModalOpen]);
const onSelectMoviePress = useCallback(() => {
setSelectModalOpen('movie');
}, [setSelectModalOpen]);
const onMovieSelect = useCallback(
(m: Movie) => {
setMovieId(m.id);
setSelectModalOpen(null);
},
[setMovieId, setSelectModalOpen]
);
const onSelectQualityPress = useCallback(() => {
setSelectModalOpen('quality');
}, [setSelectModalOpen]);
const onQualitySelect = useCallback(
(quality: QualityModel) => {
setQuality(quality);
setSelectModalOpen(null);
},
[setQuality, setSelectModalOpen]
);
const onSelectLanguagesPress = useCallback(() => {
setSelectModalOpen('language');
}, [setSelectModalOpen]);
const onLanguagesSelect = useCallback(
(languages: Language[]) => {
setLanguages(languages);
setSelectModalOpen(null);
},
[setLanguages, setSelectModalOpen]
);
const onSelectDownloadClientPress = useCallback(() => {
setSelectModalOpen('downloadClient');
}, [setSelectModalOpen]);
const onDownloadClientSelect = useCallback(
(downloadClientId: number) => {
setDownloadClientId(downloadClientId);
setSelectModalOpen(null);
},
[setDownloadClientId, setSelectModalOpen]
);
const onGrabPress = useCallback(() => {
if (!movieId) {
setError(translate('OverrideGrabNoMovie'));
return;
} else if (!quality) {
setError(translate('OverrideGrabNoQuality'));
return;
} else if (!languages.length) {
setError(translate('OverrideGrabNoLanguage'));
return;
}
dispatch(
grabRelease({
indexerId,
guid,
movieId,
quality,
languages,
downloadClientId,
shouldOverride: true,
})
);
}, [
indexerId,
guid,
movieId,
quality,
languages,
downloadClientId,
setError,
dispatch,
]);
useEffect(() => {
if (!isGrabbing && previousIsGrabbing) {
onModalClose();
}
}, [isGrabbing, previousIsGrabbing, onModalClose]);
useEffect(
() => {
dispatch(fetchDownloadClients());
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('OverrideGrabModalTitle', { title })}
</ModalHeader>
<ModalBody>
<DescriptionList>
<DescriptionListItem
className={styles.item}
title={translate('Movie')}
data={
<OverrideMatchData
value={movie?.title}
onPress={onSelectMoviePress}
/>
}
/>
<DescriptionListItem
className={styles.item}
title={translate('Quality')}
data={
<OverrideMatchData
value={
<MovieQuality className={styles.label} quality={quality} />
}
onPress={onSelectQualityPress}
/>
}
/>
<DescriptionListItem
className={styles.item}
title={translate('Languages')}
data={
<OverrideMatchData
value={
<MovieLanguage
className={styles.label}
languages={languages}
/>
}
onPress={onSelectLanguagesPress}
/>
}
/>
{downloadClients.length > 1 ? (
<DescriptionListItem
className={styles.item}
title={translate('DownloadClient')}
data={
<OverrideMatchData
value={
downloadClients.find(
(downloadClient) => downloadClient.id === downloadClientId
)?.name ?? translate('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('GrabRelease')}
</SpinnerErrorButton>
</div>
</ModalFooter>
<SelectMovieModal
isOpen={selectModalOpen === 'movie'}
modalTitle={modalTitle}
onMovieSelect={onMovieSelect}
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;

@ -0,0 +1,22 @@
import { createSelector } from 'reselect';
import { DownloadClientAppState } from 'App/State/SettingsAppState';
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: DownloadClientAppState) => {
const { isFetching, isPopulated, error, items } = downloadClients;
const clients = items.filter(
(item) => item.protocol === protocol && item.enable
);
return { isFetching, isPopulated, error, items: clients };
}
);
}

@ -0,0 +1,7 @@
import ModelBase from 'App/ModelBase';
interface MovieBlocklist extends ModelBase {
date: string;
}
export default MovieBlocklist;

@ -0,0 +1,7 @@
import ModelBase from 'App/ModelBase';
interface MovieHistory extends ModelBase {
date: string;
}
export default MovieHistory;

@ -80,7 +80,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteMovie)); decisions.Add(new DownloadDecision(remoteMovie));
await Subject.ProcessDecisions(decisions); await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteMovie>()), Times.Once()); Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteMovie>(), null), Times.Once());
} }
[Test] [Test]
@ -93,7 +93,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteMovie)); decisions.Add(new DownloadDecision(remoteMovie));
await Subject.ProcessDecisions(decisions); await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteMovie>()), Times.Once()); Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteMovie>(), null), Times.Once());
} }
[Test] [Test]
@ -110,7 +110,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteMovie2)); decisions.Add(new DownloadDecision(remoteMovie2));
await Subject.ProcessDecisions(decisions); await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteMovie>()), Times.Once()); Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteMovie>(), null), Times.Once());
} }
[Test] [Test]
@ -175,7 +175,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
var decisions = new List<DownloadDecision>(); var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteMovie)); decisions.Add(new DownloadDecision(remoteMovie));
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteMovie>())).Throws(new Exception()); Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteMovie>(), null)).Throws(new Exception());
var result = await Subject.ProcessDecisions(decisions); var result = await Subject.ProcessDecisions(decisions);
@ -204,7 +204,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteMovie, new Rejection("Failure!", RejectionType.Temporary))); decisions.Add(new DownloadDecision(remoteMovie, new Rejection("Failure!", RejectionType.Temporary)));
await Subject.ProcessDecisions(decisions); await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteMovie>()), Times.Never()); Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteMovie>(), null), Times.Never());
} }
[Test] [Test]
@ -242,11 +242,11 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteMovie)); decisions.Add(new DownloadDecision(remoteMovie));
decisions.Add(new DownloadDecision(remoteMovie)); decisions.Add(new DownloadDecision(remoteMovie));
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteMovie>())) Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteMovie>(), null))
.Throws(new DownloadClientUnavailableException("Download client failed")); .Throws(new DownloadClientUnavailableException("Download client failed"));
await Subject.ProcessDecisions(decisions); await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteMovie>()), Times.Once()); Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteMovie>(), null), Times.Once());
} }
[Test] [Test]
@ -259,12 +259,12 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteMovie)); decisions.Add(new DownloadDecision(remoteMovie));
decisions.Add(new DownloadDecision(remoteMovie2)); decisions.Add(new DownloadDecision(remoteMovie2));
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.Is<RemoteMovie>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet))) Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.Is<RemoteMovie>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet), null))
.Throws(new DownloadClientUnavailableException("Download client failed")); .Throws(new DownloadClientUnavailableException("Download client failed"));
await Subject.ProcessDecisions(decisions); await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteMovie>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet)), Times.Once()); Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteMovie>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet), null), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteMovie>(r => r.Release.DownloadProtocol == DownloadProtocol.Torrent)), Times.Once()); Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteMovie>(r => r.Release.DownloadProtocol == DownloadProtocol.Torrent), null), Times.Once());
} }
[Test] [Test]
@ -276,7 +276,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteMovie)); decisions.Add(new DownloadDecision(remoteMovie));
Mocker.GetMock<IDownloadService>() Mocker.GetMock<IDownloadService>()
.Setup(s => s.DownloadReport(It.IsAny<RemoteMovie>())) .Setup(s => s.DownloadReport(It.IsAny<RemoteMovie>(), null))
.Throws(new ReleaseUnavailableException(remoteMovie.Release, "That 404 Error is not just a Quirk")); .Throws(new ReleaseUnavailableException(remoteMovie.Release, "That 404 Error is not just a Quirk"));
var result = await Subject.ProcessDecisions(decisions); var result = await Subject.ProcessDecisions(decisions);

@ -76,7 +76,7 @@ namespace NzbDrone.Core.Test.Download
var mock = WithUsenetClient(); var mock = WithUsenetClient();
mock.Setup(s => s.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>())); mock.Setup(s => s.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()));
await Subject.DownloadReport(_parseResult); await Subject.DownloadReport(_parseResult, null);
VerifyEventPublished<MovieGrabbedEvent>(); VerifyEventPublished<MovieGrabbedEvent>();
} }
@ -87,7 +87,7 @@ namespace NzbDrone.Core.Test.Download
var mock = WithUsenetClient(); var mock = WithUsenetClient();
mock.Setup(s => s.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>())); mock.Setup(s => s.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()));
await Subject.DownloadReport(_parseResult); await Subject.DownloadReport(_parseResult, null);
mock.Verify(s => s.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()), Times.Once()); mock.Verify(s => s.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()), Times.Once());
} }
@ -99,7 +99,7 @@ namespace NzbDrone.Core.Test.Download
mock.Setup(s => s.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>())) mock.Setup(s => s.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()))
.Throws(new WebException()); .Throws(new WebException());
Assert.ThrowsAsync<WebException>(async () => await Subject.DownloadReport(_parseResult)); Assert.ThrowsAsync<WebException>(async () => await Subject.DownloadReport(_parseResult, null));
VerifyEventNotPublished<MovieGrabbedEvent>(); VerifyEventNotPublished<MovieGrabbedEvent>();
} }
@ -114,7 +114,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseDownloadException(v.Release, "Error", new WebException()); throw new ReleaseDownloadException(v.Release, "Error", new WebException());
}); });
Assert.ThrowsAsync<ReleaseDownloadException>(async () => await Subject.DownloadReport(_parseResult)); Assert.ThrowsAsync<ReleaseDownloadException>(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>() Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Once()); .Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Once());
@ -134,7 +134,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response)); throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response));
}); });
Assert.ThrowsAsync<ReleaseDownloadException>(async () => await Subject.DownloadReport(_parseResult)); Assert.ThrowsAsync<ReleaseDownloadException>(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>() Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), TimeSpan.FromMinutes(5.0)), Times.Once()); .Verify(v => v.RecordFailure(It.IsAny<int>(), TimeSpan.FromMinutes(5.0)), Times.Once());
@ -154,7 +154,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response)); throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response));
}); });
Assert.ThrowsAsync<ReleaseDownloadException>(async () => await Subject.DownloadReport(_parseResult)); Assert.ThrowsAsync<ReleaseDownloadException>(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>() Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), .Verify(v => v.RecordFailure(It.IsAny<int>(),
@ -168,7 +168,7 @@ namespace NzbDrone.Core.Test.Download
mock.Setup(s => s.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>())) mock.Setup(s => s.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()))
.Throws(new DownloadClientException("Some Error")); .Throws(new DownloadClientException("Some Error"));
Assert.ThrowsAsync<DownloadClientException>(async () => await Subject.DownloadReport(_parseResult)); Assert.ThrowsAsync<DownloadClientException>(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>() Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Never()); .Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Never());
@ -184,7 +184,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseUnavailableException(v.Release, "Error", new WebException()); throw new ReleaseUnavailableException(v.Release, "Error", new WebException());
}); });
Assert.ThrowsAsync<ReleaseUnavailableException>(async () => await Subject.DownloadReport(_parseResult)); Assert.ThrowsAsync<ReleaseUnavailableException>(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>() Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Never()); .Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Never());
@ -193,7 +193,7 @@ namespace NzbDrone.Core.Test.Download
[Test] [Test]
public void should_not_attempt_download_if_client_isnt_configured() public void should_not_attempt_download_if_client_isnt_configured()
{ {
Assert.ThrowsAsync<DownloadClientUnavailableException>(async () => await Subject.DownloadReport(_parseResult)); Assert.ThrowsAsync<DownloadClientUnavailableException>(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IDownloadClient>().Verify(c => c.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()), Times.Never()); Mocker.GetMock<IDownloadClient>().Verify(c => c.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()), Times.Never());
VerifyEventNotPublished<MovieGrabbedEvent>(); VerifyEventNotPublished<MovieGrabbedEvent>();
@ -215,7 +215,7 @@ namespace NzbDrone.Core.Test.Download
} }
}); });
await Subject.DownloadReport(_parseResult); await Subject.DownloadReport(_parseResult, null);
Mocker.GetMock<IDownloadClientStatusService>().Verify(c => c.GetBlockedProviders(), Times.Never()); Mocker.GetMock<IDownloadClientStatusService>().Verify(c => c.GetBlockedProviders(), Times.Never());
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()), Times.Once()); mockUsenet.Verify(c => c.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()), Times.Once());
@ -228,7 +228,7 @@ namespace NzbDrone.Core.Test.Download
var mockTorrent = WithTorrentClient(); var mockTorrent = WithTorrentClient();
var mockUsenet = WithUsenetClient(); var mockUsenet = WithUsenetClient();
await Subject.DownloadReport(_parseResult); await Subject.DownloadReport(_parseResult, null);
mockTorrent.Verify(c => c.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()), Times.Never()); mockTorrent.Verify(c => c.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()), Times.Never());
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()), Times.Once()); mockUsenet.Verify(c => c.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()), Times.Once());
@ -242,7 +242,7 @@ namespace NzbDrone.Core.Test.Download
_parseResult.Release.DownloadProtocol = DownloadProtocol.Torrent; _parseResult.Release.DownloadProtocol = DownloadProtocol.Torrent;
await Subject.DownloadReport(_parseResult); await Subject.DownloadReport(_parseResult, null);
mockTorrent.Verify(c => c.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()), Times.Once()); mockTorrent.Verify(c => c.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()), Times.Once());
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()), Times.Never()); mockUsenet.Verify(c => c.Download(It.IsAny<RemoteMovie>(), It.IsAny<IIndexer>()), Times.Never());

@ -17,7 +17,7 @@ namespace NzbDrone.Core.Download
{ {
public interface IDownloadService public interface IDownloadService
{ {
Task DownloadReport(RemoteMovie remoteMovie); Task DownloadReport(RemoteMovie remoteMovie, int? downloadClientId);
} }
public class DownloadService : IDownloadService public class DownloadService : IDownloadService
@ -50,13 +50,15 @@ namespace NzbDrone.Core.Download
_logger = logger; _logger = logger;
} }
public async Task DownloadReport(RemoteMovie remoteMovie) public async Task DownloadReport(RemoteMovie remoteMovie, int? downloadClientId)
{ {
var filterBlockedClients = remoteMovie.Release.PendingReleaseReason == PendingReleaseReason.DownloadClientUnavailable; var filterBlockedClients = remoteMovie.Release.PendingReleaseReason == PendingReleaseReason.DownloadClientUnavailable;
var tags = remoteMovie.Movie?.Tags; var tags = remoteMovie.Movie?.Tags;
var downloadClient = _downloadClientProvider.GetDownloadClient(remoteMovie.Release.DownloadProtocol, remoteMovie.Release.IndexerId, filterBlockedClients, tags); var downloadClient = downloadClientId.HasValue
? _downloadClientProvider.Get(downloadClientId.Value)
: _downloadClientProvider.GetDownloadClient(remoteMovie.Release.DownloadProtocol, remoteMovie.Release.IndexerId, filterBlockedClients, tags);
await DownloadReport(remoteMovie, downloadClient); await DownloadReport(remoteMovie, downloadClient);
} }

@ -74,7 +74,7 @@ namespace NzbDrone.Core.Download
try try
{ {
_logger.Trace("Grabbing from Indexer {0} at priority {1}.", remoteMovie.Release.Indexer, remoteMovie.Release.IndexerPriority); _logger.Trace("Grabbing from Indexer {0} at priority {1}.", remoteMovie.Release.Indexer, remoteMovie.Release.IndexerPriority);
await _downloadService.DownloadReport(remoteMovie); await _downloadService.DownloadReport(remoteMovie, null);
grabbed.Add(report); grabbed.Add(report);
} }
catch (ReleaseUnavailableException) catch (ReleaseUnavailableException)

@ -216,6 +216,7 @@
"Day": "Day", "Day": "Day",
"Days": "Days", "Days": "Days",
"Debug": "Debug", "Debug": "Debug",
"Default": "Default",
"DefaultCase": "Default Case", "DefaultCase": "Default Case",
"DefaultDelayProfile": "This is the default profile. It applies to all movies that don't have an explicit profile.", "DefaultDelayProfile": "This is the default profile. It applies to all movies that don't have an explicit profile.",
"DelayProfile": "Delay Profile", "DelayProfile": "Delay Profile",
@ -304,6 +305,7 @@
"DownloadClientTagHelpText": "Only use this download client for movies with at least one matching tag. Leave blank to use with all movies.", "DownloadClientTagHelpText": "Only use this download client for movies with at least one matching tag. Leave blank to use with all movies.",
"DownloadClientUnavailable": "Download client is unavailable", "DownloadClientUnavailable": "Download client is unavailable",
"DownloadClients": "Download Clients", "DownloadClients": "Download Clients",
"DownloadClientsLoadError": "Unable to load download clients",
"DownloadClientsSettingsSummary": "Download clients, download handling and remote path mappings", "DownloadClientsSettingsSummary": "Download clients, download handling and remote path mappings",
"DownloadFailed": "Download failed", "DownloadFailed": "Download failed",
"DownloadFailedCheckDownloadClientForMoreDetails": "Download failed: check download client for more details", "DownloadFailedCheckDownloadClientForMoreDetails": "Download failed: check download client for more details",
@ -568,6 +570,7 @@
"ManageIndexers": "Manage Indexers", "ManageIndexers": "Manage Indexers",
"ManageLists": "Manage Lists", "ManageLists": "Manage Lists",
"Manual": "Manual", "Manual": "Manual",
"ManualGrab": "Manual Grab",
"ManualImport": "Manual Import", "ManualImport": "Manual Import",
"ManualImportSelectLanguage": "Manual Import - Select Language", "ManualImportSelectLanguage": "Manual Import - Select Language",
"ManualImportSelectMovie": "Manual Import - Select Movie", "ManualImportSelectMovie": "Manual Import - Select Movie",
@ -756,6 +759,11 @@
"OriginalLanguage": "Original Language", "OriginalLanguage": "Original Language",
"OriginalTitle": "Original Title", "OriginalTitle": "Original Title",
"OutputPath": "Output Path", "OutputPath": "Output Path",
"OverrideAndAddToDownloadQueue": "Override and add to download queue",
"OverrideGrabModalTitle": "Override and Grab - {title}",
"OverrideGrabNoLanguage": "At least one language must be selected",
"OverrideGrabNoMovie": "Movie must be selected",
"OverrideGrabNoQuality": "Quality must be selected",
"Overview": "Overview", "Overview": "Overview",
"OverviewOptions": "Overview Options", "OverviewOptions": "Overview Options",
"PackageVersion": "Package Version", "PackageVersion": "Package Version",
@ -863,6 +871,7 @@
"RefreshMovie": "Refresh movie", "RefreshMovie": "Refresh movie",
"RegularExpressionsCanBeTested": "Regular expressions can be tested ", "RegularExpressionsCanBeTested": "Regular expressions can be tested ",
"RejectionCount": "Rejection Count", "RejectionCount": "Rejection Count",
"Rejections": "Rejections",
"RelativePath": "Relative Path", "RelativePath": "Relative Path",
"ReleaseBranchCheckOfficialBranchMessage": "Branch {0} is not a valid Radarr release branch, you will not receive updates", "ReleaseBranchCheckOfficialBranchMessage": "Branch {0} is not a valid Radarr release branch, you will not receive updates",
"ReleaseDates": "Release Dates", "ReleaseDates": "Release Dates",
@ -1002,6 +1011,7 @@
"Seeders": "Seeders", "Seeders": "Seeders",
"SelectAll": "Select All", "SelectAll": "Select All",
"SelectDotDot": "Select...", "SelectDotDot": "Select...",
"SelectDownloadClientModalTitle": "{modalTitle} - Select Download Client",
"SelectFolder": "Select Folder", "SelectFolder": "Select Folder",
"SelectLanguage": "Select Language", "SelectLanguage": "Select Language",
"SelectLanguages": "Select Languages", "SelectLanguages": "Select Languages",
@ -1181,7 +1191,6 @@
"UnableToLoadCustomFormats": "Unable to load Custom Formats", "UnableToLoadCustomFormats": "Unable to load Custom Formats",
"UnableToLoadDelayProfiles": "Unable to load Delay Profiles", "UnableToLoadDelayProfiles": "Unable to load Delay Profiles",
"UnableToLoadDownloadClientOptions": "Unable to load download client options", "UnableToLoadDownloadClientOptions": "Unable to load download client options",
"UnableToLoadDownloadClients": "Unable to load download clients",
"UnableToLoadGeneralSettings": "Unable to load General settings", "UnableToLoadGeneralSettings": "Unable to load General settings",
"UnableToLoadHistory": "Unable to load history", "UnableToLoadHistory": "Unable to load history",
"UnableToLoadIndexerOptions": "Unable to load indexer options", "UnableToLoadIndexerOptions": "Unable to load indexer options",

@ -5,6 +5,8 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NLog; using NLog;
using NzbDrone.Common.Cache; using NzbDrone.Common.Cache;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Download; using NzbDrone.Core.Download;
using NzbDrone.Core.Exceptions; using NzbDrone.Core.Exceptions;
@ -58,7 +60,8 @@ namespace Radarr.Api.V3.Indexers
} }
[HttpPost] [HttpPost]
public object DownloadRelease(ReleaseResource release) [Consumes("application/json")]
public async Task<object> DownloadRelease(ReleaseResource release)
{ {
var remoteMovie = _remoteMovieCache.Find(GetCacheKey(release)); var remoteMovie = _remoteMovieCache.Find(GetCacheKey(release));
@ -71,6 +74,30 @@ namespace Radarr.Api.V3.Indexers
try try
{ {
if (release.ShouldOverride == true)
{
Ensure.That(release.MovieId, () => release.MovieId).IsNotNull();
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
remoteMovie = new RemoteMovie
{
Release = remoteMovie.Release,
ParsedMovieInfo = remoteMovie.ParsedMovieInfo.JsonClone(),
DownloadAllowed = remoteMovie.DownloadAllowed,
SeedConfiguration = remoteMovie.SeedConfiguration,
CustomFormats = remoteMovie.CustomFormats,
CustomFormatScore = remoteMovie.CustomFormatScore,
MovieMatchType = remoteMovie.MovieMatchType,
ReleaseSource = remoteMovie.ReleaseSource
};
remoteMovie.Movie = _movieService.GetMovie(release.MovieId!.Value);
remoteMovie.ParsedMovieInfo.Quality = release.Quality;
remoteMovie.Languages = release.Languages;
}
if (remoteMovie.Movie == null) if (remoteMovie.Movie == null)
{ {
if (release.MovieId.HasValue) if (release.MovieId.HasValue)
@ -85,7 +112,7 @@ namespace Radarr.Api.V3.Indexers
} }
} }
_downloadService.DownloadReport(remoteMovie); await _downloadService.DownloadReport(remoteMovie, release.DownloadClientId);
} }
catch (ReleaseDownloadException ex) catch (ReleaseDownloadException ex)
{ {
@ -97,6 +124,7 @@ namespace Radarr.Api.V3.Indexers
} }
[HttpGet] [HttpGet]
[Produces("application/json")]
public async Task<List<ReleaseResource>> GetReleases(int? movieId) public async Task<List<ReleaseResource>> GetReleases(int? movieId)
{ {
if (movieId.HasValue) if (movieId.HasValue)

@ -32,6 +32,7 @@ namespace Radarr.Api.V3.Indexers
public bool SceneSource { get; set; } public bool SceneSource { get; set; }
public List<string> MovieTitles { get; set; } public List<string> MovieTitles { get; set; }
public List<Language> Languages { get; set; } public List<Language> Languages { get; set; }
public int? MappedMovieId { get; set; }
public bool Approved { get; set; } public bool Approved { get; set; }
public bool TemporarilyRejected { get; set; } public bool TemporarilyRejected { get; set; }
public bool Rejected { get; set; } public bool Rejected { get; set; }
@ -56,6 +57,12 @@ namespace Radarr.Api.V3.Indexers
// Sent when queuing an unknown release // Sent when queuing an unknown release
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int? MovieId { get; set; } public int? MovieId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int? DownloadClientId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool? ShouldOverride { get; set; }
} }
public static class ReleaseResourceMapper public static class ReleaseResourceMapper
@ -88,6 +95,7 @@ namespace Radarr.Api.V3.Indexers
Title = releaseInfo.Title, Title = releaseInfo.Title,
MovieTitles = parsedMovieInfo.MovieTitles, MovieTitles = parsedMovieInfo.MovieTitles,
Languages = remoteMovie.Languages, Languages = remoteMovie.Languages,
MappedMovieId = remoteMovie.Movie?.Id,
Approved = model.Approved, Approved = model.Approved,
TemporarilyRejected = model.TemporarilyRejected, TemporarilyRejected = model.TemporarilyRejected,
Rejected = model.Rejected, Rejected = model.Rejected,

@ -30,7 +30,7 @@ namespace Radarr.Api.V3.Queue
throw new NotFoundException(); throw new NotFoundException();
} }
await _downloadService.DownloadReport(pendingRelease.RemoteMovie); await _downloadService.DownloadReport(pendingRelease.RemoteMovie, null);
return new { }; return new { };
} }
@ -48,7 +48,7 @@ namespace Radarr.Api.V3.Queue
throw new NotFoundException(); throw new NotFoundException();
} }
await _downloadService.DownloadReport(pendingRelease.RemoteMovie); await _downloadService.DownloadReport(pendingRelease.RemoteMovie, null);
} }
return new { }; return new { };

Loading…
Cancel
Save