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:
Mark McDowall 2023-03-27 16:49:12 -07:00
parent defdc84b7e
commit 07f0fbf9a5
31 changed files with 1423 additions and 533 deletions

View File

@ -10,6 +10,7 @@ class DescriptionListItem extends Component {
render() { render() {
const { const {
className,
titleClassName, titleClassName,
descriptionClassName, descriptionClassName,
title, title,
@ -17,7 +18,7 @@ class DescriptionListItem extends Component {
} = this.props; } = this.props;
return ( return (
<span> <span className={className}>
<DescriptionListItemTitle <DescriptionListItemTitle
className={titleClassName} className={titleClassName}
> >
@ -35,6 +36,7 @@ class DescriptionListItem extends Component {
} }
DescriptionListItem.propTypes = { DescriptionListItem.propTypes = {
className: PropTypes.string,
titleClassName: PropTypes.string, titleClassName: PropTypes.string,
descriptionClassName: PropTypes.string, descriptionClassName: PropTypes.string,
title: PropTypes.string, title: PropTypes.string,

View File

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

View File

@ -39,6 +39,7 @@ import {
faChevronCircleRight as fasChevronCircleRight, faChevronCircleRight as fasChevronCircleRight,
faChevronCircleUp as fasChevronCircleUp, faChevronCircleUp as fasChevronCircleUp,
faCircle as fasCircle, faCircle as fasCircle,
faCircleDown as fasCircleDown,
faCloud as fasCloud, faCloud as fasCloud,
faCloudDownloadAlt as fasCloudDownloadAlt, faCloudDownloadAlt as fasCloudDownloadAlt,
faCog as fasCog, faCog as fasCog,
@ -121,6 +122,7 @@ export const CHECK_INDETERMINATE = fasMinus;
export const CHECK_CIRCLE = fasCheckCircle; export const CHECK_CIRCLE = fasCheckCircle;
export const CHECK_SQUARE = fasSquareCheck; export const CHECK_SQUARE = fasSquareCheck;
export const CIRCLE = fasCircle; export const CIRCLE = fasCircle;
export const CIRCLE_DOWN = fasCircleDown;
export const CIRCLE_OUTLINE = farCircle; export const CIRCLE_OUTLINE = farCircle;
export const CLEAR = fasTrashAlt; export const CLEAR = fasTrashAlt;
export const CLIPBOARD = fasCopy; export const CLIPBOARD = fasCopy;

View File

@ -40,8 +40,7 @@
cursor: default; cursor: default;
} }
.rejected, .rejected {
.download {
composes: cell from '~Components/Table/Cells/TableRowCell.css'; composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 50px; width: 50px;
@ -59,3 +58,34 @@
width: 75px; 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;
}

View File

@ -4,8 +4,11 @@ interface CssExports {
'age': string; 'age': string;
'customFormatScore': string; 'customFormatScore': string;
'download': string; 'download': string;
'downloadIcon': string;
'indexer': string; 'indexer': string;
'interactiveIcon': string;
'languages': string; 'languages': string;
'manualDownloadContent': string;
'peers': string; 'peers': string;
'protocol': string; 'protocol': string;
'quality': string; 'quality': string;

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -0,0 +1,10 @@
interface ReleaseEpisode {
id: number;
episodeFileId: number;
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber?: number;
title: string;
}
export default ReleaseEpisode;

View File

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

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

View File

@ -1,5 +1,7 @@
import ModelBase from 'App/ModelBase'; import ModelBase from 'App/ModelBase';
export type SeriesType = 'anime' | 'daily' | 'standard';
export interface Image { export interface Image {
coverType: string; coverType: string;
url: string; url: string;
@ -59,7 +61,7 @@ interface Series extends ModelBase {
runtime: number; runtime: number;
seasonFolder: boolean; seasonFolder: boolean;
seasons: Season[]; seasons: Season[];
seriesType: string; seriesType: SeriesType;
sortTitle: string; sortTitle: string;
statistics: Statistics; statistics: Statistics;
status: string; status: string;

View File

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

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using FizzWare.NBuilder; using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
@ -59,7 +59,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
} }
[Test] [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 episodes = new List<Episode> { GetEpisode(1) };
var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p)); var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p));
@ -68,7 +68,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteEpisode)); decisions.Add(new DownloadDecision(remoteEpisode));
Subject.ProcessDecisions(decisions); 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] [Test]
@ -82,7 +82,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteEpisode)); decisions.Add(new DownloadDecision(remoteEpisode));
Subject.ProcessDecisions(decisions); 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] [Test]
@ -101,7 +101,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteEpisode2)); decisions.Add(new DownloadDecision(remoteEpisode2));
Subject.ProcessDecisions(decisions); 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] [Test]
@ -166,7 +166,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
var decisions = new List<DownloadDecision>(); var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteEpisode)); 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(); Subject.ProcessDecisions(decisions).Grabbed.Should().BeEmpty();
ExceptionVerification.ExpectedWarns(1); ExceptionVerification.ExpectedWarns(1);
} }
@ -191,7 +191,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary))); decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary)));
Subject.ProcessDecisions(decisions); 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] [Test]
@ -232,11 +232,11 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteEpisode)); decisions.Add(new DownloadDecision(remoteEpisode));
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")); .Throws(new DownloadClientUnavailableException("Download client failed"));
Subject.ProcessDecisions(decisions); 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] [Test]
@ -250,12 +250,12 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteEpisode)); decisions.Add(new DownloadDecision(remoteEpisode));
decisions.Add(new DownloadDecision(remoteEpisode2)); 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")); .Throws(new DownloadClientUnavailableException("Download client failed"));
Subject.ProcessDecisions(decisions); 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.Usenet), null), 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.Torrent), null), Times.Once());
} }
[Test] [Test]
@ -268,7 +268,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteEpisode)); decisions.Add(new DownloadDecision(remoteEpisode));
Mocker.GetMock<IDownloadService>() 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")); .Throws(new ReleaseUnavailableException(remoteEpisode.Release, "That 404 Error is not just a Quirk"));
var result = Subject.ProcessDecisions(decisions); var result = Subject.ProcessDecisions(decisions);

