diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js
index aac01a6b5..931557045 100644
--- a/frontend/src/Components/DescriptionList/DescriptionListItem.js
+++ b/frontend/src/Components/DescriptionList/DescriptionListItem.js
@@ -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 (
-
+
@@ -35,6 +36,7 @@ class DescriptionListItem extends Component {
}
DescriptionListItem.propTypes = {
+ className: PropTypes.string,
titleClassName: PropTypes.string,
descriptionClassName: PropTypes.string,
title: PropTypes.string,
diff --git a/frontend/src/DownloadClient/DownloadProtocol.ts b/frontend/src/DownloadClient/DownloadProtocol.ts
new file mode 100644
index 000000000..090a1a087
--- /dev/null
+++ b/frontend/src/DownloadClient/DownloadProtocol.ts
@@ -0,0 +1,7 @@
+enum DownloadProtocol {
+ Unknown = 'unknown',
+ Usenet = 'usenet',
+ Torrent = 'torrent',
+}
+
+export default DownloadProtocol;
diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js
index 7263286ab..69e3d6ea1 100644
--- a/frontend/src/Helpers/Props/icons.js
+++ b/frontend/src/Helpers/Props/icons.js
@@ -43,6 +43,7 @@ import {
faChevronCircleRight as fasChevronCircleRight,
faChevronCircleUp as fasChevronCircleUp,
faCircle as fasCircle,
+ faCircleDown as fasCircleDown,
faCloud as fasCloud,
faCloudDownloadAlt as fasCloudDownloadAlt,
faCog as fasCog,
@@ -135,6 +136,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;
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchContent.js b/frontend/src/InteractiveSearch/InteractiveSearchContent.js
index 6eec4d461..27410388f 100644
--- a/frontend/src/InteractiveSearch/InteractiveSearchContent.js
+++ b/frontend/src/InteractiveSearch/InteractiveSearchContent.js
@@ -32,7 +32,11 @@ const columns = [
},
{
name: 'rejections',
- label: React.createElement(Icon, { name: icons.DANGER }),
+ columnLabel: () => translate('Rejections'),
+ label: React.createElement(Icon, {
+ name: icons.DANGER,
+ title: () => translate('Rejections')
+ }),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true
@@ -88,6 +92,7 @@ const columns = [
},
{
name: 'customFormatScore',
+ columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
@@ -97,7 +102,11 @@ const columns = [
},
{
name: 'indexerFlags',
- label: React.createElement(Icon, { name: icons.FLAG }),
+ columnLabel: () => translate('IndexerFlags'),
+ label: React.createElement(Icon, {
+ name: icons.FLAG,
+ title: () => translate('IndexerFlags')
+ }),
isSortable: true,
isVisible: true
}
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css
index 6545102ca..5ea574e08 100644
--- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css
+++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css
@@ -16,11 +16,11 @@
.quality,
.customFormat,
-.language {
+.languages {
composes: cell;
}
-.language {
+.languages {
width: 100px;
}
@@ -33,8 +33,7 @@
}
.rejected,
-.indexerFlags,
-.download {
+.indexerFlags {
composes: cell;
width: 50px;
@@ -70,3 +69,39 @@
.blocklist {
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;
+}
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts
index bb0b5da95..f453b991e 100644
--- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts
+++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts
@@ -7,10 +7,13 @@ interface CssExports {
'customFormat': string;
'customFormatScore': string;
'download': string;
+ 'downloadIcon': string;
'history': string;
'indexer': string;
'indexerFlags': string;
- 'language': string;
+ 'interactiveIcon': string;
+ 'languages': string;
+ 'manualDownloadContent': string;
'peers': string;
'protocol': string;
'quality': string;
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js
deleted file mode 100644
index de7c49692..000000000
--- a/frontend/src/InteractiveSearch/InteractiveSearchRow.js
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
- {formatAge(age, ageHours, ageMinutes)}
-
-
-
-
-
-
-
- {
- !!rejections.length &&
-
- }
- title={translate('ReleaseRejected')}
- body={
-
- {
- rejections.map((rejection, index) => {
- return (
- -
- {rejection}
-
- );
- })
- }
-
- }
- position={tooltipPositions.BOTTOM}
- />
- }
-
-
-
-
-
- {title}
-
-
-
-
-
- {indexer}
-
-
-
- {
- historyGrabbedData?.date && !historyFailedData?.date &&
-
- }
-
- {
- historyFailedData?.date &&
-
- }
-
- {
- blocklistData?.date &&
-
- }
-
-
-
- {formatBytes(size)}
-
-
-
- {
- protocol === 'torrent' &&
-
- }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {customFormatScore > 0 && `+${customFormatScore}`}
- {customFormatScore < 0 && customFormatScore}
-
-
-
- {
- !!indexerFlags.length &&
-
- }
- title={translate('IndexerFlags')}
- body={
-
- {
- indexerFlags.map((flag, index) => {
- return (
- -
- {flag}
-
- );
- })
- }
-
- }
- position={tooltipPositions.BOTTOM}
- />
- }
-
-
-
-
- );
- }
-}
-
-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;
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx
new file mode 100644
index 000000000..0972c9525
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx
@@ -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 (
+
+
+
+
+
+
+ {formatAge(age, ageHours, ageMinutes)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {rejections.length ? (
+ }
+ title={translate('ReleaseRejected')}
+ body={
+
+ {rejections.map((rejection, index) => {
+ return - {rejection}
;
+ })}
+
+ }
+ position={tooltipPositions.RIGHT}
+ />
+ ) : null}
+
+
+
+
+ {title}
+
+
+
+ {indexer}
+
+
+ {historyGrabbedData?.date && !historyFailedData?.date ? (
+
+ ) : null}
+
+ {historyFailedData?.date ? (
+
+ ) : null}
+
+ {blocklistData?.date ? (
+
+ ) : null}
+
+
+ {formatBytes(size)}
+
+
+ {protocol === 'torrent' ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ position={tooltipPositions.TOP}
+ />
+
+
+
+ {indexerFlags.length ? (
+ }
+ title={translate('IndexerFlags')}
+ body={
+
+ {indexerFlags.map((flag, index) => {
+ return - {flag}
;
+ })}
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+ ) : null}
+
+
+
+
+
+
+ );
+}
+
+export default InteractiveSearchRow;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx
new file mode 100644
index 000000000..81bf86e59
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx
@@ -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 (
+
+
+
+ );
+}
+
+export default SelectDownloadClientModal;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx
new file mode 100644
index 000000000..63e15808f
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx
@@ -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 (
+
+
+ {translate('SelectDownloadClientModalTitle', { modalTitle })}
+
+
+
+ {isFetching ? : null}
+
+ {!isFetching && error ? (
+
+ {translate('DownloadClientsLoadError')}
+
+ ) : null}
+
+ {isPopulated && !error ? (
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
+
+export default SelectDownloadClientModalContent;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css
new file mode 100644
index 000000000..6525db977
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css
@@ -0,0 +1,6 @@
+.downloadClient {
+ display: flex;
+ justify-content: space-between;
+ padding: 8px;
+ border-bottom: 1px solid var(--borderColor);
+}
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts
new file mode 100644
index 000000000..10c2d3948
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts
@@ -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;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx
new file mode 100644
index 000000000..6f98d60b4
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx
@@ -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 (
+
+ {name}
+ {translate('PrioritySettings', { priority })}
+
+ );
+}
+
+export default SelectDownloadClientRow;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css
new file mode 100644
index 000000000..bd4d2f788
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css
@@ -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);
+}
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css.d.ts b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css.d.ts
new file mode 100644
index 000000000..dd3ac4575
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css.d.ts
@@ -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;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.tsx b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.tsx
new file mode 100644
index 000000000..82d6bd812
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.tsx
@@ -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 (
+
+ {(value == null || (Array.isArray(value) && value.length === 0)) &&
+ !isDisabled ? (
+
+
+
+ ) : (
+ value
+ )}
+
+ );
+}
+
+export default OverrideMatchData;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModal.tsx b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModal.tsx
new file mode 100644
index 000000000..d296e626c
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModal.tsx
@@ -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 (
+
+
+
+ );
+}
+
+export default OverrideMatchModal;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css
new file mode 100644
index 000000000..a5b4b8d52
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css
@@ -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;
+ }
+}
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css.d.ts b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css.d.ts
new file mode 100644
index 000000000..79c77d6b5
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css.d.ts
@@ -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;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.tsx b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.tsx
new file mode 100644
index 000000000..bab577848
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.tsx
@@ -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(null);
+ const [error, setError] = useState(null);
+ const [selectModalOpen, setSelectModalOpen] = useState(
+ 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 (
+
+
+ {translate('OverrideGrabModalTitle', { title })}
+
+
+
+
+
+ }
+ />
+
+
+ }
+ onPress={onSelectQualityPress}
+ />
+ }
+ />
+
+
+ }
+ onPress={onSelectLanguagesPress}
+ />
+ }
+ />
+
+ {downloadClients.length > 1 ? (
+ downloadClient.id === downloadClientId
+ )?.name ?? translate('Default')
+ }
+ onPress={onSelectDownloadClientPress}
+ />
+ }
+ />
+ ) : null}
+
+
+
+
+ {error || grabError}
+
+
+
+
+
+ {translate('GrabRelease')}
+
+
+
+
+
+
+ 1 : false}
+ real={quality ? quality.revision.real > 0 : false}
+ modalTitle={modalTitle}
+ onQualitySelect={onQualitySelect}
+ onModalClose={onSelectModalClose}
+ />
+
+ l.id) : []}
+ modalTitle={modalTitle}
+ onLanguagesSelect={onLanguagesSelect}
+ onModalClose={onSelectModalClose}
+ />
+
+
+
+ );
+}
+
+export default OverrideMatchModalContent;
diff --git a/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts b/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts
new file mode 100644
index 000000000..ac31e5210
--- /dev/null
+++ b/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts
@@ -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 };
+ }
+ );
+}
diff --git a/frontend/src/typings/MovieBlocklist.ts b/frontend/src/typings/MovieBlocklist.ts
new file mode 100644
index 000000000..77aea1b35
--- /dev/null
+++ b/frontend/src/typings/MovieBlocklist.ts
@@ -0,0 +1,7 @@
+import ModelBase from 'App/ModelBase';
+
+interface MovieBlocklist extends ModelBase {
+ date: string;
+}
+
+export default MovieBlocklist;
diff --git a/frontend/src/typings/MovieHistory.ts b/frontend/src/typings/MovieHistory.ts
new file mode 100644
index 000000000..6ee04ba74
--- /dev/null
+++ b/frontend/src/typings/MovieHistory.ts
@@ -0,0 +1,7 @@
+import ModelBase from 'App/ModelBase';
+
+interface MovieHistory extends ModelBase {
+ date: string;
+}
+
+export default MovieHistory;
diff --git a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs
index dee86ae4d..6d0ee4b54 100644
--- a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs
+++ b/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs
@@ -80,7 +80,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteMovie));
await Subject.ProcessDecisions(decisions);
- Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Once());
+ Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny(), null), Times.Once());
}
[Test]
@@ -93,7 +93,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteMovie));
await Subject.ProcessDecisions(decisions);
- Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Once());
+ Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny(), null), Times.Once());
}
[Test]
@@ -110,7 +110,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteMovie2));
await Subject.ProcessDecisions(decisions);
- Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Once());
+ Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny(), null), Times.Once());
}
[Test]
@@ -175,7 +175,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
var decisions = new List();
decisions.Add(new DownloadDecision(remoteMovie));
- Mocker.GetMock().Setup(s => s.DownloadReport(It.IsAny())).Throws(new Exception());
+ Mocker.GetMock().Setup(s => s.DownloadReport(It.IsAny(), null)).Throws(new Exception());
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)));
await Subject.ProcessDecisions(decisions);
- Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Never());
+ Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny(), null), Times.Never());
}
[Test]
@@ -242,11 +242,11 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteMovie));
decisions.Add(new DownloadDecision(remoteMovie));
- Mocker.GetMock().Setup(s => s.DownloadReport(It.IsAny()))
+ Mocker.GetMock().Setup(s => s.DownloadReport(It.IsAny(), null))
.Throws(new DownloadClientUnavailableException("Download client failed"));
await Subject.ProcessDecisions(decisions);
- Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Once());
+ Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny(), null), Times.Once());
}
[Test]
@@ -259,12 +259,12 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteMovie));
decisions.Add(new DownloadDecision(remoteMovie2));
- Mocker.GetMock().Setup(s => s.DownloadReport(It.Is(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet)))
+ Mocker.GetMock().Setup(s => s.DownloadReport(It.Is(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet), null))
.Throws(new DownloadClientUnavailableException("Download client failed"));
await Subject.ProcessDecisions(decisions);
- Mocker.GetMock().Verify(v => v.DownloadReport(It.Is(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet)), Times.Once());
- Mocker.GetMock().Verify(v => v.DownloadReport(It.Is(r => r.Release.DownloadProtocol == DownloadProtocol.Torrent)), Times.Once());
+ Mocker.GetMock().Verify(v => v.DownloadReport(It.Is(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet), null), Times.Once());
+ Mocker.GetMock().Verify(v => v.DownloadReport(It.Is(r => r.Release.DownloadProtocol == DownloadProtocol.Torrent), null), Times.Once());
}
[Test]
@@ -276,7 +276,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteMovie));
Mocker.GetMock()
- .Setup(s => s.DownloadReport(It.IsAny()))
+ .Setup(s => s.DownloadReport(It.IsAny(), null))
.Throws(new ReleaseUnavailableException(remoteMovie.Release, "That 404 Error is not just a Quirk"));
var result = await Subject.ProcessDecisions(decisions);
diff --git a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs
index 256f4ce3c..eba7df9a3 100644
--- a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs
+++ b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs
@@ -76,7 +76,7 @@ namespace NzbDrone.Core.Test.Download
var mock = WithUsenetClient();
mock.Setup(s => s.Download(It.IsAny(), It.IsAny()));
- await Subject.DownloadReport(_parseResult);
+ await Subject.DownloadReport(_parseResult, null);
VerifyEventPublished();
}
@@ -87,7 +87,7 @@ namespace NzbDrone.Core.Test.Download
var mock = WithUsenetClient();
mock.Setup(s => s.Download(It.IsAny(), It.IsAny()));
- await Subject.DownloadReport(_parseResult);
+ await Subject.DownloadReport(_parseResult, null);
mock.Verify(s => s.Download(It.IsAny(), It.IsAny()), Times.Once());
}
@@ -99,7 +99,7 @@ namespace NzbDrone.Core.Test.Download
mock.Setup(s => s.Download(It.IsAny(), It.IsAny()))
.Throws(new WebException());
- Assert.ThrowsAsync(async () => await Subject.DownloadReport(_parseResult));
+ Assert.ThrowsAsync(async () => await Subject.DownloadReport(_parseResult, null));
VerifyEventNotPublished();
}
@@ -114,7 +114,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseDownloadException(v.Release, "Error", new WebException());
});
- Assert.ThrowsAsync(async () => await Subject.DownloadReport(_parseResult));
+ Assert.ThrowsAsync(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock()
.Verify(v => v.RecordFailure(It.IsAny(), It.IsAny()), Times.Once());
@@ -134,7 +134,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response));
});
- Assert.ThrowsAsync(async () => await Subject.DownloadReport(_parseResult));
+ Assert.ThrowsAsync(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock()
.Verify(v => v.RecordFailure(It.IsAny(), 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));
});
- Assert.ThrowsAsync(async () => await Subject.DownloadReport(_parseResult));
+ Assert.ThrowsAsync(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock()
.Verify(v => v.RecordFailure(It.IsAny(),
@@ -168,7 +168,7 @@ namespace NzbDrone.Core.Test.Download
mock.Setup(s => s.Download(It.IsAny(), It.IsAny()))
.Throws(new DownloadClientException("Some Error"));
- Assert.ThrowsAsync(async () => await Subject.DownloadReport(_parseResult));
+ Assert.ThrowsAsync(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock()
.Verify(v => v.RecordFailure(It.IsAny(), It.IsAny()), Times.Never());
@@ -184,7 +184,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseUnavailableException(v.Release, "Error", new WebException());
});
- Assert.ThrowsAsync(async () => await Subject.DownloadReport(_parseResult));
+ Assert.ThrowsAsync(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock()
.Verify(v => v.RecordFailure(It.IsAny(), It.IsAny()), Times.Never());
@@ -193,7 +193,7 @@ namespace NzbDrone.Core.Test.Download
[Test]
public void should_not_attempt_download_if_client_isnt_configured()
{
- Assert.ThrowsAsync(async () => await Subject.DownloadReport(_parseResult));
+ Assert.ThrowsAsync(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock().Verify(c => c.Download(It.IsAny(), It.IsAny()), Times.Never());
VerifyEventNotPublished();
@@ -215,7 +215,7 @@ namespace NzbDrone.Core.Test.Download
}
});
- await Subject.DownloadReport(_parseResult);
+ await Subject.DownloadReport(_parseResult, null);
Mocker.GetMock().Verify(c => c.GetBlockedProviders(), Times.Never());
mockUsenet.Verify(c => c.Download(It.IsAny(), It.IsAny()), Times.Once());
@@ -228,7 +228,7 @@ namespace NzbDrone.Core.Test.Download
var mockTorrent = WithTorrentClient();
var mockUsenet = WithUsenetClient();
- await Subject.DownloadReport(_parseResult);
+ await Subject.DownloadReport(_parseResult, null);
mockTorrent.Verify(c => c.Download(It.IsAny(), It.IsAny()), Times.Never());
mockUsenet.Verify(c => c.Download(It.IsAny(), It.IsAny()), Times.Once());
@@ -242,7 +242,7 @@ namespace NzbDrone.Core.Test.Download
_parseResult.Release.DownloadProtocol = DownloadProtocol.Torrent;
- await Subject.DownloadReport(_parseResult);
+ await Subject.DownloadReport(_parseResult, null);
mockTorrent.Verify(c => c.Download(It.IsAny(), It.IsAny()), Times.Once());
mockUsenet.Verify(c => c.Download(It.IsAny(), It.IsAny()), Times.Never());
diff --git a/src/NzbDrone.Core/Download/DownloadService.cs b/src/NzbDrone.Core/Download/DownloadService.cs
index 3d32a0403..eec9aa857 100644
--- a/src/NzbDrone.Core/Download/DownloadService.cs
+++ b/src/NzbDrone.Core/Download/DownloadService.cs
@@ -17,7 +17,7 @@ namespace NzbDrone.Core.Download
{
public interface IDownloadService
{
- Task DownloadReport(RemoteMovie remoteMovie);
+ Task DownloadReport(RemoteMovie remoteMovie, int? downloadClientId);
}
public class DownloadService : IDownloadService
@@ -50,13 +50,15 @@ namespace NzbDrone.Core.Download
_logger = logger;
}
- public async Task DownloadReport(RemoteMovie remoteMovie)
+ public async Task DownloadReport(RemoteMovie remoteMovie, int? downloadClientId)
{
var filterBlockedClients = remoteMovie.Release.PendingReleaseReason == PendingReleaseReason.DownloadClientUnavailable;
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);
}
diff --git a/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs b/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs
index 890662962..592234f50 100644
--- a/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs
+++ b/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs
@@ -74,7 +74,7 @@ namespace NzbDrone.Core.Download
try
{
_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);
}
catch (ReleaseUnavailableException)
diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json
index bf66083bb..e45853bc2 100644
--- a/src/NzbDrone.Core/Localization/Core/en.json
+++ b/src/NzbDrone.Core/Localization/Core/en.json
@@ -216,6 +216,7 @@
"Day": "Day",
"Days": "Days",
"Debug": "Debug",
+ "Default": "Default",
"DefaultCase": "Default Case",
"DefaultDelayProfile": "This is the default profile. It applies to all movies that don't have an explicit 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.",
"DownloadClientUnavailable": "Download client is unavailable",
"DownloadClients": "Download Clients",
+ "DownloadClientsLoadError": "Unable to load download clients",
"DownloadClientsSettingsSummary": "Download clients, download handling and remote path mappings",
"DownloadFailed": "Download failed",
"DownloadFailedCheckDownloadClientForMoreDetails": "Download failed: check download client for more details",
@@ -568,6 +570,7 @@
"ManageIndexers": "Manage Indexers",
"ManageLists": "Manage Lists",
"Manual": "Manual",
+ "ManualGrab": "Manual Grab",
"ManualImport": "Manual Import",
"ManualImportSelectLanguage": "Manual Import - Select Language",
"ManualImportSelectMovie": "Manual Import - Select Movie",
@@ -756,6 +759,11 @@
"OriginalLanguage": "Original Language",
"OriginalTitle": "Original Title",
"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",
"OverviewOptions": "Overview Options",
"PackageVersion": "Package Version",
@@ -863,6 +871,7 @@
"RefreshMovie": "Refresh movie",
"RegularExpressionsCanBeTested": "Regular expressions can be tested ",
"RejectionCount": "Rejection Count",
+ "Rejections": "Rejections",
"RelativePath": "Relative Path",
"ReleaseBranchCheckOfficialBranchMessage": "Branch {0} is not a valid Radarr release branch, you will not receive updates",
"ReleaseDates": "Release Dates",
@@ -1002,6 +1011,7 @@
"Seeders": "Seeders",
"SelectAll": "Select All",
"SelectDotDot": "Select...",
+ "SelectDownloadClientModalTitle": "{modalTitle} - Select Download Client",
"SelectFolder": "Select Folder",
"SelectLanguage": "Select Language",
"SelectLanguages": "Select Languages",
@@ -1181,7 +1191,6 @@
"UnableToLoadCustomFormats": "Unable to load Custom Formats",
"UnableToLoadDelayProfiles": "Unable to load Delay Profiles",
"UnableToLoadDownloadClientOptions": "Unable to load download client options",
- "UnableToLoadDownloadClients": "Unable to load download clients",
"UnableToLoadGeneralSettings": "Unable to load General settings",
"UnableToLoadHistory": "Unable to load history",
"UnableToLoadIndexerOptions": "Unable to load indexer options",
diff --git a/src/Radarr.Api.V3/Indexers/ReleaseController.cs b/src/Radarr.Api.V3/Indexers/ReleaseController.cs
index 774eb4f75..8ce628184 100644
--- a/src/Radarr.Api.V3/Indexers/ReleaseController.cs
+++ b/src/Radarr.Api.V3/Indexers/ReleaseController.cs
@@ -5,6 +5,8 @@ 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;
using NzbDrone.Core.Exceptions;
@@ -58,7 +60,8 @@ namespace Radarr.Api.V3.Indexers
}
[HttpPost]
- public object DownloadRelease(ReleaseResource release)
+ [Consumes("application/json")]
+ public async Task