mirror of
https://github.com/Sonarr/Sonarr.git
synced 2025-01-10 23:29:53 +02:00
Override release grab modal
New: Option to override release and grab New: Option to select download client when multiple of the same type are configured Closes #4526 Closes #4774
This commit is contained in:
parent
defdc84b7e
commit
07f0fbf9a5
@ -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 (
|
||||
<span>
|
||||
<span 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,
|
||||
|
7
frontend/src/DownloadClient/DownloadProtocol.ts
Normal file
7
frontend/src/DownloadClient/DownloadProtocol.ts
Normal file
@ -0,0 +1,7 @@
|
||||
enum DownloadProtocol {
|
||||
Unknown = 'unknown',
|
||||
Usenet = 'usenet',
|
||||
Torrent = 'torrent',
|
||||
}
|
||||
|
||||
export default DownloadProtocol;
|
@ -39,6 +39,7 @@ import {
|
||||
faChevronCircleRight as fasChevronCircleRight,
|
||||
faChevronCircleUp as fasChevronCircleUp,
|
||||
faCircle as fasCircle,
|
||||
faCircleDown as fasCircleDown,
|
||||
faCloud as fasCloud,
|
||||
faCloudDownloadAlt as fasCloudDownloadAlt,
|
||||
faCog as fasCog,
|
||||
@ -121,6 +122,7 @@ export const CHECK_INDETERMINATE = fasMinus;
|
||||
export const CHECK_CIRCLE = fasCheckCircle;
|
||||
export const CHECK_SQUARE = fasSquareCheck;
|
||||
export const CIRCLE = fasCircle;
|
||||
export const CIRCLE_DOWN = fasCircleDown;
|
||||
export const CIRCLE_OUTLINE = farCircle;
|
||||
export const CLEAR = fasTrashAlt;
|
||||
export const CLIPBOARD = fasCopy;
|
||||
|
@ -40,8 +40,7 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.rejected,
|
||||
.download {
|
||||
.rejected {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 50px;
|
||||
@ -59,3 +58,34 @@
|
||||
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
.download {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.manualDownloadContent {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 20.39px;
|
||||
vertical-align: middle;
|
||||
line-height: 20.39px;
|
||||
}
|
||||
|
||||
.interactiveIcon {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 0;
|
||||
/* width: 100%; */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.downloadIcon {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 8px;
|
||||
/* width: 100%; */
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -4,8 +4,11 @@ interface CssExports {
|
||||
'age': string;
|
||||
'customFormatScore': string;
|
||||
'download': string;
|
||||
'downloadIcon': string;
|
||||
'indexer': string;
|
||||
'interactiveIcon': string;
|
||||
'languages': string;
|
||||
'manualDownloadContent': string;
|
||||
'peers': string;
|
||||
'protocol': string;
|
||||
'quality': string;
|
||||
|
@ -1,308 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import formatAge from 'Utilities/Number/formatAge';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
|
||||
import Peers from './Peers';
|
||||
import ReleaseSceneIndicator from './ReleaseSceneIndicator';
|
||||
import styles from './InteractiveSearchRow.css';
|
||||
|
||||
function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
|
||||
if (isGrabbing) {
|
||||
return icons.SPINNER;
|
||||
} else if (isGrabbed) {
|
||||
return icons.DOWNLOADING;
|
||||
} else if (grabError) {
|
||||
return icons.DOWNLOADING;
|
||||
}
|
||||
|
||||
return icons.DOWNLOAD;
|
||||
}
|
||||
|
||||
function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
|
||||
if (isGrabbing) {
|
||||
return '';
|
||||
} else if (isGrabbed) {
|
||||
return 'Added to download queue';
|
||||
} else if (grabError) {
|
||||
return grabError;
|
||||
}
|
||||
|
||||
return 'Add to download queue';
|
||||
}
|
||||
|
||||
class InteractiveSearchRow extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isConfirmGrabModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onGrabPress = () => {
|
||||
const {
|
||||
guid,
|
||||
indexerId,
|
||||
onGrabPress
|
||||
} = this.props;
|
||||
|
||||
onGrabPress({
|
||||
guid,
|
||||
indexerId
|
||||
});
|
||||
};
|
||||
|
||||
onConfirmGrabPress = () => {
|
||||
this.setState({ isConfirmGrabModalOpen: true });
|
||||
};
|
||||
|
||||
onGrabConfirm = () => {
|
||||
this.setState({ isConfirmGrabModalOpen: false });
|
||||
|
||||
const {
|
||||
guid,
|
||||
indexerId,
|
||||
searchPayload,
|
||||
onGrabPress
|
||||
} = this.props;
|
||||
|
||||
onGrabPress({
|
||||
guid,
|
||||
indexerId,
|
||||
...searchPayload
|
||||
});
|
||||
};
|
||||
|
||||
onGrabCancel = () => {
|
||||
this.setState({ isConfirmGrabModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
protocol,
|
||||
age,
|
||||
ageHours,
|
||||
ageMinutes,
|
||||
publishDate,
|
||||
title,
|
||||
infoUrl,
|
||||
indexer,
|
||||
size,
|
||||
seeders,
|
||||
leechers,
|
||||
quality,
|
||||
languages,
|
||||
customFormatScore,
|
||||
customFormats,
|
||||
sceneMapping,
|
||||
seasonNumber,
|
||||
episodeNumbers,
|
||||
absoluteEpisodeNumbers,
|
||||
mappedSeasonNumber,
|
||||
mappedEpisodeNumbers,
|
||||
mappedAbsoluteEpisodeNumbers,
|
||||
rejections,
|
||||
episodeRequested,
|
||||
downloadAllowed,
|
||||
isDaily,
|
||||
isGrabbing,
|
||||
isGrabbed,
|
||||
longDateFormat,
|
||||
timeFormat,
|
||||
grabError
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableRowCell className={styles.protocol}>
|
||||
<ProtocolLabel
|
||||
protocol={protocol}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell
|
||||
className={styles.age}
|
||||
title={formatDateTime(publishDate, longDateFormat, timeFormat, { includeSeconds: true })}
|
||||
>
|
||||
{formatAge(age, ageHours, ageMinutes)}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
<div className={styles.titleContent}>
|
||||
<Link to={infoUrl}>
|
||||
{title}
|
||||
</Link>
|
||||
<ReleaseSceneIndicator
|
||||
className={styles.sceneMapping}
|
||||
seasonNumber={mappedSeasonNumber}
|
||||
episodeNumbers={mappedEpisodeNumbers}
|
||||
absoluteEpisodeNumbers={mappedAbsoluteEpisodeNumbers}
|
||||
sceneSeasonNumber={seasonNumber}
|
||||
sceneEpisodeNumbers={episodeNumbers}
|
||||
sceneAbsoluteEpisodeNumbers={absoluteEpisodeNumbers}
|
||||
sceneMapping={sceneMapping}
|
||||
episodeRequested={episodeRequested}
|
||||
isDaily={isDaily}
|
||||
/>
|
||||
</div>
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.indexer}>
|
||||
{indexer}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.size}>
|
||||
{formatBytes(size)}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.peers}>
|
||||
{
|
||||
protocol === 'torrent' &&
|
||||
<Peers
|
||||
seeders={seeders}
|
||||
leechers={leechers}
|
||||
/>
|
||||
}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.languages}>
|
||||
<EpisodeLanguages languages={languages} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.quality}>
|
||||
<EpisodeQuality quality={quality} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.customFormatScore}>
|
||||
<Tooltip
|
||||
anchor={
|
||||
formatPreferredWordScore(customFormatScore, customFormats.length)
|
||||
}
|
||||
tooltip={<EpisodeFormats formats={customFormats} />}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.rejected}>
|
||||
{
|
||||
!!rejections.length &&
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
name={icons.DANGER}
|
||||
kind={kinds.DANGER}
|
||||
/>
|
||||
}
|
||||
title="Release Rejected"
|
||||
body={
|
||||
<ul>
|
||||
{
|
||||
rejections.map((rejection, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
{rejection}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.download}>
|
||||
<SpinnerIconButton
|
||||
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
|
||||
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
|
||||
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
|
||||
isSpinning={isGrabbing}
|
||||
onPress={downloadAllowed ? this.onGrabPress : this.onConfirmGrabPress}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={this.state.isConfirmGrabModalOpen}
|
||||
kind={kinds.WARNING}
|
||||
title="Grab Release"
|
||||
message={`Sonarr was unable to determine which series and episode this release was for. Sonarr may be unable to automatically import this release. Do you want to grab '${title}'?`}
|
||||
confirmLabel="Grab"
|
||||
onConfirm={this.onGrabConfirm}
|
||||
onCancel={this.onGrabCancel}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
InteractiveSearchRow.propTypes = {
|
||||
guid: PropTypes.string.isRequired,
|
||||
protocol: PropTypes.string.isRequired,
|
||||
age: PropTypes.number.isRequired,
|
||||
ageHours: PropTypes.number.isRequired,
|
||||
ageMinutes: PropTypes.number.isRequired,
|
||||
publishDate: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
infoUrl: PropTypes.string.isRequired,
|
||||
indexerId: PropTypes.number.isRequired,
|
||||
indexer: PropTypes.string.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
seeders: PropTypes.number,
|
||||
leechers: PropTypes.number,
|
||||
quality: PropTypes.object.isRequired,
|
||||
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
customFormats: PropTypes.arrayOf(PropTypes.object),
|
||||
customFormatScore: PropTypes.number.isRequired,
|
||||
sceneMapping: PropTypes.object,
|
||||
seasonNumber: PropTypes.number,
|
||||
episodeNumbers: PropTypes.arrayOf(PropTypes.number),
|
||||
absoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
|
||||
mappedSeasonNumber: PropTypes.number,
|
||||
mappedEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
|
||||
mappedAbsoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
|
||||
rejections: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
episodeRequested: PropTypes.bool.isRequired,
|
||||
downloadAllowed: PropTypes.bool.isRequired,
|
||||
isDaily: PropTypes.bool.isRequired,
|
||||
isGrabbing: PropTypes.bool.isRequired,
|
||||
isGrabbed: PropTypes.bool.isRequired,
|
||||
grabError: PropTypes.string,
|
||||
longDateFormat: PropTypes.string.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired,
|
||||
searchPayload: PropTypes.object.isRequired,
|
||||
onGrabPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
InteractiveSearchRow.defaultProps = {
|
||||
rejections: [],
|
||||
isGrabbing: false,
|
||||
isGrabbed: false
|
||||
};
|
||||
|
||||
export default InteractiveSearchRow;
|
314
frontend/src/InteractiveSearch/InteractiveSearchRow.tsx
Normal file
314
frontend/src/InteractiveSearch/InteractiveSearchRow.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import type DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import formatAge from 'Utilities/Number/formatAge';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
|
||||
import OverrideMatchModal from './OverrideMatch/OverrideMatchModal';
|
||||
import Peers from './Peers';
|
||||
import ReleaseEpisode from './ReleaseEpisode';
|
||||
import ReleaseSceneIndicator from './ReleaseSceneIndicator';
|
||||
import styles from './InteractiveSearchRow.css';
|
||||
|
||||
function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
|
||||
if (isGrabbing) {
|
||||
return icons.SPINNER;
|
||||
} else if (isGrabbed) {
|
||||
return icons.DOWNLOADING;
|
||||
} else if (grabError) {
|
||||
return icons.DOWNLOADING;
|
||||
}
|
||||
|
||||
return icons.DOWNLOAD;
|
||||
}
|
||||
|
||||
function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
|
||||
if (isGrabbing) {
|
||||
return '';
|
||||
} else if (isGrabbed) {
|
||||
return 'Added to download queue';
|
||||
} else if (grabError) {
|
||||
return grabError;
|
||||
}
|
||||
|
||||
return 'Add to download queue';
|
||||
}
|
||||
|
||||
interface InteractiveSearchRowProps {
|
||||
guid: string;
|
||||
protocol: DownloadProtocol;
|
||||
age: number;
|
||||
ageHours: number;
|
||||
ageMinutes: number;
|
||||
publishDate: string;
|
||||
title: string;
|
||||
infoUrl: string;
|
||||
indexerId: number;
|
||||
indexer: string;
|
||||
size: number;
|
||||
seeders?: number;
|
||||
leechers?: number;
|
||||
quality: QualityModel;
|
||||
languages: Language[];
|
||||
customFormats?: object[];
|
||||
customFormatScore: number;
|
||||
sceneMapping?: object;
|
||||
seasonNumber?: number;
|
||||
episodeNumbers?: number[];
|
||||
absoluteEpisodeNumbers?: number[];
|
||||
mappedSeriesId?: number;
|
||||
mappedSeasonNumber?: number;
|
||||
mappedEpisodeNumbers?: number[];
|
||||
mappedAbsoluteEpisodeNumbers?: number[];
|
||||
mappedEpisodeInfo: ReleaseEpisode[];
|
||||
rejections: string[];
|
||||
episodeRequested: boolean;
|
||||
downloadAllowed: boolean;
|
||||
isDaily: boolean;
|
||||
isGrabbing: boolean;
|
||||
isGrabbed: boolean;
|
||||
grabError?: string;
|
||||
longDateFormat: string;
|
||||
timeFormat: string;
|
||||
searchPayload: object;
|
||||
onGrabPress(...args: unknown[]): void;
|
||||
}
|
||||
|
||||
function InteractiveSearchRow(props: InteractiveSearchRowProps) {
|
||||
const {
|
||||
guid,
|
||||
indexerId,
|
||||
protocol,
|
||||
age,
|
||||
ageHours,
|
||||
ageMinutes,
|
||||
publishDate,
|
||||
title,
|
||||
infoUrl,
|
||||
indexer,
|
||||
size,
|
||||
seeders,
|
||||
leechers,
|
||||
quality,
|
||||
languages,
|
||||
customFormatScore,
|
||||
customFormats,
|
||||
sceneMapping,
|
||||
seasonNumber,
|
||||
episodeNumbers,
|
||||
absoluteEpisodeNumbers,
|
||||
mappedSeriesId,
|
||||
mappedSeasonNumber,
|
||||
mappedEpisodeNumbers,
|
||||
mappedAbsoluteEpisodeNumbers,
|
||||
mappedEpisodeInfo,
|
||||
rejections,
|
||||
episodeRequested,
|
||||
downloadAllowed,
|
||||
isDaily,
|
||||
isGrabbing,
|
||||
isGrabbed,
|
||||
longDateFormat,
|
||||
timeFormat,
|
||||
grabError,
|
||||
searchPayload,
|
||||
onGrabPress,
|
||||
} = props;
|
||||
|
||||
const [isConfirmGrabModalOpen, setIsConfirmGrabModalOpen] = useState(false);
|
||||
const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false);
|
||||
|
||||
const onGrabPressWrapper = useCallback(() => {
|
||||
if (downloadAllowed) {
|
||||
onGrabPress({
|
||||
guid,
|
||||
indexerId,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmGrabModalOpen(true);
|
||||
}, [
|
||||
guid,
|
||||
indexerId,
|
||||
downloadAllowed,
|
||||
onGrabPress,
|
||||
setIsConfirmGrabModalOpen,
|
||||
]);
|
||||
|
||||
const onGrabConfirm = useCallback(() => {
|
||||
setIsConfirmGrabModalOpen(false);
|
||||
|
||||
onGrabPress({
|
||||
guid,
|
||||
indexerId,
|
||||
...searchPayload,
|
||||
});
|
||||
}, [guid, indexerId, searchPayload, onGrabPress, setIsConfirmGrabModalOpen]);
|
||||
|
||||
const onGrabCancel = useCallback(() => {
|
||||
setIsConfirmGrabModalOpen(false);
|
||||
}, [setIsConfirmGrabModalOpen]);
|
||||
|
||||
const onOverridePress = useCallback(() => {
|
||||
setIsOverrideModalOpen(true);
|
||||
}, [setIsOverrideModalOpen]);
|
||||
|
||||
const onOverrideModalClose = useCallback(() => {
|
||||
setIsOverrideModalOpen(false);
|
||||
}, [setIsOverrideModalOpen]);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableRowCell className={styles.protocol}>
|
||||
<ProtocolLabel protocol={protocol} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell
|
||||
className={styles.age}
|
||||
title={formatDateTime(publishDate, longDateFormat, timeFormat, {
|
||||
includeSeconds: true,
|
||||
})}
|
||||
>
|
||||
{formatAge(age, ageHours, ageMinutes)}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
<div className={styles.titleContent}>
|
||||
<Link to={infoUrl}>{title}</Link>
|
||||
<ReleaseSceneIndicator
|
||||
className={styles.sceneMapping}
|
||||
seasonNumber={mappedSeasonNumber}
|
||||
episodeNumbers={mappedEpisodeNumbers}
|
||||
absoluteEpisodeNumbers={mappedAbsoluteEpisodeNumbers}
|
||||
sceneSeasonNumber={seasonNumber}
|
||||
sceneEpisodeNumbers={episodeNumbers}
|
||||
sceneAbsoluteEpisodeNumbers={absoluteEpisodeNumbers}
|
||||
sceneMapping={sceneMapping}
|
||||
episodeRequested={episodeRequested}
|
||||
isDaily={isDaily}
|
||||
/>
|
||||
</div>
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.indexer}>{indexer}</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.size}>{formatBytes(size)}</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.peers}>
|
||||
{protocol === 'torrent' ? (
|
||||
<Peers seeders={seeders} leechers={leechers} />
|
||||
) : null}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.languages}>
|
||||
<EpisodeLanguages languages={languages} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.quality}>
|
||||
<EpisodeQuality quality={quality} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.customFormatScore}>
|
||||
<Tooltip
|
||||
anchor={formatPreferredWordScore(
|
||||
customFormatScore,
|
||||
customFormats.length
|
||||
)}
|
||||
tooltip={<EpisodeFormats formats={customFormats} />}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.rejected}>
|
||||
{rejections.length ? (
|
||||
<Popover
|
||||
anchor={<Icon name={icons.DANGER} kind={kinds.DANGER} />}
|
||||
title="Release Rejected"
|
||||
body={
|
||||
<ul>
|
||||
{rejections.map((rejection, index) => {
|
||||
return <li key={index}>{rejection}</li>;
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
) : null}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.download}>
|
||||
<SpinnerIconButton
|
||||
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
|
||||
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
|
||||
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
|
||||
isSpinning={isGrabbing}
|
||||
onPress={onGrabPressWrapper}
|
||||
/>
|
||||
|
||||
<Link
|
||||
title="Override and add to download queue"
|
||||
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>
|
||||
</TableRowCell>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isConfirmGrabModalOpen}
|
||||
kind={kinds.WARNING}
|
||||
title="Grab Release"
|
||||
message={`Sonarr was unable to determine which series and episode this release was for. Sonarr may be unable to automatically import this release. Do you want to grab '${title}'?`}
|
||||
confirmLabel="Grab"
|
||||
onConfirm={onGrabConfirm}
|
||||
onCancel={onGrabCancel}
|
||||
/>
|
||||
|
||||
<OverrideMatchModal
|
||||
isOpen={isOverrideModalOpen}
|
||||
title={title}
|
||||
indexerId={indexerId}
|
||||
guid={guid}
|
||||
seriesId={mappedSeriesId}
|
||||
seasonNumber={mappedSeasonNumber}
|
||||
episodes={mappedEpisodeInfo}
|
||||
languages={languages}
|
||||
quality={quality}
|
||||
protocol={protocol}
|
||||
isGrabbing={isGrabbing}
|
||||
grabError={grabError}
|
||||
onModalClose={onOverrideModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default InteractiveSearchRow;
|
@ -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,68 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Form from 'Components/Form/Form';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||
import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import SelectDownloadClientRow from './SelectDownloadClientRow';
|
||||
|
||||
interface SelectDownloadClientModalContentProps {
|
||||
protocol: DownloadProtocol;
|
||||
modalTitle: string;
|
||||
onDownloadClientSelect(downloadClientId: number): void;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function SelectDownloadClientModalContent(
|
||||
props: SelectDownloadClientModalContentProps
|
||||
) {
|
||||
const { modalTitle, protocol, onDownloadClientSelect, onModalClose } = props;
|
||||
|
||||
const { isFetching, isPopulated, error, items } = useSelector(
|
||||
createEnabledDownloadClientsSelector(protocol)
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{modalTitle} - Select Download Client</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{isFetching ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isFetching && error ? (
|
||||
<div>Unable to load download clients</div>
|
||||
) : 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);
|
||||
}
|
7
frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts
vendored
Normal file
7
frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts
vendored
Normal file
@ -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,31 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
import styles from './SelectDownloadClientRow.css';
|
||||
|
||||
interface SelectSeasonRowProps {
|
||||
id: number;
|
||||
name: number;
|
||||
priority: number;
|
||||
onDownloadClientSelect(downloadClientId: number): unknown;
|
||||
}
|
||||
|
||||
function SelectDownloadClientRow(props: SelectSeasonRowProps) {
|
||||
const { id, name, priority, onDownloadClientSelect } = props;
|
||||
|
||||
const onSeasonSelectWrapper = useCallback(() => {
|
||||
onDownloadClientSelect(id);
|
||||
}, [id, onDownloadClientSelect]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={styles.downloadClient}
|
||||
component="div"
|
||||
onPress={onSeasonSelectWrapper}
|
||||
>
|
||||
<div>{name}</div>
|
||||
<div>Priority: {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);
|
||||
}
|
9
frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css.d.ts
vendored
Normal file
9
frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css.d.ts
vendored
Normal file
@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
</span>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverrideMatchData;
|
@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import ReleaseEpisode from 'InteractiveSearch/ReleaseEpisode';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import OverrideMatchModalContent from './OverrideMatchModalContent';
|
||||
|
||||
interface OverrideMatchModalProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
indexerId: number;
|
||||
guid: string;
|
||||
seriesId?: number;
|
||||
seasonNumber?: number;
|
||||
episodes: ReleaseEpisode[];
|
||||
languages: Language[];
|
||||
quality: QualityModel;
|
||||
protocol: DownloadProtocol;
|
||||
isGrabbing: boolean;
|
||||
grabError: string;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function OverrideMatchModal(props: OverrideMatchModalProps) {
|
||||
const {
|
||||
isOpen,
|
||||
title,
|
||||
indexerId,
|
||||
guid,
|
||||
seriesId,
|
||||
seasonNumber,
|
||||
episodes,
|
||||
languages,
|
||||
quality,
|
||||
protocol,
|
||||
isGrabbing,
|
||||
grabError,
|
||||
onModalClose,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} size={sizes.LARGE} onModalClose={onModalClose}>
|
||||
<OverrideMatchModalContent
|
||||
title={title}
|
||||
indexerId={indexerId}
|
||||
guid={guid}
|
||||
seriesId={seriesId}
|
||||
seasonNumber={seasonNumber}
|
||||
episodes={episodes}
|
||||
languages={languages}
|
||||
quality={quality}
|
||||
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;
|
||||
}
|
||||
}
|
11
frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css.d.ts
vendored
Normal file
11
frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css.d.ts
vendored
Normal file
@ -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,391 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
|
||||
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
|
||||
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
|
||||
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
|
||||
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
|
||||
import ReleaseEpisode from 'InteractiveSearch/ReleaseEpisode';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import Series from 'Series/Series';
|
||||
import { grabRelease } from 'Store/Actions/releaseActions';
|
||||
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
|
||||
import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
|
||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import SelectDownloadClientModal from './DownloadClient/SelectDownloadClientModal';
|
||||
import OverrideMatchData from './OverrideMatchData';
|
||||
import styles from './OverrideMatchModalContent.css';
|
||||
|
||||
type SelectType =
|
||||
| 'select'
|
||||
| 'series'
|
||||
| 'season'
|
||||
| 'episode'
|
||||
| 'quality'
|
||||
| 'language'
|
||||
| 'downloadClient';
|
||||
|
||||
interface OverrideMatchModalContentProps {
|
||||
indexerId: number;
|
||||
title: string;
|
||||
guid: string;
|
||||
seriesId?: number;
|
||||
seasonNumber?: number;
|
||||
episodes: ReleaseEpisode[];
|
||||
languages: Language[];
|
||||
quality: QualityModel;
|
||||
protocol: DownloadProtocol;
|
||||
isGrabbing: boolean;
|
||||
grabError: string;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function OverrideMatchModalContent(props: OverrideMatchModalContentProps) {
|
||||
const modalTitle = 'Manual Grab';
|
||||
const {
|
||||
indexerId,
|
||||
title,
|
||||
guid,
|
||||
protocol,
|
||||
isGrabbing,
|
||||
grabError,
|
||||
onModalClose,
|
||||
} = props;
|
||||
|
||||
const [seriesId, setSeriesId] = useState(props.seriesId);
|
||||
const [seasonNumber, setSeasonNumber] = useState(props.seasonNumber);
|
||||
const [episodes, setEpisodes] = useState(props.episodes);
|
||||
const [languages, setLanguages] = useState(props.languages);
|
||||
const [quality, setQuality] = useState(props.quality);
|
||||
const [downloadClientId, setDownloadClientId] = useState(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectModalOpen, setSelectModalOpen] = useState<SelectType | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const series: Series = useSelector(createSeriesSelector(seriesId));
|
||||
const { items: downloadClients } = useSelector(
|
||||
createEnabledDownloadClientsSelector(protocol)
|
||||
);
|
||||
|
||||
const episodeInfo = useMemo(() => {
|
||||
return episodes.map((episode) => {
|
||||
return (
|
||||
<div key={episode.id}>
|
||||
{episode.episodeNumber}
|
||||
|
||||
{series.seriesType === 'anime' &&
|
||||
episode.absoluteEpisodeNumber != null
|
||||
? ` (${episode.absoluteEpisodeNumber})`
|
||||
: ''}
|
||||
|
||||
{` - ${episode.title}`}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, [series, episodes]);
|
||||
|
||||
const onSelectModalClose = useCallback(() => {
|
||||
setSelectModalOpen(null);
|
||||
}, [setSelectModalOpen]);
|
||||
|
||||
const onSelectSeriesPress = useCallback(() => {
|
||||
setSelectModalOpen('series');
|
||||
}, [setSelectModalOpen]);
|
||||
|
||||
const onSeriesSelect = useCallback(
|
||||
(s: Series) => {
|
||||
setSeriesId(s.id);
|
||||
setSeasonNumber(undefined);
|
||||
setEpisodes([]);
|
||||
setSelectModalOpen(null);
|
||||
},
|
||||
[setSeriesId, setSeasonNumber, setEpisodes, setSelectModalOpen]
|
||||
);
|
||||
|
||||
const onSelectSeasonPress = useCallback(() => {
|
||||
setSelectModalOpen('season');
|
||||
}, [setSelectModalOpen]);
|
||||
|
||||
const onSeasonSelect = useCallback(
|
||||
(s: number) => {
|
||||
setSeasonNumber(s);
|
||||
setEpisodes([]);
|
||||
setSelectModalOpen(null);
|
||||
},
|
||||
[setSeasonNumber, setEpisodes, setSelectModalOpen]
|
||||
);
|
||||
|
||||
const onSelectEpisodePress = useCallback(() => {
|
||||
setSelectModalOpen('episode');
|
||||
}, [setSelectModalOpen]);
|
||||
|
||||
const onEpisodesSelect = useCallback(
|
||||
(episodeMap) => {
|
||||
setEpisodes(episodeMap[0].episodes);
|
||||
setSelectModalOpen(null);
|
||||
},
|
||||
[setEpisodes, setSelectModalOpen]
|
||||
);
|
||||
|
||||
const onSelectQualityPress = useCallback(() => {
|
||||
setSelectModalOpen('quality');
|
||||
}, [setSelectModalOpen]);
|
||||
|
||||
const onQualitySelect = useCallback(
|
||||
(quality) => {
|
||||
setQuality(quality);
|
||||
setSelectModalOpen(null);
|
||||
},
|
||||
[setQuality, setSelectModalOpen]
|
||||
);
|
||||
|
||||
const onSelectLanguagesPress = useCallback(() => {
|
||||
setSelectModalOpen('language');
|
||||
}, [setSelectModalOpen]);
|
||||
|
||||
const onLanguagesSelect = useCallback(
|
||||
(languages) => {
|
||||
setLanguages(languages);
|
||||
setSelectModalOpen(null);
|
||||
},
|
||||
[setLanguages, setSelectModalOpen]
|
||||
);
|
||||
|
||||
const onSelectDownloadClientPress = useCallback(() => {
|
||||
setSelectModalOpen('downloadClient');
|
||||
}, [setSelectModalOpen]);
|
||||
|
||||
const onDownloadClientSelect = useCallback(
|
||||
(downloadClientId) => {
|
||||
setDownloadClientId(downloadClientId);
|
||||
setSelectModalOpen(null);
|
||||
},
|
||||
[setDownloadClientId, setSelectModalOpen]
|
||||
);
|
||||
|
||||
const onGrabPress = useCallback(() => {
|
||||
if (!seriesId) {
|
||||
setError('Series must be selected');
|
||||
return;
|
||||
} else if (!episodes.length) {
|
||||
setError('At least one episode must be selected');
|
||||
return;
|
||||
} else if (!quality) {
|
||||
setError('Quality must be selected');
|
||||
return;
|
||||
} else if (!languages.length) {
|
||||
setError('At least one language must be selected');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
grabRelease({
|
||||
indexerId,
|
||||
guid,
|
||||
seriesId,
|
||||
episodeIds: episodes.map((e) => e.id),
|
||||
quality,
|
||||
languages,
|
||||
downloadClientId,
|
||||
shouldOverride: true,
|
||||
})
|
||||
);
|
||||
}, [
|
||||
indexerId,
|
||||
guid,
|
||||
seriesId,
|
||||
episodes,
|
||||
quality,
|
||||
languages,
|
||||
downloadClientId,
|
||||
setError,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
dispatch(fetchDownloadClients());
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('Override and Grab - {title}', { title })}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
className={styles.item}
|
||||
title={translate('Series')}
|
||||
data={
|
||||
<OverrideMatchData
|
||||
value={series?.title}
|
||||
onPress={onSelectSeriesPress}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
className={styles.item}
|
||||
title={translate('Season Number')}
|
||||
data={
|
||||
<OverrideMatchData
|
||||
value={seasonNumber}
|
||||
isDisabled={!series}
|
||||
onPress={onSelectSeasonPress}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
className={styles.item}
|
||||
title={translate('Episodes')}
|
||||
data={
|
||||
<OverrideMatchData
|
||||
value={episodeInfo}
|
||||
isDisabled={!series || isNaN(seasonNumber)}
|
||||
onPress={onSelectEpisodePress}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
className={styles.item}
|
||||
title={translate('Quality')}
|
||||
data={
|
||||
<OverrideMatchData
|
||||
value={
|
||||
<EpisodeQuality className={styles.label} quality={quality} />
|
||||
}
|
||||
onPress={onSelectQualityPress}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
className={styles.item}
|
||||
title={translate('Languages')}
|
||||
data={
|
||||
<OverrideMatchData
|
||||
value={
|
||||
<EpisodeLanguages
|
||||
className={styles.label}
|
||||
languages={languages}
|
||||
/>
|
||||
}
|
||||
onPress={onSelectLanguagesPress}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{downloadClients.length > 1 ? (
|
||||
<DescriptionListItem
|
||||
className={styles.item}
|
||||
title={translate('Download Client')}
|
||||
data={
|
||||
<OverrideMatchData
|
||||
value={
|
||||
downloadClients.find(
|
||||
(downloadClient) => downloadClient.id === downloadClientId
|
||||
)?.name ?? 'Default'
|
||||
}
|
||||
onPress={onSelectDownloadClientPress}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className={styles.footer}>
|
||||
<div className={styles.error}>{error || grabError}</div>
|
||||
|
||||
<div className={styles.buttons}>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isGrabbing}
|
||||
error={grabError}
|
||||
onPress={onGrabPress}
|
||||
>
|
||||
{translate('Grab Release')}
|
||||
</SpinnerErrorButton>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
|
||||
<SelectSeriesModal
|
||||
isOpen={selectModalOpen === 'series'}
|
||||
modalTitle={modalTitle}
|
||||
onSeriesSelect={onSeriesSelect}
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<SelectSeasonModal
|
||||
isOpen={selectModalOpen === 'season'}
|
||||
modalTitle={modalTitle}
|
||||
seriesId={seriesId}
|
||||
onSeasonSelect={onSeasonSelect}
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<SelectEpisodeModal
|
||||
isOpen={selectModalOpen === 'episode'}
|
||||
selectedIds={[guid]}
|
||||
seriesId={seriesId}
|
||||
isAnime={series.seriesType === 'anime'}
|
||||
seasonNumber={seasonNumber}
|
||||
selectedDetails={title}
|
||||
modalTitle={modalTitle}
|
||||
onEpisodesSelect={onEpisodesSelect}
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<SelectQualityModal
|
||||
isOpen={selectModalOpen === 'quality'}
|
||||
qualityId={quality ? quality.quality.id : 0}
|
||||
proper={quality ? quality.revision.version > 1 : false}
|
||||
real={quality ? quality.revision.real > 0 : false}
|
||||
modalTitle={modalTitle}
|
||||
onQualitySelect={onQualitySelect}
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<SelectLanguageModal
|
||||
isOpen={selectModalOpen === 'language'}
|
||||
languageIds={languages ? languages.map((l) => l.id) : []}
|
||||
modalTitle={modalTitle}
|
||||
onLanguagesSelect={onLanguagesSelect}
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<SelectDownloadClientModal
|
||||
isOpen={selectModalOpen === 'downloadClient'}
|
||||
protocol={protocol}
|
||||
modalTitle={modalTitle}
|
||||
onDownloadClientSelect={onDownloadClientSelect}
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverrideMatchModalContent;
|
10
frontend/src/InteractiveSearch/ReleaseEpisode.ts
Normal file
10
frontend/src/InteractiveSearch/ReleaseEpisode.ts
Normal file
@ -0,0 +1,10 @@
|
||||
interface ReleaseEpisode {
|
||||
id: number;
|
||||
episodeFileId: number;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
absoluteEpisodeNumber?: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default ReleaseEpisode;
|
@ -1,188 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
import Icon from 'Components/Icon';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, tooltipPositions } from 'Helpers/Props';
|
||||
import styles from './ReleaseSceneIndicator.css';
|
||||
|
||||
function formatReleaseNumber(seasonNumber, episodeNumbers, absoluteEpisodeNumbers) {
|
||||
if (episodeNumbers && episodeNumbers.length) {
|
||||
if (episodeNumbers.length > 1) {
|
||||
return `${seasonNumber}x${episodeNumbers[0]}-${episodeNumbers[episodeNumbers.length - 1]}`;
|
||||
}
|
||||
return `${seasonNumber}x${episodeNumbers[0]}`;
|
||||
}
|
||||
|
||||
if (absoluteEpisodeNumbers && absoluteEpisodeNumbers.length) {
|
||||
if (absoluteEpisodeNumbers.length > 1) {
|
||||
return `${absoluteEpisodeNumbers[0]}-${absoluteEpisodeNumbers[absoluteEpisodeNumbers.length - 1]}`;
|
||||
}
|
||||
return absoluteEpisodeNumbers[0];
|
||||
}
|
||||
|
||||
if (seasonNumber !== undefined) {
|
||||
return `Season ${seasonNumber}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ReleaseSceneIndicator(props) {
|
||||
const {
|
||||
className,
|
||||
seasonNumber,
|
||||
episodeNumbers,
|
||||
absoluteEpisodeNumbers,
|
||||
sceneSeasonNumber,
|
||||
sceneEpisodeNumbers,
|
||||
sceneAbsoluteEpisodeNumbers,
|
||||
sceneMapping,
|
||||
episodeRequested,
|
||||
isDaily
|
||||
} = props;
|
||||
|
||||
const {
|
||||
sceneOrigin,
|
||||
title,
|
||||
comment
|
||||
} = sceneMapping || {};
|
||||
|
||||
if (isDaily) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let mappingDifferent = (sceneSeasonNumber !== undefined && seasonNumber !== sceneSeasonNumber);
|
||||
|
||||
if (sceneEpisodeNumbers !== undefined) {
|
||||
mappingDifferent = mappingDifferent || !_.isEqual(sceneEpisodeNumbers, episodeNumbers);
|
||||
} else if (sceneAbsoluteEpisodeNumbers !== undefined) {
|
||||
mappingDifferent = mappingDifferent || !_.isEqual(sceneAbsoluteEpisodeNumbers, absoluteEpisodeNumbers);
|
||||
}
|
||||
|
||||
if (!sceneMapping && !mappingDifferent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const releaseNumber = formatReleaseNumber(sceneSeasonNumber, sceneEpisodeNumbers, sceneAbsoluteEpisodeNumbers);
|
||||
const mappedNumber = formatReleaseNumber(seasonNumber, episodeNumbers, absoluteEpisodeNumbers);
|
||||
const messages = [];
|
||||
|
||||
const isMixed = (sceneOrigin === 'mixed');
|
||||
const isUnknown = (sceneOrigin === 'unknown' || sceneOrigin === 'unknown:tvdb');
|
||||
|
||||
let level = styles.levelNone;
|
||||
|
||||
if (isMixed) {
|
||||
level = styles.levelMixed;
|
||||
messages.push(<div key="source">{comment ?? 'Source'} releases exist with ambiguous numbering, unable to reliably identify episode.</div>);
|
||||
} else if (isUnknown) {
|
||||
level = styles.levelUnknown;
|
||||
messages.push(<div key="unknown">Numbering varies for this episode and release does not match any known mappings.</div>);
|
||||
if (sceneOrigin === 'unknown') {
|
||||
messages.push(<div key="origin">Assuming Scene numbering.</div>);
|
||||
} else if (sceneOrigin === 'unknown:tvdb') {
|
||||
messages.push(<div key="origin">Assuming TheTVDB numbering.</div>);
|
||||
}
|
||||
} else if (mappingDifferent) {
|
||||
level = styles.levelMapped;
|
||||
} else if (sceneOrigin) {
|
||||
level = styles.levelNormal;
|
||||
}
|
||||
|
||||
if (!episodeRequested) {
|
||||
if (!isMixed && !isUnknown) {
|
||||
level = styles.levelNotRequested;
|
||||
}
|
||||
if (mappedNumber) {
|
||||
messages.push(<div key="not-requested">Mapped episode wasn't requested in this search.</div>);
|
||||
} else {
|
||||
messages.push(<div key="unknown-series">Unknown episode or series.</div>);
|
||||
}
|
||||
}
|
||||
|
||||
const table = (
|
||||
<DescriptionList className={styles.descriptionList}>
|
||||
{
|
||||
comment !== undefined &&
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title="Mapping"
|
||||
data={comment}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
title !== undefined &&
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title="Title"
|
||||
data={title}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
releaseNumber !== undefined &&
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title="Release"
|
||||
data={releaseNumber ?? 'unknown'}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
releaseNumber !== undefined &&
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title="TheTVDB"
|
||||
data={mappedNumber ?? 'unknown'}
|
||||
/>
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
anchor={
|
||||
<div className={classNames(level, styles.container, className)}>
|
||||
<Icon name={icons.SCENE_MAPPING} />
|
||||
</div>
|
||||
}
|
||||
title="Scene Info"
|
||||
body={
|
||||
<div>
|
||||
{table}
|
||||
{
|
||||
messages.length &&
|
||||
<div className={styles.messages}>
|
||||
{messages}
|
||||
</div> || null
|
||||
}
|
||||
</div>
|
||||
}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ReleaseSceneIndicator.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
seasonNumber: PropTypes.number,
|
||||
episodeNumbers: PropTypes.arrayOf(PropTypes.number),
|
||||
absoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
|
||||
sceneSeasonNumber: PropTypes.number,
|
||||
sceneEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
|
||||
sceneAbsoluteEpisodeNumbers: PropTypes.arrayOf(PropTypes.number),
|
||||
sceneMapping: PropTypes.object.isRequired,
|
||||
episodeRequested: PropTypes.bool.isRequired,
|
||||
isDaily: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default ReleaseSceneIndicator;
|
215
frontend/src/InteractiveSearch/ReleaseSceneIndicator.tsx
Normal file
215
frontend/src/InteractiveSearch/ReleaseSceneIndicator.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
import classNames from 'classnames';
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
import Icon from 'Components/Icon';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, tooltipPositions } from 'Helpers/Props';
|
||||
import styles from './ReleaseSceneIndicator.css';
|
||||
|
||||
function formatReleaseNumber(
|
||||
seasonNumber,
|
||||
episodeNumbers,
|
||||
absoluteEpisodeNumbers
|
||||
) {
|
||||
if (episodeNumbers && episodeNumbers.length) {
|
||||
if (episodeNumbers.length > 1) {
|
||||
return `${seasonNumber}x${episodeNumbers[0]}-${
|
||||
episodeNumbers[episodeNumbers.length - 1]
|
||||
}`;
|
||||
}
|
||||
return `${seasonNumber}x${episodeNumbers[0]}`;
|
||||
}
|
||||
|
||||
if (absoluteEpisodeNumbers && absoluteEpisodeNumbers.length) {
|
||||
if (absoluteEpisodeNumbers.length > 1) {
|
||||
return `${absoluteEpisodeNumbers[0]}-${
|
||||
absoluteEpisodeNumbers[absoluteEpisodeNumbers.length - 1]
|
||||
}`;
|
||||
}
|
||||
return absoluteEpisodeNumbers[0];
|
||||
}
|
||||
|
||||
if (seasonNumber !== undefined) {
|
||||
return `Season ${seasonNumber}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ReleaseSceneIndicatorProps {
|
||||
className: string;
|
||||
seasonNumber?: number;
|
||||
episodeNumbers?: number[];
|
||||
absoluteEpisodeNumbers?: number[];
|
||||
sceneSeasonNumber?: number;
|
||||
sceneEpisodeNumbers?: number[];
|
||||
sceneAbsoluteEpisodeNumbers?: number[];
|
||||
sceneMapping?: {
|
||||
sceneOrigin?: string;
|
||||
title?: string;
|
||||
comment?: string;
|
||||
};
|
||||
episodeRequested: boolean;
|
||||
isDaily: boolean;
|
||||
}
|
||||
|
||||
function ReleaseSceneIndicator(props: ReleaseSceneIndicatorProps) {
|
||||
const {
|
||||
className,
|
||||
seasonNumber,
|
||||
episodeNumbers,
|
||||
absoluteEpisodeNumbers,
|
||||
sceneSeasonNumber,
|
||||
sceneEpisodeNumbers,
|
||||
sceneAbsoluteEpisodeNumbers,
|
||||
sceneMapping = {},
|
||||
episodeRequested,
|
||||
isDaily,
|
||||
} = props;
|
||||
|
||||
const { sceneOrigin, title, comment } = sceneMapping;
|
||||
|
||||
if (isDaily) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let mappingDifferent =
|
||||
sceneSeasonNumber !== undefined && seasonNumber !== sceneSeasonNumber;
|
||||
|
||||
if (sceneEpisodeNumbers !== undefined) {
|
||||
mappingDifferent =
|
||||
mappingDifferent || !_.isEqual(sceneEpisodeNumbers, episodeNumbers);
|
||||
} else if (sceneAbsoluteEpisodeNumbers !== undefined) {
|
||||
mappingDifferent =
|
||||
mappingDifferent ||
|
||||
!_.isEqual(sceneAbsoluteEpisodeNumbers, absoluteEpisodeNumbers);
|
||||
}
|
||||
|
||||
if (!sceneMapping && !mappingDifferent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const releaseNumber = formatReleaseNumber(
|
||||
sceneSeasonNumber,
|
||||
sceneEpisodeNumbers,
|
||||
sceneAbsoluteEpisodeNumbers
|
||||
);
|
||||
const mappedNumber = formatReleaseNumber(
|
||||
seasonNumber,
|
||||
episodeNumbers,
|
||||
absoluteEpisodeNumbers
|
||||
);
|
||||
const messages = [];
|
||||
|
||||
const isMixed = sceneOrigin === 'mixed';
|
||||
const isUnknown = sceneOrigin === 'unknown' || sceneOrigin === 'unknown:tvdb';
|
||||
|
||||
let level = styles.levelNone;
|
||||
|
||||
if (isMixed) {
|
||||
level = styles.levelMixed;
|
||||
messages.push(
|
||||
<div key="source">
|
||||
{comment ?? 'Source'} releases exist with ambiguous numbering, unable to
|
||||
reliably identify episode.
|
||||
</div>
|
||||
);
|
||||
} else if (isUnknown) {
|
||||
level = styles.levelUnknown;
|
||||
messages.push(
|
||||
<div key="unknown">
|
||||
Numbering varies for this episode and release does not match any known
|
||||
mappings.
|
||||
</div>
|
||||
);
|
||||
if (sceneOrigin === 'unknown') {
|
||||
messages.push(<div key="origin">Assuming Scene numbering.</div>);
|
||||
} else if (sceneOrigin === 'unknown:tvdb') {
|
||||
messages.push(<div key="origin">Assuming TheTVDB numbering.</div>);
|
||||
}
|
||||
} else if (mappingDifferent) {
|
||||
level = styles.levelMapped;
|
||||
} else if (sceneOrigin) {
|
||||
level = styles.levelNormal;
|
||||
}
|
||||
|
||||
if (!episodeRequested) {
|
||||
if (!isMixed && !isUnknown) {
|
||||
level = styles.levelNotRequested;
|
||||
}
|
||||
if (mappedNumber) {
|
||||
messages.push(
|
||||
<div key="not-requested">
|
||||
Mapped episode wasn't requested in this search.
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
messages.push(<div key="unknown-series">Unknown episode or series.</div>);
|
||||
}
|
||||
}
|
||||
|
||||
const table = (
|
||||
<DescriptionList className={styles.descriptionList}>
|
||||
{comment !== undefined && (
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title="Mapping"
|
||||
data={comment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{title !== undefined && (
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title="Title"
|
||||
data={title}
|
||||
/>
|
||||
)}
|
||||
|
||||
{releaseNumber !== undefined && (
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title="Release"
|
||||
data={releaseNumber ?? 'unknown'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{releaseNumber !== undefined && (
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title="TheTVDB"
|
||||
data={mappedNumber ?? 'unknown'}
|
||||
/>
|
||||
)}
|
||||
</DescriptionList>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
anchor={
|
||||
<div className={classNames(level, styles.container, className)}>
|
||||
<Icon name={icons.SCENE_MAPPING} />
|
||||
</div>
|
||||
}
|
||||
title="Scene Info"
|
||||
body={
|
||||
<div>
|
||||
{table}
|
||||
{(messages.length && (
|
||||
<div className={styles.messages}>{messages}</div>
|
||||
)) ||
|
||||
null}
|
||||
</div>
|
||||
}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReleaseSceneIndicator;
|
@ -1,5 +1,7 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
|
||||
export type SeriesType = 'anime' | 'daily' | 'standard';
|
||||
|
||||
export interface Image {
|
||||
coverType: string;
|
||||
url: string;
|
||||
@ -59,7 +61,7 @@ interface Series extends ModelBase {
|
||||
runtime: number;
|
||||
seasonFolder: boolean;
|
||||
seasons: Season[];
|
||||
seriesType: string;
|
||||
seriesType: SeriesType;
|
||||
sortTitle: string;
|
||||
statistics: Statistics;
|
||||
status: string;
|
||||
|
@ -0,0 +1,21 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
|
||||
export default function createEnabledDownloadClientsSelector(
|
||||
protocol: DownloadProtocol
|
||||
) {
|
||||
return createSelector(
|
||||
createSortedSectionSelector('settings.downloadClients', sortByName),
|
||||
(downloadClients) => {
|
||||
const { isFetching, isPopulated, error, items } = downloadClients;
|
||||
|
||||
const clients = items.filter(
|
||||
(item) => item.protocol === protocol && item.enable
|
||||
);
|
||||
|
||||
return { isFetching, isPopulated, error, items: clients };
|
||||
}
|
||||
);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FizzWare.NBuilder;
|
||||
using FluentAssertions;
|
||||
@ -59,7 +59,7 @@ private RemoteEpisode GetRemoteEpisode(List<Episode> episodes, QualityModel qual
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_download_report_if_epsiode_was_not_already_downloaded()
|
||||
public void should_download_report_if_episode_was_not_already_downloaded()
|
||||
{
|
||||
var episodes = new List<Episode> { GetEpisode(1) };
|
||||
var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p));
|
||||
@ -68,7 +68,7 @@ public void should_download_report_if_epsiode_was_not_already_downloaded()
|
||||
decisions.Add(new DownloadDecision(remoteEpisode));
|
||||
|
||||
Subject.ProcessDecisions(decisions);
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>()), Times.Once());
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>(), null), Times.Once());
|
||||
}
|
||||
|
||||
[Test]
|
||||
@ -82,7 +82,7 @@ public void should_only_download_episode_once()
|
||||
decisions.Add(new DownloadDecision(remoteEpisode));
|
||||
|
||||
Subject.ProcessDecisions(decisions);
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>()), Times.Once());
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>(), null), Times.Once());
|
||||
}
|
||||
|
||||
[Test]
|
||||
@ -101,7 +101,7 @@ public void should_not_download_if_any_episode_was_already_downloaded()
|
||||
decisions.Add(new DownloadDecision(remoteEpisode2));
|
||||
|
||||
Subject.ProcessDecisions(decisions);
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>()), Times.Once());
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>(), null), Times.Once());
|
||||
}
|
||||
|
||||
[Test]
|
||||
@ -166,7 +166,7 @@ public void should_not_add_to_downloaded_list_when_download_fails()
|
||||
var decisions = new List<DownloadDecision>();
|
||||
decisions.Add(new DownloadDecision(remoteEpisode));
|
||||
|
||||
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteEpisode>())).Throws(new Exception());
|
||||
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteEpisode>(), null)).Throws(new Exception());
|
||||
Subject.ProcessDecisions(decisions).Grabbed.Should().BeEmpty();
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
}
|
||||
@ -191,7 +191,7 @@ public void should_not_grab_if_pending()
|
||||
decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary)));
|
||||
|
||||
Subject.ProcessDecisions(decisions);
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>()), Times.Never());
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>(), null), Times.Never());
|
||||
}
|
||||
|
||||
[Test]
|
||||
@ -232,11 +232,11 @@ public void should_add_to_failed_if_already_failed_for_that_protocol()
|
||||
decisions.Add(new DownloadDecision(remoteEpisode));
|
||||
decisions.Add(new DownloadDecision(remoteEpisode));
|
||||
|
||||
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteEpisode>()))
|
||||
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteEpisode>(), null))
|
||||
.Throws(new DownloadClientUnavailableException("Download client failed"));
|
||||
|
||||
Subject.ProcessDecisions(decisions);
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>()), Times.Once());
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>(), null), Times.Once());
|
||||
}
|
||||
|
||||
[Test]
|
||||
@ -250,12 +250,12 @@ public void should_not_add_to_failed_if_failed_for_a_different_protocol()
|
||||
decisions.Add(new DownloadDecision(remoteEpisode));
|
||||
decisions.Add(new DownloadDecision(remoteEpisode2));
|
||||
|
||||
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.Is<RemoteEpisode>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet)))
|
||||
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.Is<RemoteEpisode>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet), null))
|
||||
.Throws(new DownloadClientUnavailableException("Download client failed"));
|
||||
|
||||
Subject.ProcessDecisions(decisions);
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteEpisode>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet)), Times.Once());
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteEpisode>(r => r.Release.DownloadProtocol == DownloadProtocol.Torrent)), Times.Once());
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteEpisode>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet), null), Times.Once());
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteEpisode>(r => r.Release.DownloadProtocol == DownloadProtocol.Torrent), null), Times.Once());
|
||||
}
|
||||
|
||||
[Test]
|
||||
@ -268,7 +268,7 @@ public void should_add_to_rejected_if_release_unavailable_on_indexer()
|
||||
decisions.Add(new DownloadDecision(remoteEpisode));
|
||||
|
||||
Mocker.GetMock<IDownloadService>()
|
||||
.Setup(s => s.DownloadReport(It.IsAny<RemoteEpisode>()))
|
||||
.Setup(s => s.DownloadReport(It.IsAny<RemoteEpisode>(), null))
|
||||
.Throws(new ReleaseUnavailableException(remoteEpisode.Release, "That 404 Error is not just a Quirk"));
|
||||
|
||||
var result = Subject.ProcessDecisions(decisions);
|
||||
|
@ -84,7 +84,7 @@ public void Download_report_should_publish_on_grab_event()
|
||||
var mock = WithUsenetClient();
|
||||
mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>()));
|
||||
|
||||
Subject.DownloadReport(_parseResult);
|
||||
Subject.DownloadReport(_parseResult, null);
|
||||
|
||||
VerifyEventPublished<EpisodeGrabbedEvent>();
|
||||
}
|
||||
@ -95,7 +95,7 @@ public void Download_report_should_grab_using_client()
|
||||
var mock = WithUsenetClient();
|
||||
mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>()));
|
||||
|
||||
Subject.DownloadReport(_parseResult);
|
||||
Subject.DownloadReport(_parseResult, null);
|
||||
|
||||
mock.Verify(s => s.Download(It.IsAny<RemoteEpisode>()), Times.Once());
|
||||
}
|
||||
@ -107,7 +107,7 @@ public void Download_report_should_not_publish_on_failed_grab_event()
|
||||
mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>()))
|
||||
.Throws(new WebException());
|
||||
|
||||
Assert.Throws<WebException>(() => Subject.DownloadReport(_parseResult));
|
||||
Assert.Throws<WebException>(() => Subject.DownloadReport(_parseResult, null));
|
||||
|
||||
VerifyEventNotPublished<EpisodeGrabbedEvent>();
|
||||
}
|
||||
@ -122,7 +122,7 @@ public void Download_report_should_trigger_indexer_backoff_on_indexer_error()
|
||||
throw new ReleaseDownloadException(v.Release, "Error", new WebException());
|
||||
});
|
||||
|
||||
Assert.Throws<ReleaseDownloadException>(() => Subject.DownloadReport(_parseResult));
|
||||
Assert.Throws<ReleaseDownloadException>(() => Subject.DownloadReport(_parseResult, null));
|
||||
|
||||
Mocker.GetMock<IIndexerStatusService>()
|
||||
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Once());
|
||||
@ -142,7 +142,7 @@ public void Download_report_should_trigger_indexer_backoff_on_http429_with_long_
|
||||
throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response));
|
||||
});
|
||||
|
||||
Assert.Throws<ReleaseDownloadException>(() => Subject.DownloadReport(_parseResult));
|
||||
Assert.Throws<ReleaseDownloadException>(() => Subject.DownloadReport(_parseResult, null));
|
||||
|
||||
Mocker.GetMock<IIndexerStatusService>()
|
||||
.Verify(v => v.RecordFailure(It.IsAny<int>(), TimeSpan.FromMinutes(5.0)), Times.Once());
|
||||
@ -162,7 +162,7 @@ public void Download_report_should_trigger_indexer_backoff_on_http429_based_on_d
|
||||
throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response));
|
||||
});
|
||||
|
||||
Assert.Throws<ReleaseDownloadException>(() => Subject.DownloadReport(_parseResult));
|
||||
Assert.Throws<ReleaseDownloadException>(() => Subject.DownloadReport(_parseResult, null));
|
||||
|
||||
Mocker.GetMock<IIndexerStatusService>()
|
||||
.Verify(v => v.RecordFailure(It.IsAny<int>(),
|
||||
@ -176,7 +176,7 @@ public void Download_report_should_not_trigger_indexer_backoff_on_downloadclient
|
||||
mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>()))
|
||||
.Throws(new DownloadClientException("Some Error"));
|
||||
|
||||
Assert.Throws<DownloadClientException>(() => Subject.DownloadReport(_parseResult));
|
||||
Assert.Throws<DownloadClientException>(() => Subject.DownloadReport(_parseResult, null));
|
||||
|
||||
Mocker.GetMock<IIndexerStatusService>()
|
||||
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Never());
|
||||
@ -192,7 +192,7 @@ public void Download_report_should_not_trigger_indexer_backoff_on_indexer_404_er
|
||||
throw new ReleaseUnavailableException(v.Release, "Error", new WebException());
|
||||
});
|
||||
|
||||
Assert.Throws<ReleaseUnavailableException>(() => Subject.DownloadReport(_parseResult));
|
||||
Assert.Throws<ReleaseUnavailableException>(() => Subject.DownloadReport(_parseResult, null));
|
||||
|
||||
Mocker.GetMock<IIndexerStatusService>()
|
||||
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Never());
|
||||
@ -201,7 +201,7 @@ public void Download_report_should_not_trigger_indexer_backoff_on_indexer_404_er
|
||||
[Test]
|
||||
public void should_not_attempt_download_if_client_isnt_configured()
|
||||
{
|
||||
Assert.Throws<DownloadClientUnavailableException>(() => Subject.DownloadReport(_parseResult));
|
||||
Assert.Throws<DownloadClientUnavailableException>(() => Subject.DownloadReport(_parseResult, null));
|
||||
|
||||
Mocker.GetMock<IDownloadClient>().Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Never());
|
||||
VerifyEventNotPublished<EpisodeGrabbedEvent>();
|
||||
@ -223,7 +223,7 @@ public void should_attempt_download_even_if_client_is_disabled()
|
||||
}
|
||||
});
|
||||
|
||||
Subject.DownloadReport(_parseResult);
|
||||
Subject.DownloadReport(_parseResult, null);
|
||||
|
||||
Mocker.GetMock<IDownloadClientStatusService>().Verify(c => c.GetBlockedProviders(), Times.Never());
|
||||
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Once());
|
||||
@ -236,7 +236,7 @@ public void should_send_download_to_correct_usenet_client()
|
||||
var mockTorrent = WithTorrentClient();
|
||||
var mockUsenet = WithUsenetClient();
|
||||
|
||||
Subject.DownloadReport(_parseResult);
|
||||
Subject.DownloadReport(_parseResult, null);
|
||||
|
||||
mockTorrent.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Never());
|
||||
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Once());
|
||||
@ -250,7 +250,7 @@ public void should_send_download_to_correct_torrent_client()
|
||||
|
||||
_parseResult.Release.DownloadProtocol = DownloadProtocol.Torrent;
|
||||
|
||||
Subject.DownloadReport(_parseResult);
|
||||
Subject.DownloadReport(_parseResult, null);
|
||||
|
||||
mockTorrent.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Once());
|
||||
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Never());
|
||||
|
@ -15,7 +15,7 @@ namespace NzbDrone.Core.Download
|
||||
{
|
||||
public interface IDownloadService
|
||||
{
|
||||
void DownloadReport(RemoteEpisode remoteEpisode);
|
||||
void DownloadReport(RemoteEpisode remoteEpisode, int? downloadClientId);
|
||||
}
|
||||
|
||||
public class DownloadService : IDownloadService
|
||||
@ -45,13 +45,21 @@ public DownloadService(IProvideDownloadClient downloadClientProvider,
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void DownloadReport(RemoteEpisode remoteEpisode)
|
||||
public void DownloadReport(RemoteEpisode remoteEpisode, int? downloadClientId)
|
||||
{
|
||||
var downloadClient = downloadClientId.HasValue
|
||||
? _downloadClientProvider.Get(downloadClientId.Value)
|
||||
: _downloadClientProvider.GetDownloadClient(remoteEpisode.Release.DownloadProtocol, remoteEpisode.Release.IndexerId);
|
||||
|
||||
DownloadReport(remoteEpisode, downloadClient);
|
||||
}
|
||||
|
||||
private void DownloadReport(RemoteEpisode remoteEpisode, IDownloadClient downloadClient)
|
||||
{
|
||||
Ensure.That(remoteEpisode.Series, () => remoteEpisode.Series).IsNotNull();
|
||||
Ensure.That(remoteEpisode.Episodes, () => remoteEpisode.Episodes).HasItems();
|
||||
|
||||
var downloadTitle = remoteEpisode.Release.Title;
|
||||
var downloadClient = _downloadClientProvider.GetDownloadClient(remoteEpisode.Release.DownloadProtocol, remoteEpisode.Release.IndexerId);
|
||||
|
||||
if (downloadClient == null)
|
||||
{
|
||||
|
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
@ -74,7 +74,7 @@ public ProcessedDecisions ProcessDecisions(List<DownloadDecision> decisions)
|
||||
try
|
||||
{
|
||||
_logger.Trace("Grabbing from Indexer {0} at priority {1}.", remoteEpisode.Release.Indexer, remoteEpisode.Release.IndexerPriority);
|
||||
_downloadService.DownloadReport(remoteEpisode);
|
||||
_downloadService.DownloadReport(remoteEpisode, null);
|
||||
grabbed.Add(report);
|
||||
}
|
||||
catch (ReleaseUnavailableException)
|
||||
|
@ -4,6 +4,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Cache;
|
||||
using NzbDrone.Common.EnsureThat;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.DecisionEngine;
|
||||
using NzbDrone.Core.Download;
|
||||
@ -79,6 +80,23 @@ public object DownloadRelease(ReleaseResource release)
|
||||
|
||||
try
|
||||
{
|
||||
if (release.ShouldOverride == true)
|
||||
{
|
||||
Ensure.That(release.SeriesId, () => release.SeriesId).IsNotNull();
|
||||
Ensure.That(release.EpisodeIds, () => release.EpisodeIds).IsNotNull();
|
||||
Ensure.That(release.EpisodeIds, () => release.EpisodeIds).HasItems();
|
||||
Ensure.That(release.Quality, () => release.Quality).IsNotNull();
|
||||
Ensure.That(release.Languages, () => release.Languages).IsNotNull();
|
||||
|
||||
// Clone the remote episode so we don't overwrite anything on the original
|
||||
remoteEpisode = remoteEpisode.JsonClone();
|
||||
|
||||
remoteEpisode.Series = _seriesService.GetSeries(release.SeriesId.Value);
|
||||
remoteEpisode.Episodes = _episodeService.GetEpisodes(release.EpisodeIds);
|
||||
remoteEpisode.ParsedEpisodeInfo.Quality = release.Quality;
|
||||
remoteEpisode.Languages = release.Languages;
|
||||
}
|
||||
|
||||
if (remoteEpisode.Series == null)
|
||||
{
|
||||
if (release.EpisodeId.HasValue)
|
||||
@ -125,7 +143,7 @@ public object DownloadRelease(ReleaseResource release)
|
||||
throw new NzbDroneClientException(HttpStatusCode.NotFound, "Unable to parse episodes in the release");
|
||||
}
|
||||
|
||||
_downloadService.DownloadReport(remoteEpisode);
|
||||
_downloadService.DownloadReport(remoteEpisode, release.DownloadClientId);
|
||||
}
|
||||
catch (ReleaseDownloadException ex)
|
||||
{
|
||||
|
@ -7,6 +7,7 @@
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Qualities;
|
||||
using NzbDrone.Core.Tv;
|
||||
using Sonarr.Api.V3.CustomFormats;
|
||||
using Sonarr.Api.V3.Series;
|
||||
using Sonarr.Http.REST;
|
||||
@ -40,6 +41,8 @@ public class ReleaseResource : RestResource
|
||||
public int? MappedSeasonNumber { get; set; }
|
||||
public int[] MappedEpisodeNumbers { get; set; }
|
||||
public int[] MappedAbsoluteEpisodeNumbers { get; set; }
|
||||
public int? MappedSeriesId { get; set; }
|
||||
public IEnumerable<ReleaseEpisodeResource> MappedEpisodeInfo { get; set; }
|
||||
public bool Approved { get; set; }
|
||||
public bool TemporarilyRejected { get; set; }
|
||||
public bool Rejected { get; set; }
|
||||
@ -75,6 +78,15 @@ public class ReleaseResource : RestResource
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public int? EpisodeId { get; set; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public List<int> EpisodeIds { get; set; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public int? DownloadClientId { get; set; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool? ShouldOverride { get; set; }
|
||||
}
|
||||
|
||||
public static class ReleaseResourceMapper
|
||||
@ -109,9 +121,11 @@ public static ReleaseResource ToResource(this DownloadDecision model)
|
||||
SeriesTitle = parsedEpisodeInfo.SeriesTitle,
|
||||
EpisodeNumbers = parsedEpisodeInfo.EpisodeNumbers,
|
||||
AbsoluteEpisodeNumbers = parsedEpisodeInfo.AbsoluteEpisodeNumbers,
|
||||
MappedSeriesId = remoteEpisode.Series?.Id,
|
||||
MappedSeasonNumber = remoteEpisode.Episodes.FirstOrDefault()?.SeasonNumber,
|
||||
MappedEpisodeNumbers = remoteEpisode.Episodes.Select(v => v.EpisodeNumber).ToArray(),
|
||||
MappedAbsoluteEpisodeNumbers = remoteEpisode.Episodes.Where(v => v.AbsoluteEpisodeNumber.HasValue).Select(v => v.AbsoluteEpisodeNumber.Value).ToArray(),
|
||||
MappedEpisodeInfo = remoteEpisode.Episodes.Select(v => new ReleaseEpisodeResource(v)),
|
||||
Approved = model.Approved,
|
||||
TemporarilyRejected = model.TemporarilyRejected,
|
||||
Rejected = model.Rejected,
|
||||
@ -178,4 +192,26 @@ public static ReleaseInfo ToModel(this ReleaseResource resource)
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
public class ReleaseEpisodeResource
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int SeasonNumber { get; set; }
|
||||
public int EpisodeNumber { get; set; }
|
||||
public int? AbsoluteEpisodeNumber { get; set; }
|
||||
public string Title { get; set; }
|
||||
|
||||
public ReleaseEpisodeResource()
|
||||
{
|
||||
}
|
||||
|
||||
public ReleaseEpisodeResource(Episode episode)
|
||||
{
|
||||
Id = episode.Id;
|
||||
SeasonNumber = episode.SeasonNumber;
|
||||
EpisodeNumber = episode.EpisodeNumber;
|
||||
AbsoluteEpisodeNumber = episode.AbsoluteEpisodeNumber;
|
||||
Title = episode.Title;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ public object Grab(int id)
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
_downloadService.DownloadReport(pendingRelease.RemoteEpisode);
|
||||
_downloadService.DownloadReport(pendingRelease.RemoteEpisode, null);
|
||||
|
||||
return new { };
|
||||
}
|
||||
@ -47,7 +47,7 @@ public object Grab([FromBody] QueueBulkResource resource)
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
_downloadService.DownloadReport(pendingRelease.RemoteEpisode);
|
||||
_downloadService.DownloadReport(pendingRelease.RemoteEpisode, null);
|
||||
}
|
||||
|
||||
return new { };
|
||||
|
Loading…
Reference in New Issue
Block a user