From 9b4f80535e888f3c58cd862697f81590385849e2 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 27 Mar 2023 16:49:12 -0700 Subject: [PATCH] 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) --- .../DescriptionList/DescriptionListItem.js | 4 +- .../src/DownloadClient/DownloadProtocol.ts | 7 + frontend/src/Helpers/Props/icons.js | 2 + .../InteractiveSearchContent.js | 13 +- .../InteractiveSearchRow.css | 43 +- .../InteractiveSearchRow.css.d.ts | 5 +- .../InteractiveSearch/InteractiveSearchRow.js | 365 ----------------- .../InteractiveSearchRow.tsx | 378 ++++++++++++++++++ .../SelectDownloadClientModal.tsx | 31 ++ .../SelectDownloadClientModalContent.tsx | 74 ++++ .../SelectDownloadClientRow.css | 6 + .../SelectDownloadClientRow.css.d.ts | 7 + .../SelectDownloadClientRow.tsx | 32 ++ .../OverrideMatch/OverrideMatchData.css | 17 + .../OverrideMatch/OverrideMatchData.css.d.ts | 9 + .../OverrideMatch/OverrideMatchData.tsx | 35 ++ .../OverrideMatch/OverrideMatchModal.tsx | 56 +++ .../OverrideMatchModalContent.css | 49 +++ .../OverrideMatchModalContent.css.d.ts | 11 + .../OverrideMatchModalContent.tsx | 299 ++++++++++++++ .../createEnabledDownloadClientsSelector.ts | 22 + frontend/src/typings/MovieBlocklist.ts | 7 + frontend/src/typings/MovieHistory.ts | 7 + .../DownloadApprovedFixture.cs | 22 +- .../Download/DownloadServiceFixture.cs | 24 +- src/NzbDrone.Core/Download/DownloadService.cs | 8 +- .../Download/ProcessDownloadDecisions.cs | 2 +- src/NzbDrone.Core/Localization/Core/en.json | 11 +- .../Indexers/ReleaseController.cs | 32 +- src/Radarr.Api.V3/Indexers/ReleaseResource.cs | 8 + .../Queue/QueueActionController.cs | 4 +- 31 files changed, 1185 insertions(+), 405 deletions(-) create mode 100644 frontend/src/DownloadClient/DownloadProtocol.ts delete mode 100644 frontend/src/InteractiveSearch/InteractiveSearchRow.js create mode 100644 frontend/src/InteractiveSearch/InteractiveSearchRow.tsx create mode 100644 frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx create mode 100644 frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx create mode 100644 frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css create mode 100644 frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts create mode 100644 frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx create mode 100644 frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css create mode 100644 frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css.d.ts create mode 100644 frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.tsx create mode 100644 frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModal.tsx create mode 100644 frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css create mode 100644 frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css.d.ts create mode 100644 frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.tsx create mode 100644 frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts create mode 100644 frontend/src/typings/MovieBlocklist.ts create mode 100644 frontend/src/typings/MovieHistory.ts 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 ? ( +
+ {items.map((downloadClient) => { + const { id, name, priority } = downloadClient; + + return ( + + ); + })} + + ) : 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 DownloadRelease(ReleaseResource release) { var remoteMovie = _remoteMovieCache.Find(GetCacheKey(release)); @@ -71,6 +74,30 @@ namespace Radarr.Api.V3.Indexers 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 (release.MovieId.HasValue) @@ -85,7 +112,7 @@ namespace Radarr.Api.V3.Indexers } } - _downloadService.DownloadReport(remoteMovie); + await _downloadService.DownloadReport(remoteMovie, release.DownloadClientId); } catch (ReleaseDownloadException ex) { @@ -97,6 +124,7 @@ namespace Radarr.Api.V3.Indexers } [HttpGet] + [Produces("application/json")] public async Task> GetReleases(int? movieId) { if (movieId.HasValue) diff --git a/src/Radarr.Api.V3/Indexers/ReleaseResource.cs b/src/Radarr.Api.V3/Indexers/ReleaseResource.cs index d37478be7..e931e29b1 100644 --- a/src/Radarr.Api.V3/Indexers/ReleaseResource.cs +++ b/src/Radarr.Api.V3/Indexers/ReleaseResource.cs @@ -32,6 +32,7 @@ namespace Radarr.Api.V3.Indexers public bool SceneSource { get; set; } public List MovieTitles { get; set; } public List Languages { get; set; } + public int? MappedMovieId { get; set; } public bool Approved { get; set; } public bool TemporarilyRejected { get; set; } public bool Rejected { get; set; } @@ -56,6 +57,12 @@ namespace Radarr.Api.V3.Indexers // Sent when queuing an unknown release [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] 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 @@ -88,6 +95,7 @@ namespace Radarr.Api.V3.Indexers Title = releaseInfo.Title, MovieTitles = parsedMovieInfo.MovieTitles, Languages = remoteMovie.Languages, + MappedMovieId = remoteMovie.Movie?.Id, Approved = model.Approved, TemporarilyRejected = model.TemporarilyRejected, Rejected = model.Rejected, diff --git a/src/Radarr.Api.V3/Queue/QueueActionController.cs b/src/Radarr.Api.V3/Queue/QueueActionController.cs index e1c35ec56..7f987faac 100644 --- a/src/Radarr.Api.V3/Queue/QueueActionController.cs +++ b/src/Radarr.Api.V3/Queue/QueueActionController.cs @@ -30,7 +30,7 @@ namespace Radarr.Api.V3.Queue throw new NotFoundException(); } - await _downloadService.DownloadReport(pendingRelease.RemoteMovie); + await _downloadService.DownloadReport(pendingRelease.RemoteMovie, null); return new { }; } @@ -48,7 +48,7 @@ namespace Radarr.Api.V3.Queue throw new NotFoundException(); } - await _downloadService.DownloadReport(pendingRelease.RemoteMovie); + await _downloadService.DownloadReport(pendingRelease.RemoteMovie, null); } return new { };