From 93ca96b7bdb169f973337ff8918753528bc9cdbe Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 27 Mar 2023 16:49:12 -0700 Subject: [PATCH] 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 + frontend/src/Search/SearchIndexConnector.js | 10 + .../SelectDownloadClientModal.tsx | 31 ++ .../SelectDownloadClientModalContent.tsx | 74 +++ .../SelectDownloadClientRow.css | 6 + .../SelectDownloadClientRow.css.d.ts | 7 + .../SelectDownloadClientRow.tsx | 32 ++ .../Table/OverrideMatch/OverrideMatchData.css | 17 + .../OverrideMatch/OverrideMatchData.css.d.ts | 9 + .../Table/OverrideMatch/OverrideMatchData.tsx | 35 ++ .../OverrideMatch/OverrideMatchModal.tsx | 45 ++ .../OverrideMatchModalContent.css | 49 ++ .../OverrideMatchModalContent.css.d.ts | 11 + .../OverrideMatchModalContent.tsx | 150 ++++++ frontend/src/Search/Table/SearchIndexRow.css | 30 ++ .../src/Search/Table/SearchIndexRow.css.d.ts | 3 + frontend/src/Search/Table/SearchIndexRow.js | 431 ------------------ frontend/src/Search/Table/SearchIndexRow.tsx | 395 ++++++++++++++++ .../createEnabledDownloadClientsSelector.ts | 22 + frontend/src/Utilities/Number/formatBytes.js | 4 +- src/NzbDrone.Core/Localization/Core/en.json | 8 + src/Prowlarr.Api.V1/Search/ReleaseResource.cs | 4 + .../Search/SearchController.cs | 2 +- 25 files changed, 953 insertions(+), 435 deletions(-) create mode 100644 frontend/src/DownloadClient/DownloadProtocol.ts create mode 100644 frontend/src/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx create mode 100644 frontend/src/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx create mode 100644 frontend/src/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientRow.css create mode 100644 frontend/src/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts create mode 100644 frontend/src/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx create mode 100644 frontend/src/Search/Table/OverrideMatch/OverrideMatchData.css create mode 100644 frontend/src/Search/Table/OverrideMatch/OverrideMatchData.css.d.ts create mode 100644 frontend/src/Search/Table/OverrideMatch/OverrideMatchData.tsx create mode 100644 frontend/src/Search/Table/OverrideMatch/OverrideMatchModal.tsx create mode 100644 frontend/src/Search/Table/OverrideMatch/OverrideMatchModalContent.css create mode 100644 frontend/src/Search/Table/OverrideMatch/OverrideMatchModalContent.css.d.ts create mode 100644 frontend/src/Search/Table/OverrideMatch/OverrideMatchModalContent.tsx delete mode 100644 frontend/src/Search/Table/SearchIndexRow.js create mode 100644 frontend/src/Search/Table/SearchIndexRow.tsx create mode 100644 frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.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 834452242..773748996 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, @@ -141,6 +142,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/Search/SearchIndexConnector.js b/frontend/src/Search/SearchIndexConnector.js index e3302e73c..78a9866b2 100644 --- a/frontend/src/Search/SearchIndexConnector.js +++ b/frontend/src/Search/SearchIndexConnector.js @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import withScrollPosition from 'Components/withScrollPosition'; import { bulkGrabReleases, cancelFetchReleases, clearReleases, fetchReleases, setReleasesFilter, setReleasesSort, setReleasesTableOption } from 'Store/Actions/releaseActions'; +import { fetchDownloadClients } from 'Store/Actions/Settings/downloadClients'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createReleaseClientSideCollectionItemsSelector from 'Store/Selectors/createReleaseClientSideCollectionItemsSelector'; import SearchIndex from './SearchIndex'; @@ -55,12 +56,20 @@ function createMapDispatchToProps(dispatch, props) { dispatchClearReleases() { dispatch(clearReleases()); + }, + + dispatchFetchDownloadClients() { + dispatch(fetchDownloadClients()); } }; } class SearchIndexConnector extends Component { + componentDidMount() { + this.props.dispatchFetchDownloadClients(); + } + componentWillUnmount() { this.props.dispatchCancelFetchReleases(); this.props.dispatchClearReleases(); @@ -85,6 +94,7 @@ SearchIndexConnector.propTypes = { onBulkGrabPress: PropTypes.func.isRequired, dispatchCancelFetchReleases: PropTypes.func.isRequired, dispatchClearReleases: PropTypes.func.isRequired, + dispatchFetchDownloadClients: PropTypes.func.isRequired, items: PropTypes.arrayOf(PropTypes.object) }; diff --git a/frontend/src/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx b/frontend/src/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx new file mode 100644 index 000000000..81bf86e59 --- /dev/null +++ b/frontend/src/Search/Table/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/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx b/frontend/src/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx new file mode 100644 index 000000000..63e15808f --- /dev/null +++ b/frontend/src/Search/Table/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/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientRow.css b/frontend/src/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientRow.css new file mode 100644 index 000000000..6525db977 --- /dev/null +++ b/frontend/src/Search/Table/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/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts b/frontend/src/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts new file mode 100644 index 000000000..10c2d3948 --- /dev/null +++ b/frontend/src/Search/Table/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/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx b/frontend/src/Search/Table/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx new file mode 100644 index 000000000..6f98d60b4 --- /dev/null +++ b/frontend/src/Search/Table/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/Search/Table/OverrideMatch/OverrideMatchData.css b/frontend/src/Search/Table/OverrideMatch/OverrideMatchData.css new file mode 100644 index 000000000..bd4d2f788 --- /dev/null +++ b/frontend/src/Search/Table/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/Search/Table/OverrideMatch/OverrideMatchData.css.d.ts b/frontend/src/Search/Table/OverrideMatch/OverrideMatchData.css.d.ts new file mode 100644 index 000000000..dd3ac4575 --- /dev/null +++ b/frontend/src/Search/Table/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/Search/Table/OverrideMatch/OverrideMatchData.tsx b/frontend/src/Search/Table/OverrideMatch/OverrideMatchData.tsx new file mode 100644 index 000000000..82d6bd812 --- /dev/null +++ b/frontend/src/Search/Table/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/Search/Table/OverrideMatch/OverrideMatchModal.tsx b/frontend/src/Search/Table/OverrideMatch/OverrideMatchModal.tsx new file mode 100644 index 000000000..16d62ea7c --- /dev/null +++ b/frontend/src/Search/Table/OverrideMatch/OverrideMatchModal.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { sizes } from 'Helpers/Props'; +import OverrideMatchModalContent from './OverrideMatchModalContent'; + +interface OverrideMatchModalProps { + isOpen: boolean; + title: string; + indexerId: number; + guid: string; + protocol: DownloadProtocol; + isGrabbing: boolean; + grabError?: string; + onModalClose(): void; +} + +function OverrideMatchModal(props: OverrideMatchModalProps) { + const { + isOpen, + title, + indexerId, + guid, + protocol, + isGrabbing, + grabError, + onModalClose, + } = props; + + return ( + + + + ); +} + +export default OverrideMatchModal; diff --git a/frontend/src/Search/Table/OverrideMatch/OverrideMatchModalContent.css b/frontend/src/Search/Table/OverrideMatch/OverrideMatchModalContent.css new file mode 100644 index 000000000..a5b4b8d52 --- /dev/null +++ b/frontend/src/Search/Table/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/Search/Table/OverrideMatch/OverrideMatchModalContent.css.d.ts b/frontend/src/Search/Table/OverrideMatch/OverrideMatchModalContent.css.d.ts new file mode 100644 index 000000000..79c77d6b5 --- /dev/null +++ b/frontend/src/Search/Table/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/Search/Table/OverrideMatch/OverrideMatchModalContent.tsx b/frontend/src/Search/Table/OverrideMatch/OverrideMatchModalContent.tsx new file mode 100644 index 000000000..fbe0ec450 --- /dev/null +++ b/frontend/src/Search/Table/OverrideMatch/OverrideMatchModalContent.tsx @@ -0,0 +1,150 @@ +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 { grabRelease } from 'Store/Actions/releaseActions'; +import { fetchDownloadClients } from 'Store/Actions/settingsActions'; +import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector'; +import translate from 'Utilities/String/translate'; +import SelectDownloadClientModal from './DownloadClient/SelectDownloadClientModal'; +import OverrideMatchData from './OverrideMatchData'; +import styles from './OverrideMatchModalContent.css'; + +type SelectType = 'select' | 'downloadClient'; + +interface OverrideMatchModalContentProps { + indexerId: number; + title: string; + guid: string; + 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 [downloadClientId, setDownloadClientId] = useState(null); + const [selectModalOpen, setSelectModalOpen] = useState( + null + ); + const previousIsGrabbing = usePrevious(isGrabbing); + + const dispatch = useDispatch(); + const { items: downloadClients } = useSelector( + createEnabledDownloadClientsSelector(protocol) + ); + + const onSelectModalClose = useCallback(() => { + setSelectModalOpen(null); + }, [setSelectModalOpen]); + + const onSelectDownloadClientPress = useCallback(() => { + setSelectModalOpen('downloadClient'); + }, [setSelectModalOpen]); + + const onDownloadClientSelect = useCallback( + (downloadClientId: number) => { + setDownloadClientId(downloadClientId); + setSelectModalOpen(null); + }, + [setDownloadClientId, setSelectModalOpen] + ); + + const onGrabPress = useCallback(() => { + dispatch( + grabRelease({ + indexerId, + guid, + downloadClientId, + }) + ); + }, [indexerId, guid, downloadClientId, dispatch]); + + useEffect(() => { + if (!isGrabbing && previousIsGrabbing) { + onModalClose(); + } + }, [isGrabbing, previousIsGrabbing, onModalClose]); + + useEffect( + () => { + dispatch(fetchDownloadClients()); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( + + + {translate('OverrideGrabModalTitle', { title })} + + + + + {downloadClients.length > 1 ? ( + downloadClient.id === downloadClientId + )?.name ?? translate('Default') + } + onPress={onSelectDownloadClientPress} + /> + } + /> + ) : null} + + + + +
{grabError}
+ +
+ + + + {translate('GrabRelease')} + +
+
+ + +
+ ); +} + +export default OverrideMatchModalContent; diff --git a/frontend/src/Search/Table/SearchIndexRow.css b/frontend/src/Search/Table/SearchIndexRow.css index 37b59a35d..b36ec4071 100644 --- a/frontend/src/Search/Table/SearchIndexRow.css +++ b/frontend/src/Search/Table/SearchIndexRow.css @@ -67,3 +67,33 @@ color: var(--textColor); } + +.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/Search/Table/SearchIndexRow.css.d.ts b/frontend/src/Search/Table/SearchIndexRow.css.d.ts index 6d625f58a..7552b96f9 100644 --- a/frontend/src/Search/Table/SearchIndexRow.css.d.ts +++ b/frontend/src/Search/Table/SearchIndexRow.css.d.ts @@ -6,12 +6,15 @@ interface CssExports { 'category': string; 'cell': string; 'checkInput': string; + 'downloadIcon': string; 'downloadLink': string; 'externalLinks': string; 'files': string; 'grabs': string; 'indexer': string; 'indexerFlags': string; + 'interactiveIcon': string; + 'manualDownloadContent': string; 'peers': string; 'protocol': string; 'size': string; diff --git a/frontend/src/Search/Table/SearchIndexRow.js b/frontend/src/Search/Table/SearchIndexRow.js deleted file mode 100644 index 613605e02..000000000 --- a/frontend/src/Search/Table/SearchIndexRow.js +++ /dev/null @@ -1,431 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; -import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; -import Popover from 'Components/Tooltip/Popover'; -import { icons, kinds, tooltipPositions } from 'Helpers/Props'; -import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import formatAge from 'Utilities/Number/formatAge'; -import formatBytes from 'Utilities/Number/formatBytes'; -import titleCase from 'Utilities/String/titleCase'; -import translate from 'Utilities/String/translate'; -import CategoryLabel from './CategoryLabel'; -import Peers from './Peers'; -import ReleaseLinks from './ReleaseLinks'; -import styles from './SearchIndexRow.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('AddedToDownloadClient'); - } else if (grabError) { - return grabError; - } - - return translate('AddToDownloadClient'); -} - -class SearchIndexRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isConfirmGrabModalOpen: false - }; - } - - // - // Listeners - - onGrabPress = () => { - const { - guid, - indexerId, - onGrabPress - } = this.props; - - onGrabPress({ - guid, - indexerId - }); - }; - - onSavePress = () => { - const { - downloadUrl, - fileName, - onSavePress - } = this.props; - - onSavePress({ - downloadUrl, - fileName - }); - }; - - // - // Render - - render() { - const { - guid, - protocol, - downloadUrl, - magnetUrl, - categories, - age, - ageHours, - ageMinutes, - publishDate, - title, - infoUrl, - indexer, - size, - files, - grabs, - seeders, - leechers, - imdbId, - tmdbId, - tvdbId, - tvMazeId, - indexerFlags, - columns, - isGrabbing, - isGrabbed, - grabError, - longDateFormat, - timeFormat, - isSelected, - onSelectedChange - } = this.props; - - return ( - <> - { - columns.map((column) => { - const { - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (column.name === 'select') { - return ( - - ); - } - - if (column.name === 'protocol') { - return ( - - - - ); - } - - if (column.name === 'age') { - return ( - - {formatAge(age, ageHours, ageMinutes)} - - ); - } - - if (column.name === 'sortTitle') { - return ( - - -
- {title} -
- -
- ); - } - - if (column.name === 'indexer') { - return ( - - {indexer} - - ); - } - - if (column.name === 'size') { - return ( - - {formatBytes(size)} - - ); - } - - if (column.name === 'files') { - return ( - - {files} - - ); - } - - if (column.name === 'grabs') { - return ( - - {grabs} - - ); - } - - if (column.name === 'peers') { - return ( - - { - protocol === 'torrent' && - - } - - ); - } - - if (column.name === 'category') { - return ( - - - - ); - } - - if (column.name === 'indexerFlags') { - return ( - - { - !!indexerFlags.length && - - } - title={translate('IndexerFlags')} - body={ -
    - { - indexerFlags.map((flag, index) => { - return ( -
  • - {titleCase(flag)} -
  • - ); - }) - } -
- } - position={tooltipPositions.LEFT} - /> - } -
- ); - } - - if (column.name === 'actions') { - return ( - - - - { - downloadUrl ? - : - null - } - - { - magnetUrl ? - : - null - } - - { - imdbId || tmdbId || tvdbId || tvMazeId ? ( - - } - title={translate('Links')} - body={ - - } - kind={kinds.INVERSE} - position={tooltipPositions.TOP} - /> - ) : null - } - - ); - } - - return null; - }) - } - - ); - } -} - -SearchIndexRow.propTypes = { - guid: PropTypes.string.isRequired, - categories: PropTypes.arrayOf(PropTypes.object).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, - fileName: PropTypes.string.isRequired, - infoUrl: PropTypes.string.isRequired, - downloadUrl: PropTypes.string, - magnetUrl: PropTypes.string, - indexerId: PropTypes.number.isRequired, - indexer: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - files: PropTypes.number, - grabs: PropTypes.number, - seeders: PropTypes.number, - leechers: PropTypes.number, - imdbId: PropTypes.number, - tmdbId: PropTypes.number, - tvdbId: PropTypes.number, - tvMazeId: PropTypes.number, - indexerFlags: PropTypes.arrayOf(PropTypes.string).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onGrabPress: PropTypes.func.isRequired, - onSavePress: PropTypes.func.isRequired, - isGrabbing: PropTypes.bool.isRequired, - isGrabbed: PropTypes.bool.isRequired, - grabError: PropTypes.string, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isSelected: PropTypes.bool, - onSelectedChange: PropTypes.func.isRequired -}; - -SearchIndexRow.defaultProps = { - isGrabbing: false, - isGrabbed: false -}; - -export default SearchIndexRow; diff --git a/frontend/src/Search/Table/SearchIndexRow.tsx b/frontend/src/Search/Table/SearchIndexRow.tsx new file mode 100644 index 000000000..455d00c81 --- /dev/null +++ b/frontend/src/Search/Table/SearchIndexRow.tsx @@ -0,0 +1,395 @@ +import React, { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; +import Column from 'Components/Table/Column'; +import Popover from 'Components/Tooltip/Popover'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel'; +import { IndexerCategory } from 'Indexer/Indexer'; +import OverrideMatchModal from 'Search/Table/OverrideMatch/OverrideMatchModal'; +import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector'; +import { SelectStateInputProps } from 'typings/props'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatBytes from 'Utilities/Number/formatBytes'; +import titleCase from 'Utilities/String/titleCase'; +import translate from 'Utilities/String/translate'; +import CategoryLabel from './CategoryLabel'; +import Peers from './Peers'; +import ReleaseLinks from './ReleaseLinks'; +import styles from './SearchIndexRow.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('AddedToDownloadClient'); + } else if (grabError) { + return grabError; + } + + return translate('AddToDownloadClient'); +} + +interface SearchIndexRowProps { + guid: string; + protocol: DownloadProtocol; + age: number; + ageHours: number; + ageMinutes: number; + publishDate: string; + title: string; + fileName: string; + infoUrl: string; + downloadUrl?: string; + magnetUrl?: string; + indexerId: number; + indexer: string; + categories: IndexerCategory[]; + size: number; + files?: number; + grabs?: number; + seeders?: number; + leechers?: number; + imdbId?: string; + tmdbId?: number; + tvdbId?: number; + tvMazeId?: number; + indexerFlags: string[]; + isGrabbing: boolean; + isGrabbed: boolean; + grabError?: string; + longDateFormat: string; + timeFormat: string; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; + onGrabPress(...args: unknown[]): void; + onSavePress(...args: unknown[]): void; +} + +function SearchIndexRow(props: SearchIndexRowProps) { + const { + guid, + indexerId, + protocol, + categories, + age, + ageHours, + ageMinutes, + publishDate, + title, + fileName, + infoUrl, + downloadUrl, + magnetUrl, + indexer, + size, + files, + grabs, + seeders, + leechers, + imdbId, + tmdbId, + tvdbId, + tvMazeId, + indexerFlags = [], + isGrabbing = false, + isGrabbed = false, + grabError, + longDateFormat, + timeFormat, + columns, + isSelected, + onSelectedChange, + onGrabPress, + onSavePress, + } = props; + + const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false); + + const { items: downloadClients } = useSelector( + createEnabledDownloadClientsSelector(protocol) + ); + + const onGrabPressWrapper = useCallback(() => { + onGrabPress({ + guid, + indexerId, + }); + }, [guid, indexerId, onGrabPress]); + + const onSavePressWrapper = useCallback(() => { + onSavePress({ + downloadUrl, + fileName, + }); + }, [downloadUrl, fileName, onSavePress]); + + const onOverridePress = useCallback(() => { + setIsOverrideModalOpen(true); + }, [setIsOverrideModalOpen]); + + const onOverrideModalClose = useCallback(() => { + setIsOverrideModalOpen(false); + }, [setIsOverrideModalOpen]); + + return ( + <> + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'select') { + return ( + + ); + } + + if (name === 'protocol') { + return ( + + + + ); + } + + if (name === 'age') { + return ( + + {formatAge(age, ageHours, ageMinutes)} + + ); + } + + if (name === 'sortTitle') { + return ( + + +
{title}
+ +
+ ); + } + + if (name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (name === 'size') { + return ( + + {formatBytes(size)} + + ); + } + + if (name === 'files') { + return ( + + {files} + + ); + } + + if (name === 'grabs') { + return ( + + {grabs} + + ); + } + + if (name === 'peers') { + return ( + + {protocol === 'torrent' && ( + + )} + + ); + } + + if (name === 'category') { + return ( + + + + ); + } + + if (name === 'indexerFlags') { + return ( + + {!!indexerFlags.length && ( + } + title={translate('IndexerFlags')} + body={ +
    + {indexerFlags.map((flag, index) => { + return
  • {titleCase(flag)}
  • ; + })} +
+ } + position={tooltipPositions.LEFT} + /> + )} +
+ ); + } + + if (name === 'actions') { + return ( + + + + {downloadClients.length > 1 ? ( + +
+ + + +
+ + ) : null} + + {downloadUrl ? ( + + ) : null} + + {magnetUrl ? ( + + ) : null} + + {imdbId || tmdbId || tvdbId || tvMazeId ? ( + + } + title={translate('Links')} + body={ + + } + position={tooltipPositions.TOP} + /> + ) : null} +
+ ); + } + + return null; + })} + + + + ); +} + +export default SearchIndexRow; 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/Utilities/Number/formatBytes.js b/frontend/src/Utilities/Number/formatBytes.js index 2fb3eebe6..d4d389357 100644 --- a/frontend/src/Utilities/Number/formatBytes.js +++ b/frontend/src/Utilities/Number/formatBytes.js @@ -7,10 +7,10 @@ function formatBytes(input) { return ''; } - return filesize(size, { + return `${filesize(size, { base: 2, round: 1 - }); + })}`; } export default formatBytes; diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index f8ef93533..e0f7f627d 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -143,6 +143,7 @@ "DatabaseMigration": "Database Migration", "Date": "Date", "Dates": "Dates", + "Default": "Default", "DefaultCategory": "Default Category", "DefaultNameCopiedProfile": "{name} - Copy", "Delete": "Delete", @@ -286,6 +287,7 @@ "GeneralSettingsSummary": "Port, SSL, username/password, proxy, analytics, and updates", "Genre": "Genre", "GoToApplication": "Go to application", + "GrabRelease": "Grab Release", "GrabReleases": "Grab Release(s)", "GrabTitle": "Grab Title", "Grabbed": "Grabbed", @@ -447,6 +449,7 @@ "ManageClients": "Manage Clients", "ManageDownloadClients": "Manage Download Clients", "Manual": "Manual", + "ManualGrab": "Manual Grab", "MappedCategories": "Mapped Categories", "MappedDrivesRunningAsService": "Mapped network drives are not available when running as a Windows Service. Please see the FAQ for more information", "MassEditor": "Mass Editor", @@ -507,9 +510,12 @@ "OnHealthIssueHelpText": "On Health Issue", "OnHealthRestored": "On Health Restored", "OnHealthRestoredHelpText": "On Health Restored", + "Open": "Open", "OpenBrowserOnStart": "Open browser on start", "OpenThisModal": "Open This Modal", "Options": "Options", + "OverrideAndAddToDownloadClient": "Override and add to download client", + "OverrideGrabModalTitle": "Override and Grab - {title}", "PackSeedTime": "Pack Seed Time", "PackSeedTimeHelpText": "The time a pack (season or discography) torrent should be seeded before stopping, empty is app's default", "PackageVersion": "Package Version", @@ -526,6 +532,7 @@ "PortNumber": "Port Number", "Presets": "Presets", "Priority": "Priority", + "PrioritySettings": "Priority: {priority}", "Privacy": "Privacy", "Private": "Private", "Protocol": "Protocol", @@ -609,6 +616,7 @@ "SeedTimeHelpText": "The time a torrent should be seeded before stopping, empty is app's default", "Seeders": "Seeders", "SelectAll": "Select All", + "SelectDownloadClientModalTitle": "{modalTitle} - Select Download Client", "SelectIndexers": "Select Indexers", "SelectedCountOfCountReleases": "Selected {selectedCount} of {itemCount} releases", "SemiPrivate": "Semi-Private", diff --git a/src/Prowlarr.Api.V1/Search/ReleaseResource.cs b/src/Prowlarr.Api.V1/Search/ReleaseResource.cs index 77f68ca16..50cf66bd1 100644 --- a/src/Prowlarr.Api.V1/Search/ReleaseResource.cs +++ b/src/Prowlarr.Api.V1/Search/ReleaseResource.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; @@ -55,6 +56,9 @@ namespace Prowlarr.Api.V1.Search return $"{Title}{extension}"; } } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int? DownloadClientId { get; set; } } public static class ReleaseResourceMapper diff --git a/src/Prowlarr.Api.V1/Search/SearchController.cs b/src/Prowlarr.Api.V1/Search/SearchController.cs index 23fb2ab47..69532bf2c 100644 --- a/src/Prowlarr.Api.V1/Search/SearchController.cs +++ b/src/Prowlarr.Api.V1/Search/SearchController.cs @@ -74,7 +74,7 @@ namespace Prowlarr.Api.V1.Search try { - await _downloadService.SendReportToClient(releaseInfo, source, host, indexerDef.Redirect, null); + await _downloadService.SendReportToClient(releaseInfo, source, host, indexerDef.Redirect, release.DownloadClientId); } catch (ReleaseDownloadException ex) {