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 ? (
+
+ ) : 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)
{