New: Option to select download client when multiple of the same type are configured

(cherry picked from commit 07f0fbf9a51d54e44681fd0f74df4e048bff561a)
pull/2105/head
Mark McDowall 1 year ago committed by Bogdan
parent e3e9094d42
commit 93ca96b7bd

@ -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 (
<div>
<div className={className}>
<DescriptionListItemTitle
className={titleClassName}
>
@ -35,6 +36,7 @@ class DescriptionListItem extends Component {
}
DescriptionListItem.propTypes = {
className: PropTypes.string,
titleClassName: PropTypes.string,
descriptionClassName: PropTypes.string,
title: PropTypes.string,

@ -0,0 +1,7 @@
enum DownloadProtocol {
Unknown = 'unknown',
Usenet = 'usenet',
Torrent = 'torrent',
}
export default DownloadProtocol;

@ -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;

@ -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)
};

@ -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 (
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}>
<SelectDownloadClientModalContent
protocol={protocol}
modalTitle={modalTitle}
onDownloadClientSelect={onDownloadClientSelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectDownloadClientModal;

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('SelectDownloadClientModalTitle', { modalTitle })}
</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>
{translate('DownloadClientsLoadError')}
</Alert>
) : null}
{isPopulated && !error ? (
<Form>
{items.map((downloadClient) => {
const { id, name, priority } = downloadClient;
return (
<SelectDownloadClientRow
key={id}
id={id}
name={name}
priority={priority}
onDownloadClientSelect={onDownloadClientSelect}
/>
);
})}
</Form>
) : null}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectDownloadClientModalContent;

@ -0,0 +1,6 @@
.downloadClient {
display: flex;
justify-content: space-between;
padding: 8px;
border-bottom: 1px solid var(--borderColor);
}

@ -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;

@ -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 (
<Link
className={styles.downloadClient}
component="div"
onPress={onSeasonSelectWrapper}
>
<div>{name}</div>
<div>{translate('PrioritySettings', { priority })}</div>
</Link>
);
}
export default SelectDownloadClientRow;

@ -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);
}

@ -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;

@ -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 (
<Link className={styles.link} isDisabled={isDisabled} onPress={onPress}>
{(value == null || (Array.isArray(value) && value.length === 0)) &&
!isDisabled ? (
<span
className={classNames(
styles.placeholder,
isOptional && styles.optional
)}
>
&nbsp;
</span>
) : (
value
)}
</Link>
);
}
export default OverrideMatchData;

@ -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 (
<Modal isOpen={isOpen} size={sizes.LARGE} onModalClose={onModalClose}>
<OverrideMatchModalContent
title={title}
indexerId={indexerId}
guid={guid}
protocol={protocol}
isGrabbing={isGrabbing}
grabError={grabError}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default OverrideMatchModal;

@ -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;
}
}

@ -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;

@ -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<number | null>(null);
const [selectModalOpen, setSelectModalOpen] = useState<SelectType | null>(
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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('OverrideGrabModalTitle', { title })}
</ModalHeader>
<ModalBody>
<DescriptionList>
{downloadClients.length > 1 ? (
<DescriptionListItem
className={styles.item}
title={translate('DownloadClient')}
data={
<OverrideMatchData
value={
downloadClients.find(
(downloadClient) => downloadClient.id === downloadClientId
)?.name ?? translate('Default')
}
onPress={onSelectDownloadClientPress}
/>
}
/>
) : null}
</DescriptionList>
</ModalBody>
<ModalFooter className={styles.footer}>
<div className={styles.error}>{grabError}</div>
<div className={styles.buttons}>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerErrorButton
isSpinning={isGrabbing}
error={grabError}
onPress={onGrabPress}
>
{translate('GrabRelease')}
</SpinnerErrorButton>
</div>
</ModalFooter>
<SelectDownloadClientModal
isOpen={selectModalOpen === 'downloadClient'}
protocol={protocol}
modalTitle={modalTitle}
onDownloadClientSelect={onDownloadClientSelect}
onModalClose={onSelectModalClose}
/>
</ModalContent>
);
}
export default OverrideMatchModalContent;

@ -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;
}

@ -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;