View File

@ -84,7 +84,7 @@ namespace NzbDrone.Core.Test.Download
var mock = WithUsenetClient(); var mock = WithUsenetClient();
mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>())); mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>()));
Subject.DownloadReport(_parseResult); Subject.DownloadReport(_parseResult, null);
VerifyEventPublished<EpisodeGrabbedEvent>(); VerifyEventPublished<EpisodeGrabbedEvent>();
} }
@ -95,7 +95,7 @@ namespace NzbDrone.Core.Test.Download
var mock = WithUsenetClient(); var mock = WithUsenetClient();
mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>())); 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()); mock.Verify(s => s.Download(It.IsAny<RemoteEpisode>()), Times.Once());
} }
@ -107,7 +107,7 @@ namespace NzbDrone.Core.Test.Download
mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>())) mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>()))
.Throws(new WebException()); .Throws(new WebException());
Assert.Throws<WebException>(() => Subject.DownloadReport(_parseResult)); Assert.Throws<WebException>(() => Subject.DownloadReport(_parseResult, null));
VerifyEventNotPublished<EpisodeGrabbedEvent>(); VerifyEventNotPublished<EpisodeGrabbedEvent>();
} }
@ -122,7 +122,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseDownloadException(v.Release, "Error", new WebException()); throw new ReleaseDownloadException(v.Release, "Error", new WebException());
}); });
Assert.Throws<ReleaseDownloadException>(() => Subject.DownloadReport(_parseResult)); Assert.Throws<ReleaseDownloadException>(() => Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>() Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Once()); .Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Once());
@ -142,7 +142,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response)); 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>() Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), TimeSpan.FromMinutes(5.0)), Times.Once()); .Verify(v => v.RecordFailure(It.IsAny<int>(), TimeSpan.FromMinutes(5.0)), Times.Once());
@ -162,7 +162,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response)); 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>() Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), .Verify(v => v.RecordFailure(It.IsAny<int>(),
@ -176,7 +176,7 @@ namespace NzbDrone.Core.Test.Download
mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>())) mock.Setup(s => s.Download(It.IsAny<RemoteEpisode>()))
.Throws(new DownloadClientException("Some Error")); .Throws(new DownloadClientException("Some Error"));
Assert.Throws<DownloadClientException>(() => Subject.DownloadReport(_parseResult)); Assert.Throws<DownloadClientException>(() => Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>() Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Never()); .Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Never());
@ -192,7 +192,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseUnavailableException(v.Release, "Error", new WebException()); throw new ReleaseUnavailableException(v.Release, "Error", new WebException());
}); });
Assert.Throws<ReleaseUnavailableException>(() => Subject.DownloadReport(_parseResult)); Assert.Throws<ReleaseUnavailableException>(() => Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>() Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Never()); .Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Never());
@ -201,7 +201,7 @@ namespace NzbDrone.Core.Test.Download
[Test] [Test]
public void should_not_attempt_download_if_client_isnt_configured() 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()); Mocker.GetMock<IDownloadClient>().Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Never());
VerifyEventNotPublished<EpisodeGrabbedEvent>(); VerifyEventNotPublished<EpisodeGrabbedEvent>();
@ -223,7 +223,7 @@ namespace NzbDrone.Core.Test.Download
} }
}); });
Subject.DownloadReport(_parseResult); Subject.DownloadReport(_parseResult, null);
Mocker.GetMock<IDownloadClientStatusService>().Verify(c => c.GetBlockedProviders(), Times.Never()); Mocker.GetMock<IDownloadClientStatusService>().Verify(c => c.GetBlockedProviders(), Times.Never());
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Once()); mockUsenet.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Once());
@ -236,7 +236,7 @@ namespace NzbDrone.Core.Test.Download
var mockTorrent = WithTorrentClient(); var mockTorrent = WithTorrentClient();
var mockUsenet = WithUsenetClient(); var mockUsenet = WithUsenetClient();
Subject.DownloadReport(_parseResult); Subject.DownloadReport(_parseResult, null);
mockTorrent.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Never()); mockTorrent.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Never());
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Once()); mockUsenet.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Once());
@ -250,7 +250,7 @@ namespace NzbDrone.Core.Test.Download
_parseResult.Release.DownloadProtocol = DownloadProtocol.Torrent; _parseResult.Release.DownloadProtocol = DownloadProtocol.Torrent;
Subject.DownloadReport(_parseResult); Subject.DownloadReport(_parseResult, null);
mockTorrent.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Once()); mockTorrent.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Once());
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Never()); mockUsenet.Verify(c => c.Download(It.IsAny<RemoteEpisode>()), Times.Never());

