diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js index 39f634cc9..ee26b5323 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 0c7fb7a40..026bb6e9f 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -39,6 +39,7 @@ import { faChevronCircleRight as fasChevronCircleRight, faChevronCircleUp as fasChevronCircleUp, faCircle as fasCircle, + faCircleDown as fasCircleDown, faCloud as fasCloud, faCloudDownloadAlt as fasCloudDownloadAlt, faCog as fasCog, @@ -121,6 +122,7 @@ export const CHECK_INDETERMINATE = fasMinus; export const CHECK_CIRCLE = fasCheckCircle; export const CHECK_SQUARE = fasSquareCheck; export const CIRCLE = fasCircle; +export const CIRCLE_DOWN = fasCircleDown; export const CIRCLE_OUTLINE = farCircle; export const CLEAR = fasTrashAlt; export const CLIPBOARD = fasCopy; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css index 48c2140f4..d1764b22b 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css @@ -40,8 +40,7 @@ cursor: default; } -.rejected, -.download { +.rejected { composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 50px; @@ -59,3 +58,34 @@ width: 75px; } + +.download { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 80px; +} + +.manualDownloadContent { + position: relative; + display: inline-block; + width: 100%; + height: 20.39px; + vertical-align: middle; + line-height: 20.39px; +} + +.interactiveIcon { + position: absolute; + top: 4px; + left: 0; + /* width: 100%; */ + text-align: center; +} + +.downloadIcon { + position: absolute; + top: 7px; + left: 8px; + /* width: 100%; */ + text-align: center; +} diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts index 8bc30fbc3..0f32b14eb 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts @@ -4,8 +4,11 @@ interface CssExports { 'age': string; 'customFormatScore': string; 'download': string; + 'downloadIcon': string; 'indexer': string; + 'interactiveIcon': string; 'languages': string; + 'manualDownloadContent': string; 'peers': string; 'protocol': string; 'quality': string; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js deleted file mode 100644 index caedfc24a..000000000 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.js +++ /dev/null @@ -1,308 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import ConfirmModal from 'Components/Modal/ConfirmModal'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableRow from 'Components/Table/TableRow'; -import Popover from 'Components/Tooltip/Popover'; -import Tooltip from 'Components/Tooltip/Tooltip'; -import EpisodeFormats from 'Episode/EpisodeFormats'; -import EpisodeLanguages from 'Episode/EpisodeLanguages'; -import EpisodeQuality from 'Episode/EpisodeQuality'; -import { icons, kinds, tooltipPositions } from 'Helpers/Props'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import formatAge from 'Utilities/Number/formatAge'; -import formatBytes from 'Utilities/Number/formatBytes'; -import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore'; -import Peers from './Peers'; -import ReleaseSceneIndicator from './ReleaseSceneIndicator'; -import styles from './InteractiveSearchRow.css'; - -function getDownloadIcon(isGrabbing, isGrabbed, grabError) { - if (isGrabbing) { - return icons.SPINNER; - } else if (isGrabbed) { - return icons.DOWNLOADING; - } else if (grabError) { - return icons.DOWNLOADING; - } - - return icons.DOWNLOAD; -} - -function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { - if (isGrabbing) { - return ''; - } else if (isGrabbed) { - return 'Added to download queue'; - } else if (grabError) { - return grabError; - } - - return 'Add to download queue'; -} - -class InteractiveSearchRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isConfirmGrabModalOpen: false - }; - } - - // - // Listeners - - onGrabPress = () => { - const { - guid, - indexerId, - onGrabPress - } = this.props; - - onGrabPress({ - guid, - indexerId - }); - }; - - onConfirmGrabPress = () => { - this.setState({ isConfirmGrabModalOpen: true }); - }; - - onGrabConfirm = () => { - this.setState({ isConfirmGrabModalOpen: false }); - - const { - guid, - indexerId, - searchPayload, - onGrabPress - } = this.props; - - onGrabPress({ - guid, - indexerId, - ...searchPayload - }); - }; - - onGrabCancel = () => { - this.setState({ isConfirmGrabModalOpen: false }); - }; - - // - // Render - - render() { - const { - protocol, - age, - ageHours, - ageMinutes, - publishDate, - title, - infoUrl, - indexer, - size, - seeders, - leechers, - quality, - languages, - customFormatScore, - customFormats, - sceneMapping, - seasonNumber, - episodeNumbers, - absoluteEpisodeNumbers, - mappedSeasonNumber, - mappedEpisodeNumbers, - mappedAbsoluteEpisodeNumbers, - rejections, - episodeRequested, - downloadAllowed, - isDaily, - isGrabbing, - isGrabbed, - longDateFormat, - timeFormat, - grabError - } = this.props; - - return ( - - - - - - - {formatAge(age, ageHours, ageMinutes)} - - - -
- - {title} - - -
-
- - - {indexer} - - - - {formatBytes(size)} - - - - { - protocol === 'torrent' && - - } - - - - - - - - - - - - } - position={tooltipPositions.BOTTOM} - /> - - - - { - !!rejections.length && - - } - title="Release Rejected" - body={ -
    - { - rejections.map((rejection, index) => { - return ( -
  • - {rejection} -
  • - ); - }) - } -
- } - position={tooltipPositions.LEFT} - /> - } -
- - - - - - -
- ); - } -} - -InteractiveSearchRow.propTypes = { - guid: PropTypes.string.isRequired, - protocol: PropTypes.string.isRequired, - age: PropTypes.number.isRequired, - ageHours: PropTypes.number.isRequired, - ageMinutes: PropTypes.number.isRequired, - publishDate: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - infoUrl: PropTypes.string.isRequired, - indexerId: PropTypes.number.isRequired, - indexer: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - seeders: PropTypes.number, - leechers: PropTypes.number, - quality: PropTypes.object.isRequired, - languages: PropTypes.arrayOf(PropTypes.object).isRequired, - customFormats: PropTypes.arrayOf(PropTypes.object), - customFormatScore: PropTypes.number.isRequired, - sceneMapping: PropTypes.object, - seasonNumber: PropTypes.number, - episodeNumbers: PropTypes.arrayOf(PropTypes.number), - absoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number), - mappedSeasonNumber: PropTypes.number, - mappedEpisodeNumbers: PropTypes.arrayOf(PropTypes.number), - mappedAbsoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number), - rejections: PropTypes.arrayOf(PropTypes.string).isRequired, - episodeRequested: PropTypes.bool.isRequired, - downloadAllowed: PropTypes.bool.isRequired, - isDaily: PropTypes.bool.isRequired, - isGrabbing: PropTypes.bool.isRequired, - isGrabbed: PropTypes.bool.isRequired, - grabError: PropTypes.string, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - searchPayload: PropTypes.object.isRequired, - onGrabPress: PropTypes.func.isRequired -}; - -InteractiveSearchRow.defaultProps = { - rejections: [], - isGrabbing: false, - isGrabbed: false -}; - -export default InteractiveSearchRow; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx new file mode 100644 index 000000000..ecdecbab2 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx @@ -0,0 +1,314 @@ +import React, { useCallback, useState } from 'react'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRow from 'Components/Table/TableRow'; +import Popover from 'Components/Tooltip/Popover'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import type DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import EpisodeFormats from 'Episode/EpisodeFormats'; +import EpisodeLanguages from 'Episode/EpisodeLanguages'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatBytes from 'Utilities/Number/formatBytes'; +import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore'; +import OverrideMatchModal from './OverrideMatch/OverrideMatchModal'; +import Peers from './Peers'; +import ReleaseEpisode from './ReleaseEpisode'; +import ReleaseSceneIndicator from './ReleaseSceneIndicator'; +import styles from './InteractiveSearchRow.css'; + +function getDownloadIcon(isGrabbing, isGrabbed, grabError) { + if (isGrabbing) { + return icons.SPINNER; + } else if (isGrabbed) { + return icons.DOWNLOADING; + } else if (grabError) { + return icons.DOWNLOADING; + } + + return icons.DOWNLOAD; +} + +function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { + if (isGrabbing) { + return ''; + } else if (isGrabbed) { + return 'Added to download queue'; + } else if (grabError) { + return grabError; + } + + return 'Add to download queue'; +} + +interface InteractiveSearchRowProps { + guid: string; + protocol: DownloadProtocol; + age: number; + ageHours: number; + ageMinutes: number; + publishDate: string; + title: string; + infoUrl: string; + indexerId: number; + indexer: string; + size: number; + seeders?: number; + leechers?: number; + quality: QualityModel; + languages: Language[]; + customFormats?: object[]; + customFormatScore: number; + sceneMapping?: object; + seasonNumber?: number; + episodeNumbers?: number[]; + absoluteEpisodeNumbers?: number[]; + mappedSeriesId?: number; + mappedSeasonNumber?: number; + mappedEpisodeNumbers?: number[]; + mappedAbsoluteEpisodeNumbers?: number[]; + mappedEpisodeInfo: ReleaseEpisode[]; + rejections: string[]; + episodeRequested: boolean; + downloadAllowed: boolean; + isDaily: boolean; + isGrabbing: boolean; + isGrabbed: boolean; + grabError?: string; + longDateFormat: string; + timeFormat: string; + searchPayload: object; + onGrabPress(...args: unknown[]): void; +} + +function InteractiveSearchRow(props: InteractiveSearchRowProps) { + const { + guid, + indexerId, + protocol, + age, + ageHours, + ageMinutes, + publishDate, + title, + infoUrl, + indexer, + size, + seeders, + leechers, + quality, + languages, + customFormatScore, + customFormats, + sceneMapping, + seasonNumber, + episodeNumbers, + absoluteEpisodeNumbers, + mappedSeriesId, + mappedSeasonNumber, + mappedEpisodeNumbers, + mappedAbsoluteEpisodeNumbers, + mappedEpisodeInfo, + rejections, + episodeRequested, + downloadAllowed, + isDaily, + isGrabbing, + isGrabbed, + longDateFormat, + timeFormat, + grabError, + searchPayload, + onGrabPress, + } = props; + + const [isConfirmGrabModalOpen, setIsConfirmGrabModalOpen] = useState(false); + const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false); + + const onGrabPressWrapper = useCallback(() => { + if (downloadAllowed) { + onGrabPress({ + guid, + indexerId, + }); + + return; + } + + setIsConfirmGrabModalOpen(true); + }, [ + guid, + indexerId, + downloadAllowed, + onGrabPress, + setIsConfirmGrabModalOpen, + ]); + + const onGrabConfirm = useCallback(() => { + setIsConfirmGrabModalOpen(false); + + onGrabPress({ + guid, + indexerId, + ...searchPayload, + }); + }, [guid, indexerId, searchPayload, onGrabPress, setIsConfirmGrabModalOpen]); + + const onGrabCancel = useCallback(() => { + setIsConfirmGrabModalOpen(false); + }, [setIsConfirmGrabModalOpen]); + + const onOverridePress = useCallback(() => { + setIsOverrideModalOpen(true); + }, [setIsOverrideModalOpen]); + + const onOverrideModalClose = useCallback(() => { + setIsOverrideModalOpen(false); + }, [setIsOverrideModalOpen]); + + return ( + + + + + + + {formatAge(age, ageHours, ageMinutes)} + + + +
+ {title} + +
+
+ + {indexer} + + {formatBytes(size)} + + + {protocol === 'torrent' ? ( + + ) : null} + + + + + + + + + + + + } + position={tooltipPositions.BOTTOM} + /> + + + + {rejections.length ? ( + } + title="Release Rejected" + body={ +
    + {rejections.map((rejection, index) => { + return
  • {rejection}
  • ; + })} +
+ } + 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..87a6541c5 --- /dev/null +++ b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import Form from 'Components/Form/Form'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector'; +import translate from 'Utilities/String/translate'; +import SelectDownloadClientRow from './SelectDownloadClientRow'; + +interface SelectDownloadClientModalContentProps { + protocol: DownloadProtocol; + modalTitle: string; + onDownloadClientSelect(downloadClientId: number): void; + onModalClose(): void; +} + +function SelectDownloadClientModalContent( + props: SelectDownloadClientModalContentProps +) { + const { modalTitle, protocol, onDownloadClientSelect, onModalClose } = props; + + const { isFetching, isPopulated, error, items } = useSelector( + createEnabledDownloadClientsSelector(protocol) + ); + + return ( + + {modalTitle} - Select Download Client + + + {isFetching ? : null} + + {!isFetching && error ? ( +
Unable to load download clients
+ ) : 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..780346498 --- /dev/null +++ b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx @@ -0,0 +1,31 @@ +import React, { useCallback } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './SelectDownloadClientRow.css'; + +interface SelectSeasonRowProps { + id: number; + name: number; + priority: number; + onDownloadClientSelect(downloadClientId: number): unknown; +} + +function SelectDownloadClientRow(props: SelectSeasonRowProps) { + const { id, name, priority, onDownloadClientSelect } = props; + + const onSeasonSelectWrapper = useCallback(() => { + onDownloadClientSelect(id); + }, [id, onDownloadClientSelect]); + + return ( + +
{name}
+
Priority: {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..35669289a --- /dev/null +++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModal.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { sizes } from 'Helpers/Props'; +import ReleaseEpisode from 'InteractiveSearch/ReleaseEpisode'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import OverrideMatchModalContent from './OverrideMatchModalContent'; + +interface OverrideMatchModalProps { + isOpen: boolean; + title: string; + indexerId: number; + guid: string; + seriesId?: number; + seasonNumber?: number; + episodes: ReleaseEpisode[]; + languages: Language[]; + quality: QualityModel; + protocol: DownloadProtocol; + isGrabbing: boolean; + grabError: string; + onModalClose(): void; +} + +function OverrideMatchModal(props: OverrideMatchModalProps) { + const { + isOpen, + title, + indexerId, + guid, + seriesId, + seasonNumber, + episodes, + languages, + quality, + protocol, + isGrabbing, + grabError, + onModalClose, + } = props; + + return ( + + + + ); +} + +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..25038e2bb --- /dev/null +++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.tsx @@ -0,0 +1,391 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import EpisodeLanguages from 'Episode/EpisodeLanguages'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal'; +import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal'; +import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; +import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal'; +import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal'; +import ReleaseEpisode from 'InteractiveSearch/ReleaseEpisode'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import Series from 'Series/Series'; +import { grabRelease } from 'Store/Actions/releaseActions'; +import { fetchDownloadClients } from 'Store/Actions/settingsActions'; +import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import translate from 'Utilities/String/translate'; +import SelectDownloadClientModal from './DownloadClient/SelectDownloadClientModal'; +import OverrideMatchData from './OverrideMatchData'; +import styles from './OverrideMatchModalContent.css'; + +type SelectType = + | 'select' + | 'series' + | 'season' + | 'episode' + | 'quality' + | 'language' + | 'downloadClient'; + +interface OverrideMatchModalContentProps { + indexerId: number; + title: string; + guid: string; + seriesId?: number; + seasonNumber?: number; + episodes: ReleaseEpisode[]; + languages: Language[]; + quality: QualityModel; + protocol: DownloadProtocol; + isGrabbing: boolean; + grabError: string; + onModalClose(): void; +} + +function OverrideMatchModalContent(props: OverrideMatchModalContentProps) { + const modalTitle = 'Manual Grab'; + const { + indexerId, + title, + guid, + protocol, + isGrabbing, + grabError, + onModalClose, + } = props; + + const [seriesId, setSeriesId] = useState(props.seriesId); + const [seasonNumber, setSeasonNumber] = useState(props.seasonNumber); + const [episodes, setEpisodes] = useState(props.episodes); + const [languages, setLanguages] = useState(props.languages); + const [quality, setQuality] = useState(props.quality); + const [downloadClientId, setDownloadClientId] = useState(null); + const [error, setError] = useState(null); + const [selectModalOpen, setSelectModalOpen] = useState( + null + ); + + const dispatch = useDispatch(); + const series: Series = useSelector(createSeriesSelector(seriesId)); + const { items: downloadClients } = useSelector( + createEnabledDownloadClientsSelector(protocol) + ); + + const episodeInfo = useMemo(() => { + return episodes.map((episode) => { + return ( +
+ {episode.episodeNumber} + + {series.seriesType === 'anime' && + episode.absoluteEpisodeNumber != null + ? ` (${episode.absoluteEpisodeNumber})` + : ''} + + {` - ${episode.title}`} +
+ ); + }); + }, [series, episodes]); + + const onSelectModalClose = useCallback(() => { + setSelectModalOpen(null); + }, [setSelectModalOpen]); + + const onSelectSeriesPress = useCallback(() => { + setSelectModalOpen('series'); + }, [setSelectModalOpen]); + + const onSeriesSelect = useCallback( + (s: Series) => { + setSeriesId(s.id); + setSeasonNumber(undefined); + setEpisodes([]); + setSelectModalOpen(null); + }, + [setSeriesId, setSeasonNumber, setEpisodes, setSelectModalOpen] + ); + + const onSelectSeasonPress = useCallback(() => { + setSelectModalOpen('season'); + }, [setSelectModalOpen]); + + const onSeasonSelect = useCallback( + (s: number) => { + setSeasonNumber(s); + setEpisodes([]); + setSelectModalOpen(null); + }, + [setSeasonNumber, setEpisodes, setSelectModalOpen] + ); + + const onSelectEpisodePress = useCallback(() => { + setSelectModalOpen('episode'); + }, [setSelectModalOpen]); + + const onEpisodesSelect = useCallback( + (episodeMap) => { + setEpisodes(episodeMap[0].episodes); + setSelectModalOpen(null); + }, + [setEpisodes, setSelectModalOpen] + ); + + const onSelectQualityPress = useCallback(() => { + setSelectModalOpen('quality'); + }, [setSelectModalOpen]); + + const onQualitySelect = useCallback( + (quality) => { + setQuality(quality); + setSelectModalOpen(null); + }, + [setQuality, setSelectModalOpen] + ); + + const onSelectLanguagesPress = useCallback(() => { + setSelectModalOpen('language'); + }, [setSelectModalOpen]); + + const onLanguagesSelect = useCallback( + (languages) => { + setLanguages(languages); + setSelectModalOpen(null); + }, + [setLanguages, setSelectModalOpen] + ); + + const onSelectDownloadClientPress = useCallback(() => { + setSelectModalOpen('downloadClient'); + }, [setSelectModalOpen]); + + const onDownloadClientSelect = useCallback( + (downloadClientId) => { + setDownloadClientId(downloadClientId); + setSelectModalOpen(null); + }, + [setDownloadClientId, setSelectModalOpen] + ); + + const onGrabPress = useCallback(() => { + if (!seriesId) { + setError('Series must be selected'); + return; + } else if (!episodes.length) { + setError('At least one episode must be selected'); + return; + } else if (!quality) { + setError('Quality must be selected'); + return; + } else if (!languages.length) { + setError('At least one language must be selected'); + return; + } + + dispatch( + grabRelease({ + indexerId, + guid, + seriesId, + episodeIds: episodes.map((e) => e.id), + quality, + languages, + downloadClientId, + shouldOverride: true, + }) + ); + }, [ + indexerId, + guid, + seriesId, + episodes, + quality, + languages, + downloadClientId, + setError, + dispatch, + ]); + + useEffect( + () => { + dispatch(fetchDownloadClients()); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( + + + {translate('Override and Grab - {title}', { title })} + + + + + + } + /> + + + } + /> + + + } + /> + + + } + onPress={onSelectQualityPress} + /> + } + /> + + + } + onPress={onSelectLanguagesPress} + /> + } + /> + + {downloadClients.length > 1 ? ( + downloadClient.id === downloadClientId + )?.name ?? 'Default' + } + onPress={onSelectDownloadClientPress} + /> + } + /> + ) : null} + + + + +
{error || grabError}
+ +
+ + + + {translate('Grab Release')} + +
+
+ + + + + + + + 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/InteractiveSearch/ReleaseEpisode.ts b/frontend/src/InteractiveSearch/ReleaseEpisode.ts new file mode 100644 index 000000000..91ab5b7b5 --- /dev/null +++ b/frontend/src/InteractiveSearch/ReleaseEpisode.ts @@ -0,0 +1,10 @@ +interface ReleaseEpisode { + id: number; + episodeFileId: number; + seasonNumber: number; + episodeNumber: number; + absoluteEpisodeNumber?: number; + title: string; +} + +export default ReleaseEpisode; diff --git a/frontend/src/InteractiveSearch/ReleaseSceneIndicator.js b/frontend/src/InteractiveSearch/ReleaseSceneIndicator.js deleted file mode 100644 index 49603dc7c..000000000 --- a/frontend/src/InteractiveSearch/ReleaseSceneIndicator.js +++ /dev/null @@ -1,188 +0,0 @@ -import classNames from 'classnames'; -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import Icon from 'Components/Icon'; -import Popover from 'Components/Tooltip/Popover'; -import { icons, tooltipPositions } from 'Helpers/Props'; -import styles from './ReleaseSceneIndicator.css'; - -function formatReleaseNumber(seasonNumber, episodeNumbers, absoluteEpisodeNumbers) { - if (episodeNumbers && episodeNumbers.length) { - if (episodeNumbers.length > 1) { - return `${seasonNumber}x${episodeNumbers[0]}-${episodeNumbers[episodeNumbers.length - 1]}`; - } - return `${seasonNumber}x${episodeNumbers[0]}`; - } - - if (absoluteEpisodeNumbers && absoluteEpisodeNumbers.length) { - if (absoluteEpisodeNumbers.length > 1) { - return `${absoluteEpisodeNumbers[0]}-${absoluteEpisodeNumbers[absoluteEpisodeNumbers.length - 1]}`; - } - return absoluteEpisodeNumbers[0]; - } - - if (seasonNumber !== undefined) { - return `Season ${seasonNumber}`; - } - - return null; -} - -function ReleaseSceneIndicator(props) { - const { - className, - seasonNumber, - episodeNumbers, - absoluteEpisodeNumbers, - sceneSeasonNumber, - sceneEpisodeNumbers, - sceneAbsoluteEpisodeNumbers, - sceneMapping, - episodeRequested, - isDaily - } = props; - - const { - sceneOrigin, - title, - comment - } = sceneMapping || {}; - - if (isDaily) { - return null; - } - - let mappingDifferent = (sceneSeasonNumber !== undefined && seasonNumber !== sceneSeasonNumber); - - if (sceneEpisodeNumbers !== undefined) { - mappingDifferent = mappingDifferent || !_.isEqual(sceneEpisodeNumbers, episodeNumbers); - } else if (sceneAbsoluteEpisodeNumbers !== undefined) { - mappingDifferent = mappingDifferent || !_.isEqual(sceneAbsoluteEpisodeNumbers, absoluteEpisodeNumbers); - } - - if (!sceneMapping && !mappingDifferent) { - return null; - } - - const releaseNumber = formatReleaseNumber(sceneSeasonNumber, sceneEpisodeNumbers, sceneAbsoluteEpisodeNumbers); - const mappedNumber = formatReleaseNumber(seasonNumber, episodeNumbers, absoluteEpisodeNumbers); - const messages = []; - - const isMixed = (sceneOrigin === 'mixed'); - const isUnknown = (sceneOrigin === 'unknown' || sceneOrigin === 'unknown:tvdb'); - - let level = styles.levelNone; - - if (isMixed) { - level = styles.levelMixed; - messages.push(
{comment ?? 'Source'} releases exist with ambiguous numbering, unable to reliably identify episode.
); - } else if (isUnknown) { - level = styles.levelUnknown; - messages.push(
Numbering varies for this episode and release does not match any known mappings.
); - if (sceneOrigin === 'unknown') { - messages.push(
Assuming Scene numbering.
); - } else if (sceneOrigin === 'unknown:tvdb') { - messages.push(
Assuming TheTVDB numbering.
); - } - } else if (mappingDifferent) { - level = styles.levelMapped; - } else if (sceneOrigin) { - level = styles.levelNormal; - } - - if (!episodeRequested) { - if (!isMixed && !isUnknown) { - level = styles.levelNotRequested; - } - if (mappedNumber) { - messages.push(
Mapped episode wasn't requested in this search.
); - } else { - messages.push(
Unknown episode or series.
); - } - } - - const table = ( - - { - comment !== undefined && - - } - - { - title !== undefined && - - } - - { - releaseNumber !== undefined && - - } - - { - releaseNumber !== undefined && - - } - - ); - - return ( - - - - } - title="Scene Info" - body={ -
- {table} - { - messages.length && -
- {messages} -
|| null - } -
- } - position={tooltipPositions.RIGHT} - /> - ); -} - -ReleaseSceneIndicator.propTypes = { - className: PropTypes.string.isRequired, - seasonNumber: PropTypes.number, - episodeNumbers: PropTypes.arrayOf(PropTypes.number), - absoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number), - sceneSeasonNumber: PropTypes.number, - sceneEpisodeNumbers: PropTypes.arrayOf(PropTypes.number), - sceneAbsoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number), - sceneMapping: PropTypes.object.isRequired, - episodeRequested: PropTypes.bool.isRequired, - isDaily: PropTypes.bool.isRequired -}; - -export default ReleaseSceneIndicator; diff --git a/frontend/src/InteractiveSearch/ReleaseSceneIndicator.tsx b/frontend/src/InteractiveSearch/ReleaseSceneIndicator.tsx new file mode 100644 index 000000000..3f7ad6d9c --- /dev/null +++ b/frontend/src/InteractiveSearch/ReleaseSceneIndicator.tsx @@ -0,0 +1,215 @@ +import classNames from 'classnames'; +import _ from 'lodash'; +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import Icon from 'Components/Icon'; +import Popover from 'Components/Tooltip/Popover'; +import { icons, tooltipPositions } from 'Helpers/Props'; +import styles from './ReleaseSceneIndicator.css'; + +function formatReleaseNumber( + seasonNumber, + episodeNumbers, + absoluteEpisodeNumbers +) { + if (episodeNumbers && episodeNumbers.length) { + if (episodeNumbers.length > 1) { + return `${seasonNumber}x${episodeNumbers[0]}-${ + episodeNumbers[episodeNumbers.length - 1] + }`; + } + return `${seasonNumber}x${episodeNumbers[0]}`; + } + + if (absoluteEpisodeNumbers && absoluteEpisodeNumbers.length) { + if (absoluteEpisodeNumbers.length > 1) { + return `${absoluteEpisodeNumbers[0]}-${ + absoluteEpisodeNumbers[absoluteEpisodeNumbers.length - 1] + }`; + } + return absoluteEpisodeNumbers[0]; + } + + if (seasonNumber !== undefined) { + return `Season ${seasonNumber}`; + } + + return null; +} + +interface ReleaseSceneIndicatorProps { + className: string; + seasonNumber?: number; + episodeNumbers?: number[]; + absoluteEpisodeNumbers?: number[]; + sceneSeasonNumber?: number; + sceneEpisodeNumbers?: number[]; + sceneAbsoluteEpisodeNumbers?: number[]; + sceneMapping?: { + sceneOrigin?: string; + title?: string; + comment?: string; + }; + episodeRequested: boolean; + isDaily: boolean; +} + +function ReleaseSceneIndicator(props: ReleaseSceneIndicatorProps) { + const { + className, + seasonNumber, + episodeNumbers, + absoluteEpisodeNumbers, + sceneSeasonNumber, + sceneEpisodeNumbers, + sceneAbsoluteEpisodeNumbers, + sceneMapping = {}, + episodeRequested, + isDaily, + } = props; + + const { sceneOrigin, title, comment } = sceneMapping; + + if (isDaily) { + return null; + } + + let mappingDifferent = + sceneSeasonNumber !== undefined && seasonNumber !== sceneSeasonNumber; + + if (sceneEpisodeNumbers !== undefined) { + mappingDifferent = + mappingDifferent || !_.isEqual(sceneEpisodeNumbers, episodeNumbers); + } else if (sceneAbsoluteEpisodeNumbers !== undefined) { + mappingDifferent = + mappingDifferent || + !_.isEqual(sceneAbsoluteEpisodeNumbers, absoluteEpisodeNumbers); + } + + if (!sceneMapping && !mappingDifferent) { + return null; + } + + const releaseNumber = formatReleaseNumber( + sceneSeasonNumber, + sceneEpisodeNumbers, + sceneAbsoluteEpisodeNumbers + ); + const mappedNumber = formatReleaseNumber( + seasonNumber, + episodeNumbers, + absoluteEpisodeNumbers + ); + const messages = []; + + const isMixed = sceneOrigin === 'mixed'; + const isUnknown = sceneOrigin === 'unknown' || sceneOrigin === 'unknown:tvdb'; + + let level = styles.levelNone; + + if (isMixed) { + level = styles.levelMixed; + messages.push( +
+ {comment ?? 'Source'} releases exist with ambiguous numbering, unable to + reliably identify episode. +
+ ); + } else if (isUnknown) { + level = styles.levelUnknown; + messages.push( +
+ Numbering varies for this episode and release does not match any known + mappings. +
+ ); + if (sceneOrigin === 'unknown') { + messages.push(
Assuming Scene numbering.
); + } else if (sceneOrigin === 'unknown:tvdb') { + messages.push(
Assuming TheTVDB numbering.
); + } + } else if (mappingDifferent) { + level = styles.levelMapped; + } else if (sceneOrigin) { + level = styles.levelNormal; + } + + if (!episodeRequested) { + if (!isMixed && !isUnknown) { + level = styles.levelNotRequested; + } + if (mappedNumber) { + messages.push( +
+ Mapped episode wasn't requested in this search. +
+ ); + } else { + messages.push(
Unknown episode or series.
); + } + } + + const table = ( + + {comment !== undefined && ( + + )} + + {title !== undefined && ( + + )} + + {releaseNumber !== undefined && ( + + )} + + {releaseNumber !== undefined && ( + + )} + + ); + + return ( + + + + } + title="Scene Info" + body={ +
+ {table} + {(messages.length && ( +
{messages}
+ )) || + null} +
+ } + position={tooltipPositions.RIGHT} + /> + ); +} + +export default ReleaseSceneIndicator; diff --git a/frontend/src/Series/Series.ts b/frontend/src/Series/Series.ts index 9cbce6b6e..cecc8086f 100644 --- a/frontend/src/Series/Series.ts +++ b/frontend/src/Series/Series.ts @@ -1,5 +1,7 @@ import ModelBase from 'App/ModelBase'; +export type SeriesType = 'anime' | 'daily' | 'standard'; + export interface Image { coverType: string; url: string; @@ -59,7 +61,7 @@ interface Series extends ModelBase { runtime: number; seasonFolder: boolean; seasons: Season[]; - seriesType: string; + seriesType: SeriesType; sortTitle: string; statistics: Statistics; status: string; diff --git a/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts b/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts new file mode 100644 index 000000000..fc832643c --- /dev/null +++ b/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts @@ -0,0 +1,21 @@ +import { createSelector } from 'reselect'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import sortByName from 'Utilities/Array/sortByName'; + +export default function createEnabledDownloadClientsSelector( + protocol: DownloadProtocol +) { + return createSelector( + createSortedSectionSelector('settings.downloadClients', sortByName), + (downloadClients) => { + const { isFetching, isPopulated, error, items } = downloadClients; + + const clients = items.filter( + (item) => item.protocol === protocol && item.enable + ); + + return { isFetching, isPopulated, error, items: clients }; + } + ); +} diff --git a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs index 2adc35fed..06ccb84d1 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using FizzWare.NBuilder; using FluentAssertions; @@ -59,7 +59,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests } [Test] - public void should_download_report_if_epsiode_was_not_already_downloaded() + public void should_download_report_if_episode_was_not_already_downloaded() { var episodes = new List { GetEpisode(1) }; var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p)); @@ -68,7 +68,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisode)); Subject.ProcessDecisions(decisions); - Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Once()); + Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny(), null), Times.Once()); } [Test] @@ -82,7 +82,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisode)); 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] @@ -101,7 +101,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisode2)); 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] @@ -166,7 +166,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests var decisions = new List(); decisions.Add(new DownloadDecision(remoteEpisode)); - Mocker.GetMock().Setup(s => s.DownloadReport(It.IsAny())).Throws(new Exception()); + Mocker.GetMock().Setup(s => s.DownloadReport(It.IsAny(), null)).Throws(new Exception()); Subject.ProcessDecisions(decisions).Grabbed.Should().BeEmpty(); ExceptionVerification.ExpectedWarns(1); } @@ -191,7 +191,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary))); Subject.ProcessDecisions(decisions); - Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Never()); + Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny(), null), Times.Never()); } [Test] @@ -232,11 +232,11 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisode)); decisions.Add(new DownloadDecision(remoteEpisode)); - Mocker.GetMock().Setup(s => s.DownloadReport(It.IsAny())) + Mocker.GetMock().Setup(s => s.DownloadReport(It.IsAny(), null)) .Throws(new DownloadClientUnavailableException("Download client failed")); 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] @@ -250,12 +250,12 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisode)); decisions.Add(new DownloadDecision(remoteEpisode2)); - 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")); 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] @@ -268,7 +268,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests decisions.Add(new DownloadDecision(remoteEpisode)); Mocker.GetMock() - .Setup(s => s.DownloadReport(It.IsAny())) + .Setup(s => s.DownloadReport(It.IsAny(), null)) .Throws(new ReleaseUnavailableException(remoteEpisode.Release, "That 404 Error is not just a Quirk")); var result = Subject.ProcessDecisions(decisions); diff --git a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs index da9411d0f..b2954fcc1 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs @@ -84,7 +84,7 @@ namespace NzbDrone.Core.Test.Download var mock = WithUsenetClient(); mock.Setup(s => s.Download(It.IsAny())); - Subject.DownloadReport(_parseResult); + Subject.DownloadReport(_parseResult, null); VerifyEventPublished(); } @@ -95,7 +95,7 @@ namespace NzbDrone.Core.Test.Download var mock = WithUsenetClient(); mock.Setup(s => s.Download(It.IsAny())); - Subject.DownloadReport(_parseResult); + Subject.DownloadReport(_parseResult, null); mock.Verify(s => s.Download(It.IsAny()), Times.Once()); } @@ -107,7 +107,7 @@ namespace NzbDrone.Core.Test.Download mock.Setup(s => s.Download(It.IsAny())) .Throws(new WebException()); - Assert.Throws(() => Subject.DownloadReport(_parseResult)); + Assert.Throws(() => Subject.DownloadReport(_parseResult, null)); VerifyEventNotPublished(); } @@ -122,7 +122,7 @@ namespace NzbDrone.Core.Test.Download throw new ReleaseDownloadException(v.Release, "Error", new WebException()); }); - Assert.Throws(() => Subject.DownloadReport(_parseResult)); + Assert.Throws(() => Subject.DownloadReport(_parseResult, null)); Mocker.GetMock() .Verify(v => v.RecordFailure(It.IsAny(), It.IsAny()), Times.Once()); @@ -142,7 +142,7 @@ namespace NzbDrone.Core.Test.Download throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response)); }); - Assert.Throws(() => Subject.DownloadReport(_parseResult)); + Assert.Throws(() => Subject.DownloadReport(_parseResult, null)); Mocker.GetMock() .Verify(v => v.RecordFailure(It.IsAny(), TimeSpan.FromMinutes(5.0)), Times.Once()); @@ -162,7 +162,7 @@ namespace NzbDrone.Core.Test.Download throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response)); }); - Assert.Throws(() => Subject.DownloadReport(_parseResult)); + Assert.Throws(() => Subject.DownloadReport(_parseResult, null)); Mocker.GetMock() .Verify(v => v.RecordFailure(It.IsAny(), @@ -176,7 +176,7 @@ namespace NzbDrone.Core.Test.Download mock.Setup(s => s.Download(It.IsAny())) .Throws(new DownloadClientException("Some Error")); - Assert.Throws(() => Subject.DownloadReport(_parseResult)); + Assert.Throws(() => Subject.DownloadReport(_parseResult, null)); Mocker.GetMock() .Verify(v => v.RecordFailure(It.IsAny(), It.IsAny()), Times.Never()); @@ -192,7 +192,7 @@ namespace NzbDrone.Core.Test.Download throw new ReleaseUnavailableException(v.Release, "Error", new WebException()); }); - Assert.Throws(() => Subject.DownloadReport(_parseResult)); + Assert.Throws(() => Subject.DownloadReport(_parseResult, null)); Mocker.GetMock() .Verify(v => v.RecordFailure(It.IsAny(), It.IsAny()), Times.Never()); @@ -201,7 +201,7 @@ namespace NzbDrone.Core.Test.Download [Test] public void should_not_attempt_download_if_client_isnt_configured() { - Assert.Throws(() => Subject.DownloadReport(_parseResult)); + Assert.Throws(() => Subject.DownloadReport(_parseResult, null)); Mocker.GetMock().Verify(c => c.Download(It.IsAny()), Times.Never()); VerifyEventNotPublished(); @@ -223,7 +223,7 @@ namespace NzbDrone.Core.Test.Download } }); - Subject.DownloadReport(_parseResult); + Subject.DownloadReport(_parseResult, null); Mocker.GetMock().Verify(c => c.GetBlockedProviders(), Times.Never()); mockUsenet.Verify(c => c.Download(It.IsAny()), Times.Once()); @@ -236,7 +236,7 @@ namespace NzbDrone.Core.Test.Download var mockTorrent = WithTorrentClient(); var mockUsenet = WithUsenetClient(); - Subject.DownloadReport(_parseResult); + Subject.DownloadReport(_parseResult, null); mockTorrent.Verify(c => c.Download(It.IsAny()), Times.Never()); mockUsenet.Verify(c => c.Download(It.IsAny()), Times.Once()); @@ -250,7 +250,7 @@ namespace NzbDrone.Core.Test.Download _parseResult.Release.DownloadProtocol = DownloadProtocol.Torrent; - Subject.DownloadReport(_parseResult); + Subject.DownloadReport(_parseResult, null); mockTorrent.Verify(c => c.Download(It.IsAny()), Times.Once()); mockUsenet.Verify(c => c.Download(It.IsAny()), Times.Never()); diff --git a/src/NzbDrone.Core/Download/DownloadService.cs b/src/NzbDrone.Core/Download/DownloadService.cs index 6eb6cd272..b70fbf3c6 100644 --- a/src/NzbDrone.Core/Download/DownloadService.cs +++ b/src/NzbDrone.Core/Download/DownloadService.cs @@ -15,7 +15,7 @@ namespace NzbDrone.Core.Download { public interface IDownloadService { - void DownloadReport(RemoteEpisode remoteEpisode); + void DownloadReport(RemoteEpisode remoteEpisode, int? downloadClientId); } public class DownloadService : IDownloadService @@ -45,13 +45,21 @@ namespace NzbDrone.Core.Download _logger = logger; } - public void DownloadReport(RemoteEpisode remoteEpisode) + public void DownloadReport(RemoteEpisode remoteEpisode, int? downloadClientId) + { + var downloadClient = downloadClientId.HasValue + ? _downloadClientProvider.Get(downloadClientId.Value) + : _downloadClientProvider.GetDownloadClient(remoteEpisode.Release.DownloadProtocol, remoteEpisode.Release.IndexerId); + + DownloadReport(remoteEpisode, downloadClient); + } + + private void DownloadReport(RemoteEpisode remoteEpisode, IDownloadClient downloadClient) { Ensure.That(remoteEpisode.Series, () => remoteEpisode.Series).IsNotNull(); Ensure.That(remoteEpisode.Episodes, () => remoteEpisode.Episodes).HasItems(); var downloadTitle = remoteEpisode.Release.Title; - var downloadClient = _downloadClientProvider.GetDownloadClient(remoteEpisode.Release.DownloadProtocol, remoteEpisode.Release.IndexerId); if (downloadClient == null) { diff --git a/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs b/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs index 0b8837fea..7e9990df0 100644 --- a/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs +++ b/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -74,7 +74,7 @@ namespace NzbDrone.Core.Download try { _logger.Trace("Grabbing from Indexer {0} at priority {1}.", remoteEpisode.Release.Indexer, remoteEpisode.Release.IndexerPriority); - _downloadService.DownloadReport(remoteEpisode); + _downloadService.DownloadReport(remoteEpisode, null); grabbed.Add(report); } catch (ReleaseUnavailableException) diff --git a/src/Sonarr.Api.V3/Indexers/ReleaseController.cs b/src/Sonarr.Api.V3/Indexers/ReleaseController.cs index f0f73a121..01e7f9792 100644 --- a/src/Sonarr.Api.V3/Indexers/ReleaseController.cs +++ b/src/Sonarr.Api.V3/Indexers/ReleaseController.cs @@ -4,6 +4,7 @@ using FluentValidation; using Microsoft.AspNetCore.Mvc; using NLog; using NzbDrone.Common.Cache; +using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Extensions; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; @@ -79,6 +80,23 @@ namespace Sonarr.Api.V3.Indexers try { + if (release.ShouldOverride == true) + { + Ensure.That(release.SeriesId, () => release.SeriesId).IsNotNull(); + Ensure.That(release.EpisodeIds, () => release.EpisodeIds).IsNotNull(); + Ensure.That(release.EpisodeIds, () => release.EpisodeIds).HasItems(); + Ensure.That(release.Quality, () => release.Quality).IsNotNull(); + Ensure.That(release.Languages, () => release.Languages).IsNotNull(); + + // Clone the remote episode so we don't overwrite anything on the original + remoteEpisode = remoteEpisode.JsonClone(); + + remoteEpisode.Series = _seriesService.GetSeries(release.SeriesId.Value); + remoteEpisode.Episodes = _episodeService.GetEpisodes(release.EpisodeIds); + remoteEpisode.ParsedEpisodeInfo.Quality = release.Quality; + remoteEpisode.Languages = release.Languages; + } + if (remoteEpisode.Series == null) { if (release.EpisodeId.HasValue) @@ -125,7 +143,7 @@ namespace Sonarr.Api.V3.Indexers throw new NzbDroneClientException(HttpStatusCode.NotFound, "Unable to parse episodes in the release"); } - _downloadService.DownloadReport(remoteEpisode); + _downloadService.DownloadReport(remoteEpisode, release.DownloadClientId); } catch (ReleaseDownloadException ex) { diff --git a/src/Sonarr.Api.V3/Indexers/ReleaseResource.cs b/src/Sonarr.Api.V3/Indexers/ReleaseResource.cs index 079b440fb..578e652f2 100644 --- a/src/Sonarr.Api.V3/Indexers/ReleaseResource.cs +++ b/src/Sonarr.Api.V3/Indexers/ReleaseResource.cs @@ -7,6 +7,7 @@ using NzbDrone.Core.Indexers; using NzbDrone.Core.Languages; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; +using NzbDrone.Core.Tv; using Sonarr.Api.V3.CustomFormats; using Sonarr.Api.V3.Series; using Sonarr.Http.REST; @@ -40,6 +41,8 @@ namespace Sonarr.Api.V3.Indexers public int? MappedSeasonNumber { get; set; } public int[] MappedEpisodeNumbers { get; set; } public int[] MappedAbsoluteEpisodeNumbers { get; set; } + public int? MappedSeriesId { get; set; } + public IEnumerable MappedEpisodeInfo { get; set; } public bool Approved { get; set; } public bool TemporarilyRejected { get; set; } public bool Rejected { get; set; } @@ -75,6 +78,15 @@ namespace Sonarr.Api.V3.Indexers [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int? EpisodeId { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public List EpisodeIds { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int? DownloadClientId { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool? ShouldOverride { get; set; } } public static class ReleaseResourceMapper @@ -109,9 +121,11 @@ namespace Sonarr.Api.V3.Indexers SeriesTitle = parsedEpisodeInfo.SeriesTitle, EpisodeNumbers = parsedEpisodeInfo.EpisodeNumbers, AbsoluteEpisodeNumbers = parsedEpisodeInfo.AbsoluteEpisodeNumbers, + MappedSeriesId = remoteEpisode.Series?.Id, MappedSeasonNumber = remoteEpisode.Episodes.FirstOrDefault()?.SeasonNumber, MappedEpisodeNumbers = remoteEpisode.Episodes.Select(v => v.EpisodeNumber).ToArray(), MappedAbsoluteEpisodeNumbers = remoteEpisode.Episodes.Where(v => v.AbsoluteEpisodeNumber.HasValue).Select(v => v.AbsoluteEpisodeNumber.Value).ToArray(), + MappedEpisodeInfo = remoteEpisode.Episodes.Select(v => new ReleaseEpisodeResource(v)), Approved = model.Approved, TemporarilyRejected = model.TemporarilyRejected, Rejected = model.Rejected, @@ -178,4 +192,26 @@ namespace Sonarr.Api.V3.Indexers return model; } } + + public class ReleaseEpisodeResource + { + public int Id { get; set; } + public int SeasonNumber { get; set; } + public int EpisodeNumber { get; set; } + public int? AbsoluteEpisodeNumber { get; set; } + public string Title { get; set; } + + public ReleaseEpisodeResource() + { + } + + public ReleaseEpisodeResource(Episode episode) + { + Id = episode.Id; + SeasonNumber = episode.SeasonNumber; + EpisodeNumber = episode.EpisodeNumber; + AbsoluteEpisodeNumber = episode.AbsoluteEpisodeNumber; + Title = episode.Title; + } + } } diff --git a/src/Sonarr.Api.V3/Queue/QueueActionController.cs b/src/Sonarr.Api.V3/Queue/QueueActionController.cs index 8321df02c..e8d0bfaad 100644 --- a/src/Sonarr.Api.V3/Queue/QueueActionController.cs +++ b/src/Sonarr.Api.V3/Queue/QueueActionController.cs @@ -29,7 +29,7 @@ namespace Sonarr.Api.V3.Queue throw new NotFoundException(); } - _downloadService.DownloadReport(pendingRelease.RemoteEpisode); + _downloadService.DownloadReport(pendingRelease.RemoteEpisode, null); return new { }; } @@ -47,7 +47,7 @@ namespace Sonarr.Api.V3.Queue throw new NotFoundException(); } - _downloadService.DownloadReport(pendingRelease.RemoteEpisode); + _downloadService.DownloadReport(pendingRelease.RemoteEpisode, null); } return new { };