@ -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 (
<VirtualTableSelectCell
inputClassName={styles.checkInput}
id={guid}
key={column.name}
isSelected={isSelected}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
);
}
if (column.name === 'protocol') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
<ProtocolLabel
protocol={protocol}
/>
</VirtualTableRowCell>
);
}
if (column.name === 'age') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
title={formatDateTime(publishDate, longDateFormat, timeFormat, { includeSeconds: true })}
>
{formatAge(age, ageHours, ageMinutes)}
</VirtualTableRowCell>
);
}
if (column.name === 'sortTitle') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
<Link
to={infoUrl}
title={title}
>
<div>
{title}
</div>
</Link>
</VirtualTableRowCell>
);
}
if (column.name === 'indexer') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
{indexer}
</VirtualTableRowCell>
);
}
if (column.name === 'size') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
{formatBytes(size)}
</VirtualTableRowCell>
);
}
if (column.name === 'files') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
{files}
</VirtualTableRowCell>
);
}
if (column.name === 'grabs') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
{grabs}
</VirtualTableRowCell>
);
}
if (column.name === 'peers') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
{
protocol === 'torrent' &&
<Peers
seeders={seeders}
leechers={leechers}
/>
}
</VirtualTableRowCell>
);
}
if (column.name === 'category') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
<CategoryLabel
categories={categories}
/>
</VirtualTableRowCell>
);
}
if (column.name === 'indexerFlags') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
{
!!indexerFlags.length &&
<Popover
anchor={
<Icon
name={icons.FLAG}
kind={kinds.PRIMARY}
/>
}
title={translate('IndexerFlags')}
body={
<ul>
{
indexerFlags.map((flag, index) => {
return (
<li key={index}>
{titleCase(flag)}
</li>
);
})
}
</ul>
}
position={tooltipPositions.LEFT}
/>
}
</VirtualTableRowCell>
);
}
if (column.name === 'actions') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={getDownloadKind(isGrabbed, grabError)}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isDisabled={isGrabbed}
isSpinning={isGrabbing}
onPress={this.onGrabPress}
/>
{
downloadUrl ?
<IconButton
className={styles.downloadLink}
name={icons.SAVE}
title={translate('Save')}
onPress={this.onSavePress}
/> :
null
}
{
magnetUrl ?
<IconButton
className={styles.downloadLink}
name={icons.MAGNET}
title={translate('Open')}
to={magnetUrl}
/> :
null
}
{
imdbId || tmdbId || tvdbId || tvMazeId ? (
<Popover
anchor={
<Icon
className={styles.externalLinks}
name={icons.EXTERNAL_LINK}
size={12}
/>
}
title={translate('Links')}
body={
<ReleaseLinks
categories={categories}
imdbId={imdbId}
tmdbId={tmdbId}
tvdbId={tvdbId}
tvMazeId={tvMazeId}
/>
}
kind={kinds.INVERSE}
position={tooltipPositions.TOP}
/>
) : null
}
</VirtualTableRowCell>
);
}
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;

@ -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 (
<VirtualTableSelectCell
inputClassName={styles.checkInput}
id={guid}
key={name}
isSelected={isSelected}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
);
}
if (name === 'protocol') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<ProtocolLabel protocol={protocol} />
</VirtualTableRowCell>
);
}
if (name === 'age') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
title={formatDateTime(publishDate, longDateFormat, timeFormat, {
includeSeconds: true,
})}
>
{formatAge(age, ageHours, ageMinutes)}
</VirtualTableRowCell>
);
}
if (name === 'sortTitle') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<Link to={infoUrl} title={title}>
<div>{title}</div>
</Link>
</VirtualTableRowCell>
);
}
if (name === 'indexer') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{indexer}
</VirtualTableRowCell>
);
}
if (name === 'size') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{formatBytes(size)}
</VirtualTableRowCell>
);
}
if (name === 'files') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{files}
</VirtualTableRowCell>
);
}
if (name === 'grabs') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{grabs}
</VirtualTableRowCell>
);
}
if (name === 'peers') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{protocol === 'torrent' && (
<Peers seeders={seeders} leechers={leechers} />
)}
</VirtualTableRowCell>
);
}
if (name === 'category') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<CategoryLabel categories={categories} />
</VirtualTableRowCell>
);
}
if (name === 'indexerFlags') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{!!indexerFlags.length && (
<Popover
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
title={translate('IndexerFlags')}
body={
<ul>
{indexerFlags.map((flag, index) => {
return <li key={index}>{titleCase(flag)}</li>;
})}
</ul>
}
position={tooltipPositions.LEFT}
/>
)}
</VirtualTableRowCell>
);
}
if (name === 'actions') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={getDownloadKind(isGrabbed, grabError)}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isDisabled={isGrabbed}
isSpinning={isGrabbing}
onPress={onGrabPressWrapper}
/>
{downloadClients.length > 1 ? (
<Link
className={styles.manualDownloadContent}
title={translate('OverrideAndAddToDownloadClient')}
onPress={onOverridePress}
>
<div className={styles.manualDownloadContent}>
<Icon
className={styles.interactiveIcon}
name={icons.INTERACTIVE}
size={12}
/>
<Icon
className={styles.downloadIcon}
name={icons.CIRCLE_DOWN}
size={10}
/>
</div>
</Link>
) : null}
{downloadUrl ? (
<IconButton
className={styles.downloadLink}
name={icons.SAVE}
title={translate('Save')}
onPress={onSavePressWrapper}
/>
) : null}
{magnetUrl ? (
<IconButton
className={styles.downloadLink}
name={icons.MAGNET}
title={translate('Open')}
to={magnetUrl}
/>
) : null}
{imdbId || tmdbId || tvdbId || tvMazeId ? (
<Popover
anchor={
<Icon
className={styles.externalLinks}
name={icons.EXTERNAL_LINK}
size={12}
/>
}
title={translate('Links')}
body={
<ReleaseLinks
categories={categories}
imdbId={imdbId}
tmdbId={tmdbId}
tvdbId={tvdbId}
tvMazeId={tvMazeId}
/>
}
position={tooltipPositions.TOP}
/>
) : null}
</VirtualTableRowCell>
);
}
return null;
})}
<OverrideMatchModal
isOpen={isOverrideModalOpen}
title={title}
indexerId={indexerId}
guid={guid}
protocol={protocol}
isGrabbing={isGrabbing}
grabError={grabError}
onModalClose={onOverrideModalClose}
/>
</>
);
}
export default SearchIndexRow;

@ -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 };
}
);
}

@ -7,10 +7,10 @@ function formatBytes(input) {
return '';
}
return filesize(size, {
return `${filesize(size, {
base: 2,
round: 1
});
})}`;
}
export default formatBytes;

@ -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",

@ -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

@ -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)
{

Loading…
Cancel
Save