View File

@ -15,7 +15,7 @@ namespace NzbDrone.Core.Download
{ {
public interface IDownloadService public interface IDownloadService
{ {
void DownloadReport(RemoteEpisode remoteEpisode); void DownloadReport(RemoteEpisode remoteEpisode, int? downloadClientId);
} }
public class DownloadService : IDownloadService public class DownloadService : IDownloadService
@ -45,13 +45,21 @@ namespace NzbDrone.Core.Download
_logger = logger; _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.Series, () => remoteEpisode.Series).IsNotNull();
Ensure.That(remoteEpisode.Episodes, () => remoteEpisode.Episodes).HasItems(); Ensure.That(remoteEpisode.Episodes, () => remoteEpisode.Episodes).HasItems();
var downloadTitle = remoteEpisode.Release.Title; var downloadTitle = remoteEpisode.Release.Title;
var downloadClient = _downloadClientProvider.GetDownloadClient(remoteEpisode.Release.DownloadProtocol, remoteEpisode.Release.IndexerId);
if (downloadClient == null) if (downloadClient == null)
{ {

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
@ -74,7 +74,7 @@ namespace NzbDrone.Core.Download
try try
{ {
_logger.Trace("Grabbing from Indexer {0} at priority {1}.", remoteEpisode.Release.Indexer, remoteEpisode.Release.IndexerPriority); _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); grabbed.Add(report);
} }
catch (ReleaseUnavailableException) catch (ReleaseUnavailableException)

View File

@ -4,6 +4,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NLog; using NLog;
using NzbDrone.Common.Cache; using NzbDrone.Common.Cache;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Download; using NzbDrone.Core.Download;
@ -79,6 +80,23 @@ namespace Sonarr.Api.V3.Indexers
try 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 (remoteEpisode.Series == null)
{ {
if (release.EpisodeId.HasValue) if (release.EpisodeId.HasValue)
@ -125,7 +143,7 @@ namespace Sonarr.Api.V3.Indexers
throw new NzbDroneClientException(HttpStatusCode.NotFound, "Unable to parse episodes in the release"); throw new NzbDroneClientException(HttpStatusCode.NotFound, "Unable to parse episodes in the release");
} }
_downloadService.DownloadReport(remoteEpisode); _downloadService.DownloadReport(remoteEpisode, release.DownloadClientId);
} }
catch (ReleaseDownloadException ex) catch (ReleaseDownloadException ex)
{ {

View File

@ -7,6 +7,7 @@ using NzbDrone.Core.Indexers;
using NzbDrone.Core.Languages; using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
using NzbDrone.Core.Tv;
using Sonarr.Api.V3.CustomFormats; using Sonarr.Api.V3.CustomFormats;
using Sonarr.Api.V3.Series; using Sonarr.Api.V3.Series;
using Sonarr.Http.REST; using Sonarr.Http.REST;
@ -40,6 +41,8 @@ namespace Sonarr.Api.V3.Indexers
public int? MappedSeasonNumber { get; set; } public int? MappedSeasonNumber { get; set; }
public int[] MappedEpisodeNumbers { get; set; } public int[] MappedEpisodeNumbers { get; set; }
public int[] MappedAbsoluteEpisodeNumbers { 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 Approved { get; set; }
public bool TemporarilyRejected { get; set; } public bool TemporarilyRejected { get; set; }
public bool Rejected { get; set; } public bool Rejected { get; set; }
@ -75,6 +78,15 @@ namespace Sonarr.Api.V3.Indexers
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int? EpisodeId { get; set; } 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 public static class ReleaseResourceMapper
@ -109,9 +121,11 @@ namespace Sonarr.Api.V3.Indexers
SeriesTitle = parsedEpisodeInfo.SeriesTitle, SeriesTitle = parsedEpisodeInfo.SeriesTitle,
EpisodeNumbers = parsedEpisodeInfo.EpisodeNumbers, EpisodeNumbers = parsedEpisodeInfo.EpisodeNumbers,
AbsoluteEpisodeNumbers = parsedEpisodeInfo.AbsoluteEpisodeNumbers, AbsoluteEpisodeNumbers = parsedEpisodeInfo.AbsoluteEpisodeNumbers,
MappedSeriesId = remoteEpisode.Series?.Id,
MappedSeasonNumber = remoteEpisode.Episodes.FirstOrDefault()?.SeasonNumber, MappedSeasonNumber = remoteEpisode.Episodes.FirstOrDefault()?.SeasonNumber,
MappedEpisodeNumbers = remoteEpisode.Episodes.Select(v => v.EpisodeNumber).ToArray(), MappedEpisodeNumbers = remoteEpisode.Episodes.Select(v => v.EpisodeNumber).ToArray(),
MappedAbsoluteEpisodeNumbers = remoteEpisode.Episodes.Where(v => v.AbsoluteEpisodeNumber.HasValue).Select(v => v.AbsoluteEpisodeNumber.Value).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, Approved = model.Approved,
TemporarilyRejected = model.TemporarilyRejected, TemporarilyRejected = model.TemporarilyRejected,
Rejected = model.Rejected, Rejected = model.Rejected,
@ -178,4 +192,26 @@ namespace Sonarr.Api.V3.Indexers
return model; 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;
}
}
} }

View File

@ -29,7 +29,7 @@ namespace Sonarr.Api.V3.Queue
throw new NotFoundException(); throw new NotFoundException();
} }
_downloadService.DownloadReport(pendingRelease.RemoteEpisode); _downloadService.DownloadReport(pendingRelease.RemoteEpisode, null);
return new { }; return new { };
} }
@ -47,7 +47,7 @@ namespace Sonarr.Api.V3.Queue
throw new NotFoundException(); throw new NotFoundException();
} }
_downloadService.DownloadReport(pendingRelease.RemoteEpisode); _downloadService.DownloadReport(pendingRelease.RemoteEpisode, null);
} }
return new { }; return new { };