mirror of
https://github.com/Sonarr/Sonarr
synced 2026-05-08 13:01:10 +02:00
feature: external decision (renamed from external hook)
This commit is contained in:
parent
5bde924239
commit
82bac51866
66 changed files with 3228 additions and 3 deletions
|
|
@ -12,6 +12,7 @@ import SeriesDetailsPage from 'Series/Details/SeriesDetailsPage';
|
|||
import SeriesIndex from 'Series/Index/SeriesIndex';
|
||||
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
|
||||
import DownloadClientSettings from 'Settings/DownloadClients/DownloadClientSettings';
|
||||
import ExternalDecisionSettings from 'Settings/ExternalDecisions/ExternalDecisionSettings';
|
||||
import GeneralSettings from 'Settings/General/GeneralSettings';
|
||||
import ImportListSettings from 'Settings/ImportLists/ImportListSettings';
|
||||
import IndexerSettings from 'Settings/Indexers/IndexerSettings';
|
||||
|
|
@ -120,6 +121,11 @@ function AppRoutes() {
|
|||
|
||||
<Route path="/settings/connect" component={NotificationSettings} />
|
||||
|
||||
<Route
|
||||
path="/settings/externaldecisions"
|
||||
component={ExternalDecisionSettings}
|
||||
/>
|
||||
|
||||
<Route path="/settings/metadata" component={MetadataSettings} />
|
||||
|
||||
<Route
|
||||
|
|
|
|||
|
|
@ -145,6 +145,10 @@ const LINKS: SidebarItem[] = [
|
|||
title: () => translate('Connect'),
|
||||
to: '/settings/connect',
|
||||
},
|
||||
{
|
||||
title: () => translate('ExternalDecisions'),
|
||||
to: '/settings/externaldecisions',
|
||||
},
|
||||
{
|
||||
title: () => translate('Metadata'),
|
||||
to: '/settings/metadata',
|
||||
|
|
|
|||
|
|
@ -44,6 +44,13 @@
|
|||
cursor: default;
|
||||
}
|
||||
|
||||
.externalPriorityScore {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 55px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.rejected,
|
||||
.indexerFlags {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ interface CssExports {
|
|||
'customFormatScore': string;
|
||||
'download': string;
|
||||
'downloadIcon': string;
|
||||
'externalPriorityScore': string;
|
||||
'history': string;
|
||||
'indexer': string;
|
||||
'indexerFlags': string;
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
|
|||
languages,
|
||||
customFormatScore,
|
||||
customFormats,
|
||||
externalPriorityScore,
|
||||
sceneMapping,
|
||||
mappedSeriesId,
|
||||
mappedSeasonNumber,
|
||||
|
|
@ -287,6 +288,10 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
|
|||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.externalPriorityScore}>
|
||||
{formatCustomFormatScore(externalPriorityScore)}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.indexerFlags}>
|
||||
{indexerFlags ? (
|
||||
<Popover
|
||||
|
|
|
|||
|
|
@ -90,6 +90,15 @@ const { useOptions, useOption, getOptions, getOption, setOptions, setOption } =
|
|||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'externalPriorityScore',
|
||||
label: createElement(Icon, {
|
||||
name: icons.EXTERNAL_LINK,
|
||||
title: () => translate('ExternalPriorityScore'),
|
||||
}),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'indexerFlags',
|
||||
label: createElement(Icon, {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export interface Release extends ModelBase {
|
|||
releaseWeight: number;
|
||||
customFormats: CustomFormat[];
|
||||
customFormatScore: number;
|
||||
externalPriorityScore: number;
|
||||
indexerFlags: number;
|
||||
sceneMapping?: AlternateTitle;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import SettingsToolbar from 'Settings/SettingsToolbar';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import ExternalDecisions from './ExternalDecisions/ExternalDecisions';
|
||||
|
||||
function ExternalDecisionSettings() {
|
||||
return (
|
||||
<PageContent title={translate('ExternalDecisionSettings')}>
|
||||
<SettingsToolbar showSave={false} />
|
||||
|
||||
<PageContentBody>
|
||||
<ExternalDecisions />
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExternalDecisionSettings;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
.externalDecision {
|
||||
composes: card from '~Components/Card.css';
|
||||
|
||||
position: relative;
|
||||
width: 300px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.underlay {
|
||||
@add-mixin cover;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
@add-mixin linkOverlay;
|
||||
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.name {
|
||||
text-align: center;
|
||||
font-weight: lighter;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
11
frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionItem.css.d.ts
vendored
Normal file
11
frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionItem.css.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
actions: string;
|
||||
externalDecision: string;
|
||||
name: string;
|
||||
overlay: string;
|
||||
underlay: string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import { SelectedSchema } from 'Settings/useProviderSchema';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AddExternalDecisionItem.css';
|
||||
|
||||
interface AddExternalDecisionItemProps {
|
||||
implementation: string;
|
||||
implementationName: string;
|
||||
infoLink: string;
|
||||
onExternalDecisionSelect: (selectedSchema: SelectedSchema) => void;
|
||||
}
|
||||
|
||||
function AddExternalDecisionItem({
|
||||
implementation,
|
||||
implementationName,
|
||||
infoLink,
|
||||
onExternalDecisionSelect,
|
||||
}: AddExternalDecisionItemProps) {
|
||||
const handleExternalDecisionSelect = useCallback(() => {
|
||||
onExternalDecisionSelect({ implementation, implementationName });
|
||||
}, [implementation, implementationName, onExternalDecisionSelect]);
|
||||
|
||||
return (
|
||||
<div className={styles.externalDecision}>
|
||||
<Link
|
||||
className={styles.underlay}
|
||||
onPress={handleExternalDecisionSelect}
|
||||
/>
|
||||
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.name}>{implementationName}</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button to={infoLink} size={sizes.SMALL}>
|
||||
{translate('MoreInfo')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddExternalDecisionItem;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import AddExternalDecisionModalContent, {
|
||||
AddExternalDecisionModalContentProps,
|
||||
} from './AddExternalDecisionModalContent';
|
||||
|
||||
interface AddExternalDecisionModalProps
|
||||
extends AddExternalDecisionModalContentProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
function AddExternalDecisionModal({
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
}: AddExternalDecisionModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<AddExternalDecisionModalContent
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddExternalDecisionModal;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.externalDecisions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
externalDecisions: string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
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 { kinds } from 'Helpers/Props';
|
||||
import { SelectedSchema } from 'Settings/useProviderSchema';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useExternalDecisionSchema } from '../useExternalDecisions';
|
||||
import AddExternalDecisionItem from './AddExternalDecisionItem';
|
||||
import styles from './AddExternalDecisionModalContent.css';
|
||||
|
||||
export interface AddExternalDecisionModalContentProps {
|
||||
onExternalDecisionSelect: (selectedSchema: SelectedSchema) => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function AddExternalDecisionModalContent({
|
||||
onExternalDecisionSelect,
|
||||
onModalClose,
|
||||
}: AddExternalDecisionModalContentProps) {
|
||||
const { isSchemaFetching, isSchemaFetched, schemaError, schema } =
|
||||
useExternalDecisionSchema();
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('AddExternalDecision')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{isSchemaFetching && !isSchemaFetched ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isSchemaFetching && !!schemaError ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('AddExternalDecisionError')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isSchemaFetched && !schemaError ? (
|
||||
<div className={styles.externalDecisions}>
|
||||
{schema.map((schema) => {
|
||||
return (
|
||||
<AddExternalDecisionItem
|
||||
key={schema.implementation}
|
||||
{...schema}
|
||||
implementation={schema.implementation}
|
||||
onExternalDecisionSelect={onExternalDecisionSelect}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddExternalDecisionModalContent;
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import EditExternalDecisionModalContent, {
|
||||
EditExternalDecisionModalContentProps,
|
||||
} from './EditExternalDecisionModalContent';
|
||||
|
||||
interface EditExternalDecisionModalProps
|
||||
extends EditExternalDecisionModalContentProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
function EditExternalDecisionModal({
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
}: EditExternalDecisionModalProps) {
|
||||
const handleModalClose = useCallback(() => {
|
||||
onModalClose();
|
||||
}, [onModalClose]);
|
||||
|
||||
return (
|
||||
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}>
|
||||
<EditExternalDecisionModalContent
|
||||
{...otherProps}
|
||||
onModalClose={handleModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditExternalDecisionModal;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
.deleteButton {
|
||||
composes: button from '~Components/Link/Button.css';
|
||||
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.message {
|
||||
composes: alert from '~Components/Alert.css';
|
||||
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
deleteButton: string;
|
||||
message: string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
|
||||
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 usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { inputTypes, kinds } from 'Helpers/Props';
|
||||
import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
|
||||
import { useShowAdvancedSettings } from 'Settings/advancedSettingsStore';
|
||||
import { useManageExternalDecision } from 'Settings/ExternalDecisions/useExternalDecisions';
|
||||
import { SelectedSchema } from 'Settings/useProviderSchema';
|
||||
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EditExternalDecisionModalContent.css';
|
||||
|
||||
export interface EditExternalDecisionModalContentProps {
|
||||
id?: number;
|
||||
selectedSchema?: SelectedSchema;
|
||||
onModalClose: () => void;
|
||||
onDeleteExternalDecisionPress?: () => void;
|
||||
}
|
||||
|
||||
function EditExternalDecisionModalContent({
|
||||
id,
|
||||
selectedSchema,
|
||||
onModalClose,
|
||||
onDeleteExternalDecisionPress,
|
||||
}: EditExternalDecisionModalContentProps) {
|
||||
const showAdvancedSettings = useShowAdvancedSettings();
|
||||
|
||||
const {
|
||||
item,
|
||||
updateFieldValue,
|
||||
updateValue,
|
||||
saveProvider,
|
||||
isSaving,
|
||||
saveError,
|
||||
testProvider,
|
||||
isTesting,
|
||||
validationErrors,
|
||||
validationWarnings,
|
||||
} = useManageExternalDecision(id, selectedSchema);
|
||||
|
||||
const wasSaving = usePrevious(isSaving);
|
||||
|
||||
const {
|
||||
implementationName,
|
||||
name,
|
||||
fields,
|
||||
tags,
|
||||
message,
|
||||
decisionType,
|
||||
enable,
|
||||
priority,
|
||||
} = item;
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(change: InputChanged) => {
|
||||
// @ts-expect-error - change is not yet typed
|
||||
updateValue(change.name, change.value);
|
||||
},
|
||||
[updateValue]
|
||||
);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
({
|
||||
name,
|
||||
value,
|
||||
additionalProperties,
|
||||
}: EnhancedSelectInputChanged<unknown>) => {
|
||||
updateFieldValue({ [name]: value, ...additionalProperties });
|
||||
},
|
||||
[updateFieldValue]
|
||||
);
|
||||
|
||||
const handleTestPress = useCallback(() => {
|
||||
testProvider();
|
||||
}, [testProvider]);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
saveProvider();
|
||||
}, [saveProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasSaving && !isSaving && !saveError) {
|
||||
onModalClose();
|
||||
}
|
||||
}, [isSaving, wasSaving, saveError, onModalClose]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{id
|
||||
? translate('EditExternalDecisionImplementation', {
|
||||
implementationName,
|
||||
})
|
||||
: translate('AddExternalDecisionImplementation', {
|
||||
implementationName,
|
||||
})}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form
|
||||
validationErrors={validationErrors}
|
||||
validationWarnings={validationWarnings}
|
||||
>
|
||||
{message ? (
|
||||
<Alert className={styles.message} kind={message.value.type}>
|
||||
{message.value.message}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Name')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="name"
|
||||
{...name}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Enable')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="enable"
|
||||
{...enable}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('DecisionType')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="decisionType"
|
||||
values={[
|
||||
{
|
||||
key: 'rejection',
|
||||
value: translate('ExternalDecisionTypeRejection'),
|
||||
},
|
||||
{
|
||||
key: 'prioritization',
|
||||
value: translate('ExternalDecisionTypePrioritization'),
|
||||
},
|
||||
]}
|
||||
helpText={translate('ExternalDecisionTypeHelpText')}
|
||||
{...decisionType}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
|
||||
<FormLabel>{translate('ExternalDecisionPriority')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="priority"
|
||||
helpText={translate('ExternalDecisionPriorityHelpText')}
|
||||
min={1}
|
||||
max={50}
|
||||
{...priority}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
helpText={translate('ExternalDecisionTagsHelpText')}
|
||||
{...tags}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{fields.map((field) => {
|
||||
return (
|
||||
<ProviderFieldFormGroup
|
||||
key={field.name}
|
||||
{...field}
|
||||
advancedSettings={showAdvancedSettings}
|
||||
provider="externalDecision"
|
||||
providerData={item}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{id ? (
|
||||
<Button
|
||||
className={styles.deleteButton}
|
||||
kind={kinds.DANGER}
|
||||
onPress={onDeleteExternalDecisionPress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<AdvancedSettingsButton showLabel={false} />
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isTesting}
|
||||
error={saveError}
|
||||
onPress={handleTestPress}
|
||||
>
|
||||
{translate('Test')}
|
||||
</SpinnerErrorButton>
|
||||
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={handleSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditExternalDecisionModalContent;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
.externalDecision {
|
||||
composes: card from '~Components/Card.css';
|
||||
|
||||
width: 290px;
|
||||
}
|
||||
|
||||
.name {
|
||||
@add-mixin truncate;
|
||||
|
||||
margin-bottom: 20px;
|
||||
font-weight: 300;
|
||||
font-size: 24px;
|
||||
}
|
||||
8
frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecision.css.d.ts
vendored
Normal file
8
frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecision.css.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
externalDecision: string;
|
||||
name: string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import TagList from 'Components/TagList';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { useTagList } from 'Tags/useTags';
|
||||
import titleCase from 'Utilities/String/titleCase';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import {
|
||||
ExternalDecisionModel,
|
||||
useDeleteExternalDecision,
|
||||
} from '../useExternalDecisions';
|
||||
import EditExternalDecisionModal from './EditExternalDecisionModal';
|
||||
import styles from './ExternalDecision.css';
|
||||
|
||||
interface ExternalDecisionProps extends ExternalDecisionModel {
|
||||
showPriority: boolean;
|
||||
}
|
||||
|
||||
function ExternalDecision({
|
||||
id,
|
||||
name,
|
||||
enable,
|
||||
decisionType,
|
||||
priority,
|
||||
tags,
|
||||
showPriority,
|
||||
}: ExternalDecisionProps) {
|
||||
const tagList = useTagList();
|
||||
const { deleteExternalDecision } = useDeleteExternalDecision(id);
|
||||
|
||||
const [isEditExternalDecisionModalOpen, setIsEditExternalDecisionModalOpen] =
|
||||
useState(false);
|
||||
const [
|
||||
isDeleteExternalDecisionModalOpen,
|
||||
setIsDeleteExternalDecisionModalOpen,
|
||||
] = useState(false);
|
||||
|
||||
const handleEditExternalDecisionPress = useCallback(() => {
|
||||
setIsEditExternalDecisionModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditExternalDecisionModalClose = useCallback(() => {
|
||||
setIsEditExternalDecisionModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleDeleteExternalDecisionPress = useCallback(() => {
|
||||
setIsEditExternalDecisionModalOpen(false);
|
||||
setIsDeleteExternalDecisionModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteExternalDecisionModalClose = useCallback(() => {
|
||||
setIsDeleteExternalDecisionModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDeleteExternalDecision = useCallback(() => {
|
||||
deleteExternalDecision();
|
||||
}, [deleteExternalDecision]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={styles.externalDecision}
|
||||
overlayContent={true}
|
||||
onPress={handleEditExternalDecisionPress}
|
||||
>
|
||||
<div className={styles.name}>{name}</div>
|
||||
|
||||
<Label kind={enable ? kinds.SUCCESS : kinds.DISABLED}>
|
||||
{translate(`ExternalDecisionType${titleCase(decisionType)}`)}
|
||||
</Label>
|
||||
|
||||
{showPriority ? (
|
||||
<Label kind={kinds.DEFAULT}>
|
||||
{translate('PrioritySettings', { priority })}
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
{enable ? null : (
|
||||
<Label kind={kinds.DISABLED} outline={true}>
|
||||
{translate('Disabled')}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<TagList tags={tags} tagList={tagList} />
|
||||
|
||||
<EditExternalDecisionModal
|
||||
id={id}
|
||||
isOpen={isEditExternalDecisionModalOpen}
|
||||
onModalClose={handleEditExternalDecisionModalClose}
|
||||
onDeleteExternalDecisionPress={handleDeleteExternalDecisionPress}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteExternalDecisionModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteExternalDecision')}
|
||||
message={translate('DeleteExternalDecisionMessageText', { name })}
|
||||
confirmLabel={translate('Delete')}
|
||||
onConfirm={handleConfirmDeleteExternalDecision}
|
||||
onCancel={handleDeleteExternalDecisionModalClose}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExternalDecision;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
.externalDecisions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.addExternalDecision {
|
||||
composes: externalDecision from '~./ExternalDecision.css';
|
||||
|
||||
background-color: var(--cardAlternateBackgroundColor);
|
||||
color: var(--gray);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: inline-block;
|
||||
padding: 5px 20px 0;
|
||||
border: 1px solid var(--borderColor);
|
||||
border-radius: 4px;
|
||||
background-color: var(--cardCenterBackgroundColor);
|
||||
}
|
||||
9
frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecisions.css.d.ts
vendored
Normal file
9
frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecisions.css.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
addExternalDecision: string;
|
||||
center: string;
|
||||
externalDecisions: string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import Card from 'Components/Card';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Icon from 'Components/Icon';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { SelectedSchema } from 'Settings/useProviderSchema';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import {
|
||||
useExternalDecisions,
|
||||
useSortedExternalDecisions,
|
||||
} from '../useExternalDecisions';
|
||||
import AddExternalDecisionModal from './AddExternalDecisionModal';
|
||||
import EditExternalDecisionModal from './EditExternalDecisionModal';
|
||||
import ExternalDecision from './ExternalDecision';
|
||||
import styles from './ExternalDecisions.css';
|
||||
|
||||
function ExternalDecisions() {
|
||||
const { error, isFetching, isFetched } = useExternalDecisions();
|
||||
const items = useSortedExternalDecisions();
|
||||
|
||||
const showPriority = items.some((item) => item.priority !== 25);
|
||||
|
||||
const [selectedSchema, setSelectedSchema] = useState<
|
||||
SelectedSchema | undefined
|
||||
>(undefined);
|
||||
|
||||
const [isAddExternalDecisionModalOpen, setIsAddExternalDecisionModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const [isEditExternalDecisionModalOpen, setIsEditExternalDecisionModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleAddExternalDecisionPress = useCallback(() => {
|
||||
setIsAddExternalDecisionModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleExternalDecisionSelect = useCallback(
|
||||
(selected: SelectedSchema) => {
|
||||
setSelectedSchema(selected);
|
||||
setIsAddExternalDecisionModalOpen(false);
|
||||
setIsEditExternalDecisionModalOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleAddExternalDecisionModalClose = useCallback(() => {
|
||||
setIsAddExternalDecisionModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleEditExternalDecisionModalClose = useCallback(() => {
|
||||
setIsEditExternalDecisionModalOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('ExternalDecisions')}>
|
||||
<PageSectionContent
|
||||
errorMessage={translate('ExternalDecisionsLoadError')}
|
||||
error={error}
|
||||
isFetching={isFetching}
|
||||
isPopulated={isFetched}
|
||||
>
|
||||
<div className={styles.externalDecisions}>
|
||||
{items.map((item) => (
|
||||
<ExternalDecision
|
||||
key={item.id}
|
||||
{...item}
|
||||
showPriority={showPriority}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Card
|
||||
className={styles.addExternalDecision}
|
||||
onPress={handleAddExternalDecisionPress}
|
||||
>
|
||||
<div className={styles.center}>
|
||||
<Icon name={icons.ADD} size={45} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<AddExternalDecisionModal
|
||||
isOpen={isAddExternalDecisionModalOpen}
|
||||
onExternalDecisionSelect={handleExternalDecisionSelect}
|
||||
onModalClose={handleAddExternalDecisionModalClose}
|
||||
/>
|
||||
|
||||
<EditExternalDecisionModal
|
||||
isOpen={isEditExternalDecisionModalOpen}
|
||||
selectedSchema={selectedSchema}
|
||||
onModalClose={handleEditExternalDecisionModalClose}
|
||||
/>
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExternalDecisions;
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { useMemo } from 'react';
|
||||
import {
|
||||
SelectedSchema,
|
||||
useProviderSchema,
|
||||
useSelectedSchema,
|
||||
} from 'Settings/useProviderSchema';
|
||||
import {
|
||||
useDeleteProvider,
|
||||
useManageProviderSettings,
|
||||
useProviderSettings,
|
||||
} from 'Settings/useProviderSettings';
|
||||
import Provider from 'typings/Provider';
|
||||
|
||||
export interface ExternalDecisionModel extends Provider {
|
||||
enable: boolean;
|
||||
decisionType: string;
|
||||
priority: number;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
const PATH = '/externaldecision';
|
||||
|
||||
export const useExternalDecision = (id: number | undefined) => {
|
||||
const { data } = useExternalDecisions();
|
||||
|
||||
if (id === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return data.find((schema) => schema.id === id);
|
||||
};
|
||||
|
||||
export const useExternalDecisionsData = () => {
|
||||
const { data } = useExternalDecisions();
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const useSortedExternalDecisions = () => {
|
||||
const { data } = useExternalDecisions();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
[...data].sort(
|
||||
(a, b) => a.priority - b.priority || a.name.localeCompare(b.name)
|
||||
),
|
||||
[data]
|
||||
);
|
||||
};
|
||||
|
||||
export const useExternalDecisions = () => {
|
||||
return useProviderSettings<ExternalDecisionModel>({
|
||||
path: PATH,
|
||||
});
|
||||
};
|
||||
|
||||
export const useManageExternalDecision = (
|
||||
id: number | undefined,
|
||||
selectedSchema?: SelectedSchema
|
||||
) => {
|
||||
const schema = useSelectedSchema<ExternalDecisionModel>(PATH, selectedSchema);
|
||||
|
||||
if (selectedSchema && !schema) {
|
||||
throw new Error(
|
||||
'A selected schema is required to manage an external decision'
|
||||
);
|
||||
}
|
||||
|
||||
const manage = useManageProviderSettings<ExternalDecisionModel>(
|
||||
id,
|
||||
selectedSchema && schema
|
||||
? {
|
||||
...schema,
|
||||
name: schema.implementationName || '',
|
||||
enable: true,
|
||||
decisionType: 'rejection',
|
||||
priority: 25,
|
||||
}
|
||||
: ({} as ExternalDecisionModel),
|
||||
PATH
|
||||
);
|
||||
|
||||
return manage;
|
||||
};
|
||||
|
||||
export const useDeleteExternalDecision = (id: number) => {
|
||||
const result = useDeleteProvider<ExternalDecisionModel>(id, PATH);
|
||||
|
||||
return {
|
||||
...result,
|
||||
deleteExternalDecision: result.deleteProvider,
|
||||
};
|
||||
};
|
||||
|
||||
export const useExternalDecisionSchema = (enabled: boolean = true) => {
|
||||
return useProviderSchema<ExternalDecisionModel>(PATH, enabled);
|
||||
};
|
||||
|
|
@ -76,6 +76,14 @@ function Settings() {
|
|||
{translate('ConnectSettingsSummary')}
|
||||
</div>
|
||||
|
||||
<Link className={styles.link} to="/settings/externaldecisions">
|
||||
{translate('ExternalDecisions')}
|
||||
</Link>
|
||||
|
||||
<div className={styles.summary}>
|
||||
{translate('ExternalDecisionSettingsSummary')}
|
||||
</div>
|
||||
|
||||
<Link className={styles.link} to="/settings/metadata">
|
||||
{translate('Metadata')}
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,452 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.DecisionEngine;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
using NzbDrone.Core.Tv;
|
||||
using NzbDrone.Test.Common;
|
||||
|
||||
using CustomFormat = NzbDrone.Core.CustomFormats.CustomFormat;
|
||||
using Language = NzbDrone.Core.Languages.Language;
|
||||
using QualityModel = NzbDrone.Core.Qualities.QualityModel;
|
||||
|
||||
namespace NzbDrone.Core.Test.DecisionEngineTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class ExternalPrioritizationServiceFixture : CoreTest<ExternalPrioritizationService>
|
||||
{
|
||||
private List<DownloadDecision> _decisions;
|
||||
private Series _series;
|
||||
private Mock<IExternalDecision> _decisionMock;
|
||||
private ExternalDecisionDefinition _decisionDefinition;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_series = new Series
|
||||
{
|
||||
Id = 1,
|
||||
TvdbId = 123456,
|
||||
Title = "Test Series",
|
||||
Tags = new HashSet<int> { 1, 2 },
|
||||
QualityProfileId = 1
|
||||
};
|
||||
|
||||
_decisions = new List<DownloadDecision>
|
||||
{
|
||||
CreateDecision("guid-1", "Release.A.S01E01.1080p.WEB-DL"),
|
||||
CreateDecision("guid-2", "Release.B.S01E01.720p.HDTV"),
|
||||
CreateDecision("guid-3", "Release.C.S01E01.1080p.Bluray")
|
||||
};
|
||||
|
||||
_decisionDefinition = new ExternalDecisionDefinition
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Test Prioritization Decision",
|
||||
DecisionType = ExternalDecisionType.Prioritization,
|
||||
Tags = new HashSet<int>(),
|
||||
Enable = true
|
||||
};
|
||||
|
||||
_decisionMock = new Mock<IExternalDecision>();
|
||||
_decisionMock.SetupGet(h => h.Definition).Returns(_decisionDefinition);
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.PrioritizationDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision>());
|
||||
}
|
||||
|
||||
private DownloadDecision CreateDecision(string guid, string title)
|
||||
{
|
||||
var remoteEpisode = new RemoteEpisode
|
||||
{
|
||||
Series = _series,
|
||||
Release = new ReleaseInfo
|
||||
{
|
||||
Guid = guid,
|
||||
Title = title,
|
||||
Indexer = "TestIndexer",
|
||||
Size = 1073741824,
|
||||
IndexerPriority = 25
|
||||
},
|
||||
ParsedEpisodeInfo = new ParsedEpisodeInfo
|
||||
{
|
||||
Quality = new QualityModel()
|
||||
},
|
||||
Episodes = new List<Episode>
|
||||
{
|
||||
new Episode
|
||||
{
|
||||
Id = 100,
|
||||
SeasonNumber = 1,
|
||||
EpisodeNumber = 1,
|
||||
Title = "Pilot"
|
||||
}
|
||||
},
|
||||
CustomFormats = new List<CustomFormat>(),
|
||||
Languages = new List<Language>()
|
||||
};
|
||||
|
||||
return new DownloadDecision(remoteEpisode);
|
||||
}
|
||||
|
||||
private void GivenDecisionReturnsScores(Dictionary<string, int> scores)
|
||||
{
|
||||
_decisionMock.Setup(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()))
|
||||
.Returns(new ExternalPrioritizationResponse { Scores = scores });
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.PrioritizationDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { _decisionMock.Object });
|
||||
}
|
||||
|
||||
private void GivenDecisionReturnsEmpty()
|
||||
{
|
||||
_decisionMock.Setup(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()))
|
||||
.Returns(new ExternalPrioritizationResponse { Scores = new Dictionary<string, int>() });
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.PrioritizationDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { _decisionMock.Object });
|
||||
}
|
||||
|
||||
private void GivenDecisionReturnsNull()
|
||||
{
|
||||
_decisionMock.Setup(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()))
|
||||
.Returns((ExternalPrioritizationResponse)null);
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.PrioritizationDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { _decisionMock.Object });
|
||||
}
|
||||
|
||||
private void GivenDecisionThrows()
|
||||
{
|
||||
_decisionMock.Setup(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()))
|
||||
.Throws(new Exception("Connection timeout"));
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.PrioritizationDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { _decisionMock.Object });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_set_scores_when_no_decisions_enabled()
|
||||
{
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
_decisions.Should().OnlyContain(d => d.RemoteEpisode.ExternalPriorityScore == 0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_assign_scores_from_decision_response()
|
||||
{
|
||||
GivenDecisionReturnsScores(new Dictionary<string, int>
|
||||
{
|
||||
{ "guid-3", 100 },
|
||||
{ "guid-1", 50 },
|
||||
{ "guid-2", 75 }
|
||||
});
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-3").RemoteEpisode.ExternalPriorityScore.Should().Be(100);
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-1").RemoteEpisode.ExternalPriorityScore.Should().Be(50);
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-2").RemoteEpisode.ExternalPriorityScore.Should().Be(75);
|
||||
|
||||
_decisionMock.Verify(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_keep_zero_scores_when_decision_returns_empty()
|
||||
{
|
||||
GivenDecisionReturnsEmpty();
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
_decisions.Should().OnlyContain(d => d.RemoteEpisode.ExternalPriorityScore == 0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_keep_zero_scores_when_decision_returns_null()
|
||||
{
|
||||
GivenDecisionReturnsNull();
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
_decisions.Should().OnlyContain(d => d.RemoteEpisode.ExternalPriorityScore == 0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_keep_zero_scores_on_decision_exception()
|
||||
{
|
||||
GivenDecisionThrows();
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
_decisions.Should().OnlyContain(d => d.RemoteEpisode.ExternalPriorityScore == 0);
|
||||
|
||||
_decisionMock.Verify(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()), Times.Once);
|
||||
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_record_failure_on_exception()
|
||||
{
|
||||
GivenDecisionThrows();
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
Mocker.GetMock<IExternalDecisionStatusService>()
|
||||
.Verify(s => s.RecordFailure(1), Times.Once);
|
||||
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_record_success_on_score_assignment()
|
||||
{
|
||||
GivenDecisionReturnsScores(new Dictionary<string, int> { { "guid-3", 100 }, { "guid-1", 50 }, { "guid-2", 75 } });
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
Mocker.GetMock<IExternalDecisionStatusService>()
|
||||
.Verify(s => s.RecordSuccess(1), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_assign_zero_score_to_guids_missing_from_response()
|
||||
{
|
||||
GivenDecisionReturnsScores(new Dictionary<string, int> { { "guid-3", 42 } });
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-3").RemoteEpisode.ExternalPriorityScore.Should().Be(42);
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-1").RemoteEpisode.ExternalPriorityScore.Should().Be(0);
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-2").RemoteEpisode.ExternalPriorityScore.Should().Be(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_ignore_unknown_guids_in_response()
|
||||
{
|
||||
GivenDecisionReturnsScores(new Dictionary<string, int>
|
||||
{
|
||||
{ "unknown-guid", 999 },
|
||||
{ "guid-2", 80 },
|
||||
{ "guid-1", 60 }
|
||||
});
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-2").RemoteEpisode.ExternalPriorityScore.Should().Be(80);
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-1").RemoteEpisode.ExternalPriorityScore.Should().Be(60);
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-3").RemoteEpisode.ExternalPriorityScore.Should().Be(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_chain_multiple_decisions_with_last_decision_scores_winning()
|
||||
{
|
||||
var decision1 = new Mock<IExternalDecision>();
|
||||
var definition1 = new ExternalDecisionDefinition
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Decision 1",
|
||||
DecisionType = ExternalDecisionType.Prioritization,
|
||||
Tags = new HashSet<int>(),
|
||||
Enable = true
|
||||
};
|
||||
decision1.SetupGet(h => h.Definition).Returns(definition1);
|
||||
decision1.Setup(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()))
|
||||
.Returns(new ExternalPrioritizationResponse { Scores = new Dictionary<string, int> { { "guid-3", 100 }, { "guid-2", 50 }, { "guid-1", 25 } } });
|
||||
|
||||
var decision2 = new Mock<IExternalDecision>();
|
||||
var definition2 = new ExternalDecisionDefinition
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Decision 2",
|
||||
DecisionType = ExternalDecisionType.Prioritization,
|
||||
Tags = new HashSet<int>(),
|
||||
Enable = true
|
||||
};
|
||||
decision2.SetupGet(h => h.Definition).Returns(definition2);
|
||||
decision2.Setup(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()))
|
||||
.Returns(new ExternalPrioritizationResponse { Scores = new Dictionary<string, int> { { "guid-1", 200 }, { "guid-3", 150 }, { "guid-2", 10 } } });
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.PrioritizationDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { decision1.Object, decision2.Object });
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
// Decision 2's scores should be final (overwrites decision 1)
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-1").RemoteEpisode.ExternalPriorityScore.Should().Be(200);
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-3").RemoteEpisode.ExternalPriorityScore.Should().Be(150);
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-2").RemoteEpisode.ExternalPriorityScore.Should().Be(10);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_set_scores_on_decisions_without_series()
|
||||
{
|
||||
var noSeriesDecision = new DownloadDecision(new RemoteEpisode
|
||||
{
|
||||
Series = null,
|
||||
Release = new ReleaseInfo { Guid = "guid-noseries", Title = "Unknown" },
|
||||
ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel() },
|
||||
Episodes = new List<Episode>(),
|
||||
CustomFormats = new List<CustomFormat>(),
|
||||
Languages = new List<Language>()
|
||||
});
|
||||
|
||||
var allDecisions = _decisions.Concat(new[] { noSeriesDecision }).ToList();
|
||||
|
||||
GivenDecisionReturnsScores(new Dictionary<string, int> { { "guid-3", 100 }, { "guid-1", 50 }, { "guid-2", 75 } });
|
||||
|
||||
Subject.PopulateExternalPriorityScores(allDecisions);
|
||||
|
||||
noSeriesDecision.RemoteEpisode.ExternalPriorityScore.Should().Be(0);
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-3").RemoteEpisode.ExternalPriorityScore.Should().Be(100);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_send_all_releases_in_request()
|
||||
{
|
||||
ExternalPrioritizationRequest capturedRequest = null;
|
||||
|
||||
_decisionMock.Setup(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()))
|
||||
.Callback<ExternalPrioritizationRequest>(r => capturedRequest = r)
|
||||
.Returns(new ExternalPrioritizationResponse { Scores = new Dictionary<string, int>() });
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.PrioritizationDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { _decisionMock.Object });
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest.DecisionType.Should().Be("Prioritization");
|
||||
capturedRequest.Releases.Should().HaveCount(3);
|
||||
capturedRequest.Series.Should().NotBeNull();
|
||||
capturedRequest.Series.Id.Should().Be(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_continue_chain_when_first_decision_throws()
|
||||
{
|
||||
var decision1 = new Mock<IExternalDecision>();
|
||||
var definition1 = new ExternalDecisionDefinition
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Decision 1",
|
||||
DecisionType = ExternalDecisionType.Prioritization,
|
||||
Tags = new HashSet<int>(),
|
||||
Enable = true
|
||||
};
|
||||
decision1.SetupGet(h => h.Definition).Returns(definition1);
|
||||
decision1.Setup(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()))
|
||||
.Throws(new Exception("Decision 1 failure"));
|
||||
|
||||
var decision2 = new Mock<IExternalDecision>();
|
||||
var definition2 = new ExternalDecisionDefinition
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Decision 2",
|
||||
DecisionType = ExternalDecisionType.Prioritization,
|
||||
Tags = new HashSet<int>(),
|
||||
Enable = true
|
||||
};
|
||||
decision2.SetupGet(h => h.Definition).Returns(definition2);
|
||||
decision2.Setup(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()))
|
||||
.Returns(new ExternalPrioritizationResponse { Scores = new Dictionary<string, int> { { "guid-3", 100 }, { "guid-1", 50 }, { "guid-2", 75 } } });
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.PrioritizationDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { decision1.Object, decision2.Object });
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
// Decision 2 should still run and set scores despite Decision 1 failure
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-3").RemoteEpisode.ExternalPriorityScore.Should().Be(100);
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-1").RemoteEpisode.ExternalPriorityScore.Should().Be(50);
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-2").RemoteEpisode.ExternalPriorityScore.Should().Be(75);
|
||||
|
||||
decision1.Verify(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()), Times.Once);
|
||||
decision2.Verify(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()), Times.Once);
|
||||
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_skip_decision_when_tags_dont_match()
|
||||
{
|
||||
_decisionDefinition.Tags = new HashSet<int> { 99 };
|
||||
|
||||
_decisionMock.Setup(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()))
|
||||
.Returns(new ExternalPrioritizationResponse { Scores = new Dictionary<string, int> { { "guid-3", 100 }, { "guid-1", 50 }, { "guid-2", 75 } } });
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.PrioritizationDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { _decisionMock.Object });
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
_decisions.Should().OnlyContain(d => d.RemoteEpisode.ExternalPriorityScore == 0);
|
||||
|
||||
_decisionMock.Verify(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()), Times.Never);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_apply_decision_when_decision_has_no_tags()
|
||||
{
|
||||
_decisionDefinition.Tags = new HashSet<int>();
|
||||
|
||||
GivenDecisionReturnsScores(new Dictionary<string, int> { { "guid-3", 100 }, { "guid-1", 50 }, { "guid-2", 75 } });
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-3").RemoteEpisode.ExternalPriorityScore.Should().Be(100);
|
||||
|
||||
_decisionMock.Verify(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_apply_decision_when_tags_intersect()
|
||||
{
|
||||
_decisionDefinition.Tags = new HashSet<int> { 2, 5 };
|
||||
|
||||
GivenDecisionReturnsScores(new Dictionary<string, int> { { "guid-3", 100 }, { "guid-1", 50 }, { "guid-2", 75 } });
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
_decisions.Single(d => d.RemoteEpisode.Release.Guid == "guid-3").RemoteEpisode.ExternalPriorityScore.Should().Be(100);
|
||||
|
||||
_decisionMock.Verify(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_skip_decision_when_series_has_no_tags_and_decision_has_tags()
|
||||
{
|
||||
_decisionDefinition.Tags = new HashSet<int> { 1 };
|
||||
_series.Tags = new HashSet<int>();
|
||||
|
||||
_decisionMock.Setup(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()))
|
||||
.Returns(new ExternalPrioritizationResponse { Scores = new Dictionary<string, int> { { "guid-3", 100 }, { "guid-1", 50 }, { "guid-2", 75 } } });
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.PrioritizationDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { _decisionMock.Object });
|
||||
|
||||
Subject.PopulateExternalPriorityScores(_decisions);
|
||||
|
||||
_decisions.Should().OnlyContain(d => d.RemoteEpisode.ExternalPriorityScore == 0);
|
||||
|
||||
_decisionMock.Verify(h => h.EvaluatePrioritization(It.IsAny<ExternalPrioritizationRequest>()), Times.Never);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.DecisionEngine;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads;
|
||||
using NzbDrone.Core.DecisionEngine.Specifications;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
using NzbDrone.Core.Tv;
|
||||
using NzbDrone.Test.Common;
|
||||
|
||||
using CustomFormat = NzbDrone.Core.CustomFormats.CustomFormat;
|
||||
using Language = NzbDrone.Core.Languages.Language;
|
||||
using QualityModel = NzbDrone.Core.Qualities.QualityModel;
|
||||
|
||||
namespace NzbDrone.Core.Test.DecisionEngineTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class ExternalRejectionSpecificationFixture : CoreTest<ExternalRejectionSpecification>
|
||||
{
|
||||
private RemoteEpisode _remoteEpisode;
|
||||
private Mock<IExternalDecision> _decisionMock;
|
||||
private ExternalDecisionDefinition _decisionDefinition;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_remoteEpisode = new RemoteEpisode
|
||||
{
|
||||
Series = new Series
|
||||
{
|
||||
Id = 1,
|
||||
TvdbId = 123456,
|
||||
Title = "Test Series",
|
||||
Tags = new HashSet<int> { 1, 2 },
|
||||
QualityProfileId = 1
|
||||
},
|
||||
Release = new ReleaseInfo
|
||||
{
|
||||
Guid = "test-guid",
|
||||
Title = "Test.Series.S01E01.1080p.WEB-DL",
|
||||
Indexer = "TestIndexer",
|
||||
Size = 1073741824,
|
||||
IndexerPriority = 25
|
||||
},
|
||||
ParsedEpisodeInfo = new ParsedEpisodeInfo
|
||||
{
|
||||
Quality = new QualityModel()
|
||||
},
|
||||
Episodes = new List<Episode>
|
||||
{
|
||||
new Episode
|
||||
{
|
||||
Id = 100,
|
||||
SeasonNumber = 1,
|
||||
EpisodeNumber = 1,
|
||||
Title = "Pilot"
|
||||
}
|
||||
},
|
||||
CustomFormats = new List<CustomFormat>(),
|
||||
Languages = new List<Language>()
|
||||
};
|
||||
|
||||
_decisionDefinition = new ExternalDecisionDefinition
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Test Decision",
|
||||
DecisionType = ExternalDecisionType.Rejection,
|
||||
Tags = new HashSet<int>(),
|
||||
Enable = true
|
||||
};
|
||||
|
||||
_decisionMock = new Mock<IExternalDecision>();
|
||||
_decisionMock.SetupGet(h => h.Definition).Returns(_decisionDefinition);
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.RejectionDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision>());
|
||||
}
|
||||
|
||||
private void GivenHookApproves()
|
||||
{
|
||||
_decisionMock.Setup(h => h.EvaluateRejection(It.IsAny<ExternalRejectionRequest>()))
|
||||
.Returns(new ExternalRejectionResponse { Approved = true });
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.RejectionDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { _decisionMock.Object });
|
||||
}
|
||||
|
||||
private void GivenHookRejects(string reason)
|
||||
{
|
||||
_decisionMock.Setup(h => h.EvaluateRejection(It.IsAny<ExternalRejectionRequest>()))
|
||||
.Returns(new ExternalRejectionResponse { Approved = false, Reason = reason });
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.RejectionDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { _decisionMock.Object });
|
||||
}
|
||||
|
||||
private void GivenHookThrows()
|
||||
{
|
||||
_decisionMock.Setup(h => h.EvaluateRejection(It.IsAny<ExternalRejectionRequest>()))
|
||||
.Throws(new Exception("Connection timeout"));
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.RejectionDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { _decisionMock.Object });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_accept_when_no_decisions_enabled()
|
||||
{
|
||||
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_accept_when_decision_approves_release()
|
||||
{
|
||||
GivenHookApproves();
|
||||
|
||||
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_reject_when_decision_rejects_release()
|
||||
{
|
||||
GivenHookRejects("Release contains unwanted format");
|
||||
|
||||
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_accept_when_decision_times_out()
|
||||
{
|
||||
GivenHookThrows();
|
||||
|
||||
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue();
|
||||
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_accept_when_decision_returns_error()
|
||||
{
|
||||
GivenHookThrows();
|
||||
|
||||
var result = Subject.IsSatisfiedBy(_remoteEpisode, new());
|
||||
|
||||
result.Accepted.Should().BeTrue();
|
||||
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_skip_decision_when_tags_dont_match()
|
||||
{
|
||||
_decisionDefinition.Tags = new HashSet<int> { 99 };
|
||||
|
||||
_decisionMock.Setup(h => h.EvaluateRejection(It.IsAny<ExternalRejectionRequest>()))
|
||||
.Returns(new ExternalRejectionResponse { Approved = false, Reason = "Should not reach" });
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.RejectionDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { _decisionMock.Object });
|
||||
|
||||
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue();
|
||||
|
||||
_decisionMock.Verify(h => h.EvaluateRejection(It.IsAny<ExternalRejectionRequest>()), Times.Never);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_apply_decision_when_decision_has_no_tags()
|
||||
{
|
||||
_decisionDefinition.Tags = new HashSet<int>();
|
||||
|
||||
GivenHookRejects("No tags means applies to all");
|
||||
|
||||
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_apply_decision_when_tags_intersect()
|
||||
{
|
||||
_decisionDefinition.Tags = new HashSet<int> { 2, 5 };
|
||||
|
||||
GivenHookRejects("Matching tag 2");
|
||||
|
||||
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_reject_when_any_decision_rejects()
|
||||
{
|
||||
var decision1 = new Mock<IExternalDecision>();
|
||||
var definition1 = new ExternalDecisionDefinition
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Decision 1",
|
||||
DecisionType = ExternalDecisionType.Rejection,
|
||||
Tags = new HashSet<int>(),
|
||||
Enable = true
|
||||
};
|
||||
decision1.SetupGet(h => h.Definition).Returns(definition1);
|
||||
decision1.Setup(h => h.EvaluateRejection(It.IsAny<ExternalRejectionRequest>()))
|
||||
.Returns(new ExternalRejectionResponse { Approved = true });
|
||||
|
||||
var decision2 = new Mock<IExternalDecision>();
|
||||
var definition2 = new ExternalDecisionDefinition
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Decision 2",
|
||||
DecisionType = ExternalDecisionType.Rejection,
|
||||
Tags = new HashSet<int>(),
|
||||
Enable = true
|
||||
};
|
||||
decision2.SetupGet(h => h.Definition).Returns(definition2);
|
||||
decision2.Setup(h => h.EvaluateRejection(It.IsAny<ExternalRejectionRequest>()))
|
||||
.Returns(new ExternalRejectionResponse { Approved = false, Reason = "Rejected by decision 2" });
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.RejectionDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { decision1.Object, decision2.Object });
|
||||
|
||||
var result = Subject.IsSatisfiedBy(_remoteEpisode, new());
|
||||
|
||||
result.Accepted.Should().BeFalse();
|
||||
decision1.Verify(h => h.EvaluateRejection(It.IsAny<ExternalRejectionRequest>()), Times.Once);
|
||||
decision2.Verify(h => h.EvaluateRejection(It.IsAny<ExternalRejectionRequest>()), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_include_reason_from_hook_response()
|
||||
{
|
||||
GivenHookRejects("RAR archive detected");
|
||||
|
||||
var result = Subject.IsSatisfiedBy(_remoteEpisode, new());
|
||||
|
||||
result.Accepted.Should().BeFalse();
|
||||
result.Reason.Should().Be(DownloadRejectionReason.ExternalRejection);
|
||||
result.Message.Should().Be("External: RAR archive detected");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_have_external_priority()
|
||||
{
|
||||
Subject.Priority.Should().Be(SpecificationPriority.External);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_record_failure_on_exception()
|
||||
{
|
||||
GivenHookThrows();
|
||||
|
||||
Subject.IsSatisfiedBy(_remoteEpisode, new());
|
||||
|
||||
Mocker.GetMock<IExternalDecisionStatusService>()
|
||||
.Verify(s => s.RecordFailure(1), Times.Once);
|
||||
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_record_success_on_approval()
|
||||
{
|
||||
GivenHookApproves();
|
||||
|
||||
Subject.IsSatisfiedBy(_remoteEpisode, new());
|
||||
|
||||
Mocker.GetMock<IExternalDecisionStatusService>()
|
||||
.Verify(s => s.RecordSuccess(1), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_include_release_and_series_data_in_request()
|
||||
{
|
||||
ExternalRejectionRequest capturedRequest = null;
|
||||
|
||||
_decisionMock.Setup(h => h.EvaluateRejection(It.IsAny<ExternalRejectionRequest>()))
|
||||
.Callback<ExternalRejectionRequest>(r => capturedRequest = r)
|
||||
.Returns(new ExternalRejectionResponse { Approved = true });
|
||||
|
||||
Mocker.GetMock<IExternalDecisionFactory>()
|
||||
.Setup(f => f.RejectionDecisionsEnabled())
|
||||
.Returns(new List<IExternalDecision> { _decisionMock.Object });
|
||||
|
||||
Subject.IsSatisfiedBy(_remoteEpisode, new());
|
||||
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest.DecisionType.Should().Be("Rejection");
|
||||
capturedRequest.Release.Should().NotBeNull();
|
||||
capturedRequest.Release.Guid.Should().Be("test-guid");
|
||||
capturedRequest.Release.Title.Should().Be("Test.Series.S01E01.1080p.WEB-DL");
|
||||
capturedRequest.Series.Should().NotBeNull();
|
||||
capturedRequest.Series.Id.Should().Be(1);
|
||||
capturedRequest.Series.TvdbId.Should().Be(123456);
|
||||
capturedRequest.Episodes.Should().HaveCount(1);
|
||||
capturedRequest.Episodes[0].SeasonNumber.Should().Be(1);
|
||||
capturedRequest.Episodes[0].EpisodeNumber.Should().Be(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_skip_decision_when_series_has_no_tags_and_hook_has_tags()
|
||||
{
|
||||
_decisionDefinition.Tags = new HashSet<int> { 1 };
|
||||
_remoteEpisode.Series.Tags = new HashSet<int>();
|
||||
|
||||
GivenHookRejects("Should not reach");
|
||||
|
||||
Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue();
|
||||
|
||||
_decisionMock.Verify(h => h.EvaluateRejection(It.IsAny<ExternalRejectionRequest>()), Times.Never);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_record_success_on_rejection()
|
||||
{
|
||||
GivenHookRejects("Some reason");
|
||||
|
||||
Subject.IsSatisfiedBy(_remoteEpisode, new());
|
||||
|
||||
Mocker.GetMock<IExternalDecisionStatusService>()
|
||||
.Verify(s => s.RecordSuccess(1), Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.DecisionEngine;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions;
|
||||
using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
|
|
@ -29,6 +30,9 @@ public void Setup()
|
|||
{
|
||||
GivenPreferredDownloadProtocol(DownloadProtocol.Usenet);
|
||||
|
||||
Mocker.GetMock<IExternalPrioritizationService>()
|
||||
.Setup(s => s.PopulateExternalPriorityScores(It.IsAny<List<DownloadDecision>>()));
|
||||
|
||||
_series = Builder<Series>.CreateNew()
|
||||
.With(e => e.Runtime = 60)
|
||||
.With(e => e.QualityProfile = new QualityProfile
|
||||
|
|
|
|||
|
|
@ -0,0 +1,337 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Common.Serializer;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
using NzbDrone.Test.Common;
|
||||
|
||||
namespace NzbDrone.Core.Test.DecisionEngineTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class WebhookExternalDecisionProxyFixture : CoreTest<WebhookExternalDecisionProxy>
|
||||
{
|
||||
private WebhookExternalDecisionSettings _settings;
|
||||
private ExternalRejectionRequest _payload;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_settings = new WebhookExternalDecisionSettings
|
||||
{
|
||||
Url = "http://decision.local/rejection",
|
||||
ApiKey = "test-api-key",
|
||||
Timeout = 10
|
||||
};
|
||||
|
||||
_payload = new ExternalRejectionRequest
|
||||
{
|
||||
DecisionType = "Rejection",
|
||||
Release = new ExternalReleasePayload
|
||||
{
|
||||
Guid = "test-guid",
|
||||
Title = "Test.Series.S01E01.1080p.WEB-DL",
|
||||
Indexer = "TestIndexer",
|
||||
Size = 1073741824,
|
||||
Protocol = "usenet",
|
||||
Age = 1,
|
||||
IndexerPriority = 25
|
||||
},
|
||||
Series = new ExternalSeriesPayload
|
||||
{
|
||||
Id = 1,
|
||||
TvdbId = 123456,
|
||||
Title = "Test Series"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void GivenSuccessfulResponse(string json)
|
||||
{
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Post(It.IsAny<HttpRequest>()))
|
||||
.Returns<HttpRequest>(r => new HttpResponse(r, new HttpHeader(), json, HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private void GivenEmptyResponse()
|
||||
{
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Post(It.IsAny<HttpRequest>()))
|
||||
.Returns<HttpRequest>(r => new HttpResponse(r, new HttpHeader(), string.Empty, HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private void GivenNoContentResponse()
|
||||
{
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Post(It.IsAny<HttpRequest>()))
|
||||
.Returns<HttpRequest>(r => new HttpResponse(r, new HttpHeader(), Array.Empty<byte>(), HttpStatusCode.NoContent));
|
||||
}
|
||||
|
||||
private void GivenErrorResponse(HttpStatusCode statusCode)
|
||||
{
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Post(It.IsAny<HttpRequest>()))
|
||||
.Returns<HttpRequest>(r => new HttpResponse(r, new HttpHeader(), "error", statusCode));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_send_post_request()
|
||||
{
|
||||
GivenSuccessfulResponse("{\"approved\": true}");
|
||||
|
||||
Subject.SendRejectionRequest(_payload, _settings);
|
||||
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Verify(c => c.Post(It.IsAny<HttpRequest>()), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_set_api_key_header()
|
||||
{
|
||||
HttpRequest capturedRequest = null;
|
||||
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Post(It.IsAny<HttpRequest>()))
|
||||
.Callback<HttpRequest>(r => capturedRequest = r)
|
||||
.Returns<HttpRequest>(r => new HttpResponse(r, new HttpHeader(), "{\"approved\": true}", HttpStatusCode.OK));
|
||||
|
||||
Subject.SendRejectionRequest(_payload, _settings);
|
||||
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest.Headers.GetSingleValue("X-Api-Key").Should().Be("test-api-key");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_set_api_key_header_when_empty()
|
||||
{
|
||||
_settings.ApiKey = null;
|
||||
HttpRequest capturedRequest = null;
|
||||
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Post(It.IsAny<HttpRequest>()))
|
||||
.Callback<HttpRequest>(r => capturedRequest = r)
|
||||
.Returns<HttpRequest>(r => new HttpResponse(r, new HttpHeader(), "{\"approved\": true}", HttpStatusCode.OK));
|
||||
|
||||
Subject.SendRejectionRequest(_payload, _settings);
|
||||
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest.Headers.ContainsKey("X-Api-Key").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_set_request_timeout()
|
||||
{
|
||||
HttpRequest capturedRequest = null;
|
||||
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Post(It.IsAny<HttpRequest>()))
|
||||
.Callback<HttpRequest>(r => capturedRequest = r)
|
||||
.Returns<HttpRequest>(r => new HttpResponse(r, new HttpHeader(), "{\"approved\": true}", HttpStatusCode.OK));
|
||||
|
||||
Subject.SendRejectionRequest(_payload, _settings);
|
||||
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest.RequestTimeout.Should().Be(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_set_content_type_json()
|
||||
{
|
||||
HttpRequest capturedRequest = null;
|
||||
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Post(It.IsAny<HttpRequest>()))
|
||||
.Callback<HttpRequest>(r => capturedRequest = r)
|
||||
.Returns<HttpRequest>(r => new HttpResponse(r, new HttpHeader(), "{\"approved\": true}", HttpStatusCode.OK));
|
||||
|
||||
Subject.SendRejectionRequest(_payload, _settings);
|
||||
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest.Headers.ContentType.Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_deserialize_approved_response()
|
||||
{
|
||||
GivenSuccessfulResponse("{\"approved\": true}");
|
||||
|
||||
var result = Subject.SendRejectionRequest(_payload, _settings);
|
||||
|
||||
result.Approved.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_deserialize_rejected_response_with_reason()
|
||||
{
|
||||
GivenSuccessfulResponse("{\"approved\": false, \"reason\": \"RAR archive detected\"}");
|
||||
|
||||
var result = Subject.SendRejectionRequest(_payload, _settings);
|
||||
|
||||
result.Approved.Should().BeFalse();
|
||||
result.Reason.Should().Be("RAR archive detected");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_treat_empty_body_as_approved()
|
||||
{
|
||||
GivenEmptyResponse();
|
||||
|
||||
var result = Subject.SendRejectionRequest(_payload, _settings);
|
||||
|
||||
result.Approved.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_treat_no_content_response_as_approved()
|
||||
{
|
||||
GivenNoContentResponse();
|
||||
|
||||
var result = Subject.SendRejectionRequest(_payload, _settings);
|
||||
|
||||
result.Approved.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_treat_unexpected_status_code_as_approved()
|
||||
{
|
||||
GivenErrorResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
var result = Subject.SendRejectionRequest(_payload, _settings);
|
||||
|
||||
result.Approved.Should().BeTrue();
|
||||
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_treat_bad_request_as_approved()
|
||||
{
|
||||
GivenErrorResponse(HttpStatusCode.BadRequest);
|
||||
|
||||
var result = Subject.SendRejectionRequest(_payload, _settings);
|
||||
|
||||
result.Approved.Should().BeTrue();
|
||||
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_send_request_to_configured_url()
|
||||
{
|
||||
HttpRequest capturedRequest = null;
|
||||
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Post(It.IsAny<HttpRequest>()))
|
||||
.Callback<HttpRequest>(r => capturedRequest = r)
|
||||
.Returns<HttpRequest>(r => new HttpResponse(r, new HttpHeader(), "{\"approved\": true}", HttpStatusCode.OK));
|
||||
|
||||
Subject.SendRejectionRequest(_payload, _settings);
|
||||
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest.Url.ToString().Should().StartWith("http://decision.local/rejection");
|
||||
}
|
||||
|
||||
// Prioritization proxy tests
|
||||
|
||||
[Test]
|
||||
public void should_return_scores_from_prioritization_response()
|
||||
{
|
||||
GivenSuccessfulResponse("{\"scores\": {\"guid-3\": 100, \"guid-1\": 50, \"guid-2\": 75}}");
|
||||
|
||||
var request = new ExternalPrioritizationRequest { DecisionType = "Prioritization" };
|
||||
var result = Subject.SendPrioritizationRequest(request, _settings);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.Scores.Should().ContainKey("guid-3").WhoseValue.Should().Be(100);
|
||||
result.Scores.Should().ContainKey("guid-1").WhoseValue.Should().Be(50);
|
||||
result.Scores.Should().ContainKey("guid-2").WhoseValue.Should().Be(75);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_null_for_prioritization_on_timeout()
|
||||
{
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Post(It.IsAny<HttpRequest>()))
|
||||
.Throws(new HttpRequestException("Connection timeout"));
|
||||
|
||||
var request = new ExternalPrioritizationRequest { DecisionType = "Prioritization" };
|
||||
var result = Subject.SendPrioritizationRequest(request, _settings);
|
||||
|
||||
result.Should().BeNull();
|
||||
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_null_for_prioritization_on_server_error()
|
||||
{
|
||||
GivenErrorResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
var request = new ExternalPrioritizationRequest { DecisionType = "Prioritization" };
|
||||
var result = Subject.SendPrioritizationRequest(request, _settings);
|
||||
|
||||
result.Should().BeNull();
|
||||
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_null_for_prioritization_on_empty_response()
|
||||
{
|
||||
GivenEmptyResponse();
|
||||
|
||||
var request = new ExternalPrioritizationRequest { DecisionType = "Prioritization" };
|
||||
var result = Subject.SendPrioritizationRequest(request, _settings);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_include_payload_data_in_request_body()
|
||||
{
|
||||
HttpRequest capturedRequest = null;
|
||||
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Post(It.IsAny<HttpRequest>()))
|
||||
.Callback<HttpRequest>(r => capturedRequest = r)
|
||||
.Returns<HttpRequest>(r => new HttpResponse(r, new HttpHeader(), "{\"approved\": true}", HttpStatusCode.OK));
|
||||
|
||||
Subject.SendRejectionRequest(_payload, _settings);
|
||||
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest.ContentData.Should().NotBeEmpty();
|
||||
|
||||
var bodyJson = Encoding.UTF8.GetString(capturedRequest.ContentData);
|
||||
var deserialized = Json.Deserialize<ExternalRejectionRequest>(bodyJson);
|
||||
|
||||
deserialized.DecisionType.Should().Be("Rejection");
|
||||
deserialized.Release.Guid.Should().Be("test-guid");
|
||||
deserialized.Release.Title.Should().Be("Test.Series.S01E01.1080p.WEB-DL");
|
||||
deserialized.Series.Id.Should().Be(1);
|
||||
deserialized.Series.TvdbId.Should().Be(123456);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_set_api_key_header_when_empty_string()
|
||||
{
|
||||
_settings.ApiKey = "";
|
||||
HttpRequest capturedRequest = null;
|
||||
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Post(It.IsAny<HttpRequest>()))
|
||||
.Callback<HttpRequest>(r => capturedRequest = r)
|
||||
.Returns<HttpRequest>(r => new HttpResponse(r, new HttpHeader(), "{\"approved\": true}", HttpStatusCode.OK));
|
||||
|
||||
Subject.SendRejectionRequest(_payload, _settings);
|
||||
|
||||
capturedRequest.Should().NotBeNull();
|
||||
capturedRequest.Headers.ContainsKey("X-Api-Key").Should().BeFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
using FluentMigrator;
|
||||
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Datastore.Migration;
|
||||
|
||||
[Migration(228)]
|
||||
public class add_external_decisions : NzbDroneMigrationBase
|
||||
{
|
||||
protected override void MainDbUpgrade()
|
||||
{
|
||||
Create.TableForModel("ExternalDecisions")
|
||||
.WithColumn("Enable").AsBoolean().NotNullable()
|
||||
.WithColumn("Name").AsString().NotNullable()
|
||||
.WithColumn("Implementation").AsString().NotNullable()
|
||||
.WithColumn("Settings").AsString().NotNullable()
|
||||
.WithColumn("ConfigContract").AsString().NotNullable()
|
||||
.WithColumn("DecisionType").AsInt32().NotNullable()
|
||||
.WithColumn("Priority").AsInt32().NotNullable().WithDefaultValue(25)
|
||||
.WithColumn("Tags").AsString().Nullable();
|
||||
|
||||
Create.TableForModel("ExternalDecisionStatus")
|
||||
.WithColumn("ProviderId").AsInt32().NotNullable().Unique()
|
||||
.WithColumn("InitialFailure").AsDateTimeOffset().Nullable()
|
||||
.WithColumn("MostRecentFailure").AsDateTimeOffset().Nullable()
|
||||
.WithColumn("EscalationLevel").AsInt32().NotNullable()
|
||||
.WithColumn("DisabledTill").AsDateTimeOffset().Nullable();
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
using NzbDrone.Core.CustomFormats;
|
||||
using NzbDrone.Core.DataAugmentation.Scene;
|
||||
using NzbDrone.Core.Datastore.Converters;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions;
|
||||
using NzbDrone.Core.Download;
|
||||
using NzbDrone.Core.Download.History;
|
||||
using NzbDrone.Core.Download.Pending;
|
||||
|
|
@ -174,6 +175,11 @@ public static void Map()
|
|||
Mapper.Entity<ImportListExclusion>("ImportListExclusions").RegisterModel();
|
||||
|
||||
Mapper.Entity<AutoTagging.AutoTag>("AutoTagging").RegisterModel();
|
||||
|
||||
Mapper.Entity<ExternalDecisionDefinition>("ExternalDecisions").RegisterModel()
|
||||
.Ignore(x => x.ImplementationName);
|
||||
|
||||
Mapper.Entity<ExternalDecisionStatus>("ExternalDecisionStatus").RegisterModel();
|
||||
}
|
||||
|
||||
private static void RegisterMappers()
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ public int Compare(DownloadDecision x, DownloadDecision y)
|
|||
{
|
||||
CompareQuality,
|
||||
CompareCustomFormatScore,
|
||||
CompareExternalPriorityScore,
|
||||
CompareProtocol,
|
||||
CompareEpisodeCount,
|
||||
CompareEpisodeNumber,
|
||||
|
|
@ -85,6 +86,11 @@ private int CompareCustomFormatScore(DownloadDecision x, DownloadDecision y)
|
|||
return CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteMovie => remoteMovie.CustomFormatScore);
|
||||
}
|
||||
|
||||
private int CompareExternalPriorityScore(DownloadDecision x, DownloadDecision y)
|
||||
{
|
||||
return CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => remoteEpisode.ExternalPriorityScore);
|
||||
}
|
||||
|
||||
private int CompareProtocol(DownloadDecision x, DownloadDecision y)
|
||||
{
|
||||
var result = CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode =>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions;
|
||||
using NzbDrone.Core.Profiles.Delay;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine
|
||||
|
|
@ -14,15 +15,19 @@ public class DownloadDecisionPriorizationService : IPrioritizeDownloadDecision
|
|||
{
|
||||
private readonly IConfigService _configService;
|
||||
private readonly IDelayProfileService _delayProfileService;
|
||||
private readonly IExternalPrioritizationService _externalPrioritizationService;
|
||||
|
||||
public DownloadDecisionPriorizationService(IConfigService configService, IDelayProfileService delayProfileService)
|
||||
public DownloadDecisionPriorizationService(IConfigService configService, IDelayProfileService delayProfileService, IExternalPrioritizationService externalPrioritizationService)
|
||||
{
|
||||
_configService = configService;
|
||||
_delayProfileService = delayProfileService;
|
||||
_externalPrioritizationService = externalPrioritizationService;
|
||||
}
|
||||
|
||||
public List<DownloadDecision> PrioritizeDecisions(List<DownloadDecision> decisions)
|
||||
{
|
||||
_externalPrioritizationService.PopulateExternalPriorityScores(decisions);
|
||||
|
||||
return decisions.Where(c => c.RemoteEpisode.Series != null)
|
||||
.GroupBy(c => c.RemoteEpisode.Series.Id, (seriesId, downloadDecisions) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -76,5 +76,6 @@ public enum DownloadRejectionReason
|
|||
DiskCustomFormatScoreIncrement,
|
||||
DiskUpgradesNotAllowed,
|
||||
DiskNotUpgrade,
|
||||
BeforeAirDate
|
||||
BeforeAirDate,
|
||||
ExternalRejection
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentValidation.Results;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads;
|
||||
using NzbDrone.Core.ThingiProvider;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
|
||||
{
|
||||
public abstract class ExternalDecisionBase<TSettings> : IExternalDecision
|
||||
where TSettings : ExternalDecisionSettingsBase<TSettings>, new()
|
||||
{
|
||||
public abstract string Name { get; }
|
||||
public Type ConfigContract => typeof(TSettings);
|
||||
public virtual ProviderMessage Message => null;
|
||||
public IEnumerable<ProviderDefinition> DefaultDefinitions => new List<ProviderDefinition>();
|
||||
public ProviderDefinition Definition { get; set; }
|
||||
|
||||
protected TSettings Settings => (TSettings)Definition.Settings;
|
||||
protected ExternalDecisionDefinition DecisionDefinition => (ExternalDecisionDefinition)Definition;
|
||||
|
||||
public virtual ExternalRejectionResponse EvaluateRejection(ExternalRejectionRequest request)
|
||||
{
|
||||
return new ExternalRejectionResponse();
|
||||
}
|
||||
|
||||
public virtual ExternalPrioritizationResponse EvaluatePrioritization(ExternalPrioritizationRequest request)
|
||||
{
|
||||
return new ExternalPrioritizationResponse();
|
||||
}
|
||||
|
||||
public abstract ValidationResult Test();
|
||||
|
||||
public virtual object RequestAction(string action, IDictionary<string, string> query)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return GetType().Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
using System;
|
||||
using Equ;
|
||||
using NzbDrone.Core.ThingiProvider;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
|
||||
{
|
||||
public class ExternalDecisionDefinition : ProviderDefinition, IEquatable<ExternalDecisionDefinition>
|
||||
{
|
||||
public const int DefaultPriority = 25;
|
||||
|
||||
private static readonly MemberwiseEqualityComparer<ExternalDecisionDefinition> Comparer = MemberwiseEqualityComparer<ExternalDecisionDefinition>.ByProperties;
|
||||
|
||||
public ExternalDecisionType DecisionType { get; set; }
|
||||
public int Priority { get; set; } = DefaultPriority;
|
||||
|
||||
public bool Equals(ExternalDecisionDefinition other)
|
||||
{
|
||||
return Comparer.Equals(this, other);
|
||||
}
|
||||
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
return Equals(obj as ExternalDecisionDefinition);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return Comparer.GetHashCode(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentValidation.Results;
|
||||
using NLog;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using NzbDrone.Core.ThingiProvider;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
|
||||
{
|
||||
public interface IExternalDecisionFactory : IProviderFactory<IExternalDecision, ExternalDecisionDefinition>
|
||||
{
|
||||
List<IExternalDecision> RejectionDecisionsEnabled();
|
||||
List<IExternalDecision> PrioritizationDecisionsEnabled();
|
||||
}
|
||||
|
||||
public class ExternalDecisionFactory : ProviderFactory<IExternalDecision, ExternalDecisionDefinition>, IExternalDecisionFactory
|
||||
{
|
||||
private readonly IExternalDecisionStatusService _externalDecisionStatusService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public ExternalDecisionFactory(IExternalDecisionStatusService externalDecisionStatusService,
|
||||
IExternalDecisionRepository providerRepository,
|
||||
IEnumerable<IExternalDecision> providers,
|
||||
IServiceProvider container,
|
||||
IEventAggregator eventAggregator,
|
||||
Logger logger)
|
||||
: base(providerRepository, providers, container, eventAggregator, logger)
|
||||
{
|
||||
_externalDecisionStatusService = externalDecisionStatusService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override List<ExternalDecisionDefinition> Active()
|
||||
{
|
||||
return base.Active().Where(c => c.Enable).ToList();
|
||||
}
|
||||
|
||||
public List<IExternalDecision> RejectionDecisionsEnabled()
|
||||
{
|
||||
return FilterBlockedDecisions(GetAvailableProviders()
|
||||
.Where(h => ((ExternalDecisionDefinition)h.Definition).DecisionType == ExternalDecisionType.Rejection))
|
||||
.OrderBy(h => ((ExternalDecisionDefinition)h.Definition).Priority)
|
||||
.ThenBy(h => h.Definition.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public List<IExternalDecision> PrioritizationDecisionsEnabled()
|
||||
{
|
||||
return FilterBlockedDecisions(GetAvailableProviders()
|
||||
.Where(h => ((ExternalDecisionDefinition)h.Definition).DecisionType == ExternalDecisionType.Prioritization))
|
||||
.OrderBy(h => ((ExternalDecisionDefinition)h.Definition).Priority)
|
||||
.ThenBy(h => h.Definition.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private IEnumerable<IExternalDecision> FilterBlockedDecisions(IEnumerable<IExternalDecision> decisions)
|
||||
{
|
||||
var blockedDecisions = _externalDecisionStatusService.GetBlockedProviders().ToDictionary(v => v.ProviderId, v => v);
|
||||
|
||||
foreach (var decision in decisions)
|
||||
{
|
||||
if (blockedDecisions.TryGetValue(decision.Definition.Id, out var status))
|
||||
{
|
||||
_logger.Debug("Temporarily ignoring external decision {0} till {1} due to recent failures.", decision.Definition.Name, status.DisabledTill.Value.ToLocalTime());
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return decision;
|
||||
}
|
||||
}
|
||||
|
||||
public override ValidationResult Test(ExternalDecisionDefinition definition)
|
||||
{
|
||||
var result = base.Test(definition);
|
||||
|
||||
if (definition.Id == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result == null || result.IsValid)
|
||||
{
|
||||
_externalDecisionStatusService.RecordSuccess(definition.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
_externalDecisionStatusService.RecordFailure(definition.Id);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using NzbDrone.Core.ThingiProvider;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
|
||||
{
|
||||
public interface IExternalDecisionRepository : IProviderRepository<ExternalDecisionDefinition>
|
||||
{
|
||||
}
|
||||
|
||||
public class ExternalDecisionRepository : ProviderRepository<ExternalDecisionDefinition>, IExternalDecisionRepository
|
||||
{
|
||||
public ExternalDecisionRepository(IMainDatabase database, IEventAggregator eventAggregator)
|
||||
: base(database, eventAggregator)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using System;
|
||||
using Equ;
|
||||
using NzbDrone.Core.ThingiProvider;
|
||||
using NzbDrone.Core.Validation;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
|
||||
{
|
||||
public abstract class ExternalDecisionSettingsBase<TSettings> : IProviderConfig, IEquatable<TSettings>
|
||||
where TSettings : ExternalDecisionSettingsBase<TSettings>
|
||||
{
|
||||
private static readonly MemberwiseEqualityComparer<TSettings> Comparer = MemberwiseEqualityComparer<TSettings>.ByProperties;
|
||||
|
||||
public abstract NzbDroneValidationResult Validate();
|
||||
|
||||
public bool Equals(TSettings other)
|
||||
{
|
||||
return Comparer.Equals(this as TSettings, other);
|
||||
}
|
||||
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
return Equals(obj as TSettings);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return Comparer.GetHashCode(this as TSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
using NzbDrone.Core.ThingiProvider.Status;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
|
||||
{
|
||||
public class ExternalDecisionStatus : ProviderStatusBase
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using NzbDrone.Core.ThingiProvider.Status;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
|
||||
{
|
||||
public interface IExternalDecisionStatusRepository : IProviderStatusRepository<ExternalDecisionStatus>
|
||||
{
|
||||
}
|
||||
|
||||
public class ExternalDecisionStatusRepository : ProviderStatusRepository<ExternalDecisionStatus>, IExternalDecisionStatusRepository
|
||||
{
|
||||
public ExternalDecisionStatusRepository(IMainDatabase database, IEventAggregator eventAggregator)
|
||||
: base(database, eventAggregator)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using System;
|
||||
using NLog;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using NzbDrone.Core.ThingiProvider.Status;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
|
||||
{
|
||||
public interface IExternalDecisionStatusService : IProviderStatusServiceBase<ExternalDecisionStatus>
|
||||
{
|
||||
}
|
||||
|
||||
public class ExternalDecisionStatusService : ProviderStatusServiceBase<IExternalDecision, ExternalDecisionStatus>, IExternalDecisionStatusService
|
||||
{
|
||||
public ExternalDecisionStatusService(IExternalDecisionStatusRepository providerStatusRepository, IEventAggregator eventAggregator, IRuntimeInfo runtimeInfo, Logger logger)
|
||||
: base(providerStatusRepository, eventAggregator, runtimeInfo, logger)
|
||||
{
|
||||
MinimumTimeSinceInitialFailure = TimeSpan.FromMinutes(5);
|
||||
MaximumEscalationLevel = 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
|
||||
{
|
||||
public enum ExternalDecisionType
|
||||
{
|
||||
Rejection = 0,
|
||||
Prioritization = 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
|
||||
{
|
||||
public interface IExternalPrioritizationService
|
||||
{
|
||||
void PopulateExternalPriorityScores(List<DownloadDecision> decisions);
|
||||
}
|
||||
|
||||
public class ExternalPrioritizationService : IExternalPrioritizationService
|
||||
{
|
||||
private readonly IExternalDecisionFactory _externalDecisionFactory;
|
||||
private readonly IExternalDecisionStatusService _statusService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public ExternalPrioritizationService(IExternalDecisionFactory externalDecisionFactory, IExternalDecisionStatusService statusService, Logger logger)
|
||||
{
|
||||
_externalDecisionFactory = externalDecisionFactory;
|
||||
_statusService = statusService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void PopulateExternalPriorityScores(List<DownloadDecision> decisions)
|
||||
{
|
||||
var externalDecisions = _externalDecisionFactory.PrioritizationDecisionsEnabled();
|
||||
|
||||
if (externalDecisions.Empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var withSeries = decisions.Where(d => d.RemoteEpisode.Series != null).ToList();
|
||||
|
||||
var seriesGroups = withSeries.GroupBy(d => d.RemoteEpisode.Series.Id).ToList();
|
||||
|
||||
foreach (var group in seriesGroups)
|
||||
{
|
||||
var groupDecisions = group.ToList();
|
||||
|
||||
foreach (var externalDecision in externalDecisions)
|
||||
{
|
||||
ApplyDecisionScores(externalDecision, groupDecisions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyDecisionScores(IExternalDecision externalDecision, List<DownloadDecision> decisions)
|
||||
{
|
||||
if (decisions.Empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var firstDecision = decisions.First();
|
||||
var series = firstDecision.RemoteEpisode.Series;
|
||||
|
||||
var decisionTags = ((ExternalDecisionDefinition)externalDecision.Definition).Tags;
|
||||
|
||||
if (decisionTags is { Count: > 0 })
|
||||
{
|
||||
var seriesTags = series?.Tags;
|
||||
|
||||
if (seriesTags is not { Count: > 0 } || !decisionTags.Intersect(seriesTags).Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var request = BuildPrioritizationRequest(decisions);
|
||||
|
||||
_logger.Debug("Evaluating external prioritization decision '{0}' for series '{1}' with {2} releases.", externalDecision.Definition.Name, series.Title, decisions.Count);
|
||||
|
||||
var response = externalDecision.EvaluatePrioritization(request);
|
||||
|
||||
if (response?.Scores == null || response.Scores.Count == 0)
|
||||
{
|
||||
_logger.Debug("External prioritization decision '{0}' returned empty response for series '{1}', keeping default scores.", externalDecision.Definition.Name, series.Title);
|
||||
}
|
||||
else
|
||||
{
|
||||
AssignScores(decisions, response.Scores);
|
||||
|
||||
_logger.Debug("External prioritization decision '{0}' assigned priority scores for {1} releases for series '{2}'.", externalDecision.Definition.Name, decisions.Count, series.Title);
|
||||
}
|
||||
|
||||
_statusService.RecordSuccess(externalDecision.Definition.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn(ex, "External prioritization decision '{0}' failed for series '{1}', keeping current scores (fail-open).", externalDecision.Definition.Name, series.Title);
|
||||
_statusService.RecordFailure(externalDecision.Definition.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private void AssignScores(List<DownloadDecision> decisions, Dictionary<string, int> scores)
|
||||
{
|
||||
foreach (var decision in decisions)
|
||||
{
|
||||
var guid = decision.RemoteEpisode.Release.Guid;
|
||||
|
||||
decision.RemoteEpisode.ExternalPriorityScore = scores.TryGetValue(guid, out var score) ? score : 0;
|
||||
}
|
||||
}
|
||||
|
||||
private ExternalPrioritizationRequest BuildPrioritizationRequest(List<DownloadDecision> decisions)
|
||||
{
|
||||
var firstDecision = decisions.First();
|
||||
var series = firstDecision.RemoteEpisode.Series;
|
||||
|
||||
var allEpisodes = decisions.SelectMany(d => d.RemoteEpisode.Episodes)
|
||||
.GroupBy(e => e.Id)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
|
||||
return new ExternalPrioritizationRequest
|
||||
{
|
||||
DecisionType = nameof(ExternalDecisionType.Prioritization),
|
||||
Releases = decisions.Select(d =>
|
||||
{
|
||||
var release = d.RemoteEpisode.Release;
|
||||
var torrentInfo = release as TorrentInfo;
|
||||
|
||||
return new ExternalReleasePayload
|
||||
{
|
||||
Guid = release.Guid,
|
||||
Title = release.Title,
|
||||
Indexer = release.Indexer,
|
||||
Quality = d.RemoteEpisode.ParsedEpisodeInfo?.Quality,
|
||||
CustomFormats = d.RemoteEpisode.CustomFormats?.Select(cf => new CustomFormatPayload { Id = cf.Id, Name = cf.Name }).ToList() ?? new List<CustomFormatPayload>(),
|
||||
CustomFormatScore = d.RemoteEpisode.CustomFormatScore,
|
||||
Size = release.Size,
|
||||
Protocol = release.DownloadProtocol.ToString().ToLowerInvariant(),
|
||||
Languages = d.RemoteEpisode.Languages ?? new List<Language>(),
|
||||
Seeders = torrentInfo?.Seeders,
|
||||
Peers = torrentInfo?.Peers,
|
||||
Age = release.Age,
|
||||
IndexerPriority = release.IndexerPriority,
|
||||
IndexerFlags = release.IndexerFlags,
|
||||
InfoUrl = release.InfoUrl,
|
||||
InfoHash = torrentInfo?.InfoHash,
|
||||
IsFullSeason = d.RemoteEpisode.ParsedEpisodeInfo?.FullSeason ?? false,
|
||||
ReleaseType = d.RemoteEpisode.ParsedEpisodeInfo?.ReleaseType.ToString()
|
||||
};
|
||||
}).ToList(),
|
||||
|
||||
Series = new ExternalSeriesPayload
|
||||
{
|
||||
Id = series.Id,
|
||||
TvdbId = series.TvdbId,
|
||||
ImdbId = series.ImdbId,
|
||||
TmdbId = series.TmdbId,
|
||||
Title = series.Title,
|
||||
Year = series.Year,
|
||||
Status = series.Status.ToString(),
|
||||
SeriesType = series.SeriesType.ToString(),
|
||||
Network = series.Network,
|
||||
Runtime = series.Runtime,
|
||||
OriginalLanguage = series.OriginalLanguage,
|
||||
Certification = series.Certification,
|
||||
Tags = series.Tags,
|
||||
QualityProfileId = series.QualityProfileId
|
||||
},
|
||||
|
||||
Episodes = allEpisodes.Select(e => new ExternalEpisodePayload
|
||||
{
|
||||
Id = e.Id,
|
||||
SeasonNumber = e.SeasonNumber,
|
||||
EpisodeNumber = e.EpisodeNumber,
|
||||
AbsoluteEpisodeNumber = e.AbsoluteEpisodeNumber,
|
||||
Title = e.Title,
|
||||
AirDate = e.AirDate,
|
||||
AirDateUtc = e.AirDateUtc,
|
||||
Runtime = e.Runtime,
|
||||
HasFile = e.HasFile
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
using NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads;
|
||||
using NzbDrone.Core.ThingiProvider;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
|
||||
{
|
||||
public interface IExternalDecision : IProvider
|
||||
{
|
||||
ExternalRejectionResponse EvaluateRejection(ExternalRejectionRequest request);
|
||||
ExternalPrioritizationResponse EvaluatePrioritization(ExternalPrioritizationRequest request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
using System;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads
|
||||
{
|
||||
public class ExternalEpisodePayload
|
||||
{
|
||||
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 string AirDate { get; set; }
|
||||
public DateTime? AirDateUtc { get; set; }
|
||||
public int Runtime { get; set; }
|
||||
public bool HasFile { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.Languages;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads
|
||||
{
|
||||
public class ExternalExistingFilePayload
|
||||
{
|
||||
public string Quality { get; set; }
|
||||
public long Size { get; set; }
|
||||
public List<Language> Languages { get; set; }
|
||||
public string RelativePath { get; set; }
|
||||
public string ReleaseGroup { get; set; }
|
||||
public string SceneName { get; set; }
|
||||
public DateTime DateAdded { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads
|
||||
{
|
||||
public class ExternalPrioritizationRequest
|
||||
{
|
||||
public string DecisionType { get; set; } = nameof(ExternalDecisionType.Prioritization);
|
||||
public List<ExternalReleasePayload> Releases { get; set; }
|
||||
public ExternalSeriesPayload Series { get; set; }
|
||||
public List<ExternalEpisodePayload> Episodes { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads
|
||||
{
|
||||
public class ExternalPrioritizationResponse
|
||||
{
|
||||
public Dictionary<string, int> Scores { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads
|
||||
{
|
||||
public class ExternalRejectionRequest
|
||||
{
|
||||
public string DecisionType { get; set; } = nameof(ExternalDecisionType.Rejection);
|
||||
public ExternalReleasePayload Release { get; set; }
|
||||
public ExternalSeriesPayload Series { get; set; }
|
||||
public List<ExternalEpisodePayload> Episodes { get; set; }
|
||||
public List<ExternalExistingFilePayload> ExistingFiles { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads
|
||||
{
|
||||
public class ExternalRejectionResponse
|
||||
{
|
||||
public bool Approved { get; set; } = true;
|
||||
public string Reason { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Qualities;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads
|
||||
{
|
||||
public class ExternalReleasePayload
|
||||
{
|
||||
public string Guid { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Indexer { get; set; }
|
||||
public QualityModel Quality { get; set; }
|
||||
public List<CustomFormatPayload> CustomFormats { get; set; }
|
||||
public int CustomFormatScore { get; set; }
|
||||
public long Size { get; set; }
|
||||
public string Protocol { get; set; }
|
||||
public List<Language> Languages { get; set; }
|
||||
public int? Seeders { get; set; }
|
||||
public int? Peers { get; set; }
|
||||
public int Age { get; set; }
|
||||
public int IndexerPriority { get; set; }
|
||||
public IndexerFlags IndexerFlags { get; set; }
|
||||
public string InfoUrl { get; set; }
|
||||
public string InfoHash { get; set; }
|
||||
public bool IsFullSeason { get; set; }
|
||||
public string ReleaseType { get; set; }
|
||||
}
|
||||
|
||||
public class CustomFormatPayload
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.Languages;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads
|
||||
{
|
||||
public class ExternalSeriesPayload
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int TvdbId { get; set; }
|
||||
public string ImdbId { get; set; }
|
||||
public int TmdbId { get; set; }
|
||||
public string Title { get; set; }
|
||||
public int Year { get; set; }
|
||||
public string Status { get; set; }
|
||||
public string SeriesType { get; set; }
|
||||
public string Network { get; set; }
|
||||
public int Runtime { get; set; }
|
||||
public Language OriginalLanguage { get; set; }
|
||||
public string Certification { get; set; }
|
||||
public HashSet<int> Tags { get; set; }
|
||||
public int QualityProfileId { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentValidation.Results;
|
||||
using NLog;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads;
|
||||
using NzbDrone.Core.Qualities;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
|
||||
{
|
||||
public class WebhookExternalDecision : ExternalDecisionBase<WebhookExternalDecisionSettings>
|
||||
{
|
||||
private readonly IWebhookExternalDecisionProxy _proxy;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public WebhookExternalDecision(IWebhookExternalDecisionProxy proxy, Logger logger)
|
||||
{
|
||||
_proxy = proxy;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override string Name => "Webhook";
|
||||
|
||||
public override ExternalRejectionResponse EvaluateRejection(ExternalRejectionRequest request)
|
||||
{
|
||||
return _proxy.SendRejectionRequest(request, Settings);
|
||||
}
|
||||
|
||||
public override ExternalPrioritizationResponse EvaluatePrioritization(ExternalPrioritizationRequest request)
|
||||
{
|
||||
return _proxy.SendPrioritizationRequest(request, Settings);
|
||||
}
|
||||
|
||||
public override ValidationResult Test()
|
||||
{
|
||||
var failures = new List<ValidationFailure>();
|
||||
|
||||
try
|
||||
{
|
||||
var testSeries = new ExternalSeriesPayload
|
||||
{
|
||||
Id = 0,
|
||||
TvdbId = 0,
|
||||
Title = "Test Series",
|
||||
Year = 2024,
|
||||
Status = "Continuing",
|
||||
SeriesType = "Standard",
|
||||
Runtime = 45,
|
||||
Tags = new HashSet<int>(),
|
||||
QualityProfileId = 0
|
||||
};
|
||||
|
||||
var testEpisodes = new List<ExternalEpisodePayload>
|
||||
{
|
||||
new ExternalEpisodePayload
|
||||
{
|
||||
Id = 0,
|
||||
SeasonNumber = 1,
|
||||
EpisodeNumber = 1,
|
||||
Title = "Test Episode",
|
||||
AirDate = "2024-01-15",
|
||||
Runtime = 45,
|
||||
HasFile = false
|
||||
}
|
||||
};
|
||||
|
||||
switch (DecisionDefinition.DecisionType)
|
||||
{
|
||||
case ExternalDecisionType.Prioritization:
|
||||
_proxy.SendPrioritizationRequest(BuildTestPrioritizationRequest(testSeries, testEpisodes), Settings);
|
||||
break;
|
||||
case ExternalDecisionType.Rejection:
|
||||
_proxy.SendRejectionRequest(BuildTestRejectionRequest(testSeries, testEpisodes), Settings);
|
||||
break;
|
||||
default:
|
||||
throw new NotImplementedException($"Test not implemented for decision type: {DecisionDefinition.DecisionType}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test request to external decision.");
|
||||
failures.Add(new ValidationFailure("Url", $"Unable to connect to external decision: {ex.Message}"));
|
||||
}
|
||||
|
||||
return new ValidationResult(failures);
|
||||
}
|
||||
|
||||
private static ExternalRejectionRequest BuildTestRejectionRequest(ExternalSeriesPayload series, List<ExternalEpisodePayload> episodes)
|
||||
{
|
||||
return new ExternalRejectionRequest
|
||||
{
|
||||
DecisionType = nameof(ExternalDecisionType.Rejection),
|
||||
Release = BuildTestRelease("test-guid"),
|
||||
Series = series,
|
||||
Episodes = episodes
|
||||
};
|
||||
}
|
||||
|
||||
private static ExternalPrioritizationRequest BuildTestPrioritizationRequest(ExternalSeriesPayload series, List<ExternalEpisodePayload> episodes)
|
||||
{
|
||||
return new ExternalPrioritizationRequest
|
||||
{
|
||||
DecisionType = nameof(ExternalDecisionType.Prioritization),
|
||||
Releases = new List<ExternalReleasePayload>
|
||||
{
|
||||
BuildTestRelease("test-guid-1"),
|
||||
BuildTestRelease("test-guid-2", Quality.HDTV720p, "Test.Release.S01E01.720p.HDTV", 536870912, "torrent", 0, 50, 10)
|
||||
},
|
||||
Series = series,
|
||||
Episodes = episodes
|
||||
};
|
||||
}
|
||||
|
||||
private static ExternalReleasePayload BuildTestRelease(
|
||||
string guid,
|
||||
Quality quality = null,
|
||||
string title = "Test.Release.S01E01.1080p.WEB-DL",
|
||||
long size = 1073741824,
|
||||
string protocol = "usenet",
|
||||
int age = 1,
|
||||
int? seeders = null,
|
||||
int? peers = null)
|
||||
{
|
||||
return new ExternalReleasePayload
|
||||
{
|
||||
Guid = guid,
|
||||
Title = title,
|
||||
Indexer = "TestIndexer",
|
||||
Quality = new QualityModel(quality ?? Quality.WEBDL1080p, new Revision()),
|
||||
Size = size,
|
||||
Protocol = protocol,
|
||||
Age = age,
|
||||
IndexerPriority = 25,
|
||||
Seeders = seeders,
|
||||
Peers = peers,
|
||||
ReleaseType = "SingleEpisode",
|
||||
CustomFormats = new List<CustomFormatPayload>(),
|
||||
Languages = new List<Languages.Language>()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Common.Serializer;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
|
||||
{
|
||||
public interface IWebhookExternalDecisionProxy
|
||||
{
|
||||
ExternalRejectionResponse SendRejectionRequest(ExternalRejectionRequest payload, WebhookExternalDecisionSettings settings);
|
||||
ExternalPrioritizationResponse SendPrioritizationRequest(ExternalPrioritizationRequest payload, WebhookExternalDecisionSettings settings);
|
||||
}
|
||||
|
||||
public class WebhookExternalDecisionProxy : IWebhookExternalDecisionProxy
|
||||
{
|
||||
private readonly IHttpClient _httpClient;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public WebhookExternalDecisionProxy(IHttpClient httpClient, Logger logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public ExternalRejectionResponse SendRejectionRequest(ExternalRejectionRequest payload, WebhookExternalDecisionSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestBuilder(settings.Url)
|
||||
.Accept(HttpAccept.Json)
|
||||
.Build();
|
||||
|
||||
request.Headers.ContentType = "application/json";
|
||||
request.SetContent(Json.ToJson(payload));
|
||||
request.RequestTimeout = TimeSpan.FromSeconds(settings.Timeout);
|
||||
|
||||
if (settings.ApiKey.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
request.Headers.Add("X-Api-Key", settings.ApiKey);
|
||||
}
|
||||
|
||||
var response = _httpClient.Post(request);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
if (response.Content.IsNullOrWhiteSpace())
|
||||
{
|
||||
return new ExternalRejectionResponse { Approved = true };
|
||||
}
|
||||
|
||||
return Json.Deserialize<ExternalRejectionResponse>(response.Content);
|
||||
}
|
||||
|
||||
_logger.Warn("External decision returned unexpected status code {0}, treating as approved (fail-open).", response.StatusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn(ex, "Error communicating with external rejection decision at {0}, treating as approved (fail-open).", settings.Url);
|
||||
}
|
||||
|
||||
return new ExternalRejectionResponse { Approved = true };
|
||||
}
|
||||
|
||||
public ExternalPrioritizationResponse SendPrioritizationRequest(ExternalPrioritizationRequest payload, WebhookExternalDecisionSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestBuilder(settings.Url)
|
||||
.Accept(HttpAccept.Json)
|
||||
.Build();
|
||||
|
||||
request.Headers.ContentType = "application/json";
|
||||
request.SetContent(Json.ToJson(payload));
|
||||
request.RequestTimeout = TimeSpan.FromSeconds(settings.Timeout);
|
||||
|
||||
if (settings.ApiKey.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
request.Headers.Add("X-Api-Key", settings.ApiKey);
|
||||
}
|
||||
|
||||
var response = _httpClient.Post(request);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
if (response.Content.IsNullOrWhiteSpace())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Json.Deserialize<ExternalPrioritizationResponse>(response.Content);
|
||||
}
|
||||
|
||||
_logger.Warn("External prioritization decision returned unexpected status code {0}, keeping default order (fail-open).", response.StatusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn(ex, "Error communicating with external prioritization decision at {0}, keeping default order (fail-open).", settings.Url);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
using FluentValidation;
|
||||
using NzbDrone.Core.Annotations;
|
||||
using NzbDrone.Core.Validation;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
|
||||
{
|
||||
public class WebhookExternalDecisionSettingsValidator : AbstractValidator<WebhookExternalDecisionSettings>
|
||||
{
|
||||
public WebhookExternalDecisionSettingsValidator()
|
||||
{
|
||||
RuleFor(c => c.Url).IsValidUrl();
|
||||
RuleFor(c => c.Timeout).InclusiveBetween(1, 120);
|
||||
}
|
||||
}
|
||||
|
||||
public class WebhookExternalDecisionSettings : ExternalDecisionSettingsBase<WebhookExternalDecisionSettings>
|
||||
{
|
||||
private static readonly WebhookExternalDecisionSettingsValidator Validator = new();
|
||||
|
||||
public WebhookExternalDecisionSettings()
|
||||
{
|
||||
Timeout = 30;
|
||||
}
|
||||
|
||||
[FieldDefinition(0, Label = "ExternalDecisionSettingsUrlLabel", Type = FieldType.Url, HelpText = "ExternalDecisionSettingsUrlHelpText")]
|
||||
public string Url { get; set; }
|
||||
|
||||
[FieldDefinition(1, Label = "ExternalDecisionSettingsApiKeyLabel", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey, HelpText = "ExternalDecisionSettingsApiKeyHelpText")]
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
[FieldDefinition(2, Label = "ExternalDecisionSettingsTimeoutLabel", Type = FieldType.Number, Unit = "seconds", HelpText = "ExternalDecisionSettingsTimeoutHelpText", Advanced = true)]
|
||||
public int Timeout { get; set; }
|
||||
|
||||
public override NzbDroneValidationResult Validate()
|
||||
{
|
||||
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ public enum SpecificationPriority
|
|||
Default = 0,
|
||||
Parsing = 0,
|
||||
Database = 0,
|
||||
Disk = 1
|
||||
Disk = 1,
|
||||
External = 2
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
{
|
||||
public class ExternalRejectionSpecification : IDownloadDecisionEngineSpecification
|
||||
{
|
||||
private readonly IExternalDecisionFactory _externalDecisionFactory;
|
||||
private readonly IExternalDecisionStatusService _statusService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public ExternalRejectionSpecification(IExternalDecisionFactory externalDecisionFactory, IExternalDecisionStatusService statusService, Logger logger)
|
||||
{
|
||||
_externalDecisionFactory = externalDecisionFactory;
|
||||
_statusService = statusService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public SpecificationPriority Priority => SpecificationPriority.External;
|
||||
public RejectionType Type => RejectionType.Permanent;
|
||||
|
||||
public DownloadSpecDecision IsSatisfiedBy(RemoteEpisode subject, ReleaseDecisionInformation information)
|
||||
{
|
||||
var decisions = _externalDecisionFactory.RejectionDecisionsEnabled();
|
||||
|
||||
if (decisions.Empty())
|
||||
{
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
|
||||
var seriesTags = subject.Series?.Tags;
|
||||
var matchingDecisions = decisions.Where(h =>
|
||||
{
|
||||
var decisionTags = ((ExternalDecisionDefinition)h.Definition).Tags;
|
||||
|
||||
return decisionTags.Empty() || (seriesTags != null && decisionTags.Intersect(seriesTags).Any());
|
||||
}).ToList();
|
||||
|
||||
if (matchingDecisions.Empty())
|
||||
{
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
|
||||
var request = BuildRejectionRequest(subject);
|
||||
|
||||
foreach (var decision in matchingDecisions)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Debug("Evaluating external rejection decision '{0}' for '{1}'", decision.Definition.Name, subject.Release.Title);
|
||||
|
||||
var response = decision.EvaluateRejection(request);
|
||||
|
||||
_statusService.RecordSuccess(decision.Definition.Id);
|
||||
|
||||
if (response.Approved)
|
||||
{
|
||||
_logger.Debug("External decision '{0}' approved '{1}'", decision.Definition.Name, subject.Release.Title);
|
||||
}
|
||||
else
|
||||
{
|
||||
var reason = response.Reason.IsNotNullOrWhiteSpace()
|
||||
? response.Reason
|
||||
: "Rejected by external decision";
|
||||
|
||||
_logger.Debug("External decision '{0}' rejected '{1}': {2}", decision.Definition.Name, subject.Release.Title, reason);
|
||||
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.ExternalRejection, "External: {0}", reason);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn(ex, "External decision '{0}' failed for '{1}', treating as approved (fail-open).", decision.Definition.Name, subject.Release.Title);
|
||||
_statusService.RecordFailure(decision.Definition.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
|
||||
private ExternalRejectionRequest BuildRejectionRequest(RemoteEpisode subject)
|
||||
{
|
||||
var release = subject.Release;
|
||||
var torrentInfo = release as TorrentInfo;
|
||||
|
||||
return new ExternalRejectionRequest
|
||||
{
|
||||
DecisionType = nameof(ExternalDecisionType.Rejection),
|
||||
Release = new ExternalReleasePayload
|
||||
{
|
||||
Guid = release.Guid,
|
||||
Title = release.Title,
|
||||
Indexer = release.Indexer,
|
||||
Quality = subject.ParsedEpisodeInfo?.Quality,
|
||||
CustomFormats = subject.CustomFormats?.Select(cf => new CustomFormatPayload { Id = cf.Id, Name = cf.Name }).ToList() ?? new List<CustomFormatPayload>(),
|
||||
CustomFormatScore = subject.CustomFormatScore,
|
||||
Size = release.Size,
|
||||
Protocol = release.DownloadProtocol.ToString().ToLowerInvariant(),
|
||||
Languages = subject.Languages ?? new List<Languages.Language>(),
|
||||
Seeders = torrentInfo?.Seeders,
|
||||
Peers = torrentInfo?.Peers,
|
||||
Age = release.Age,
|
||||
IndexerPriority = release.IndexerPriority,
|
||||
IndexerFlags = release.IndexerFlags,
|
||||
InfoUrl = release.InfoUrl,
|
||||
InfoHash = torrentInfo?.InfoHash,
|
||||
IsFullSeason = subject.ParsedEpisodeInfo?.FullSeason ?? false,
|
||||
ReleaseType = subject.ParsedEpisodeInfo?.ReleaseType.ToString()
|
||||
},
|
||||
Series = new ExternalSeriesPayload
|
||||
{
|
||||
Id = subject.Series.Id,
|
||||
TvdbId = subject.Series.TvdbId,
|
||||
ImdbId = subject.Series.ImdbId,
|
||||
TmdbId = subject.Series.TmdbId,
|
||||
Title = subject.Series.Title,
|
||||
Year = subject.Series.Year,
|
||||
Status = subject.Series.Status.ToString(),
|
||||
SeriesType = subject.Series.SeriesType.ToString(),
|
||||
Network = subject.Series.Network,
|
||||
Runtime = subject.Series.Runtime,
|
||||
OriginalLanguage = subject.Series.OriginalLanguage,
|
||||
Certification = subject.Series.Certification,
|
||||
Tags = subject.Series.Tags,
|
||||
QualityProfileId = subject.Series.QualityProfileId
|
||||
},
|
||||
Episodes = subject.Episodes.Select(e => new ExternalEpisodePayload
|
||||
{
|
||||
Id = e.Id,
|
||||
SeasonNumber = e.SeasonNumber,
|
||||
EpisodeNumber = e.EpisodeNumber,
|
||||
AbsoluteEpisodeNumber = e.AbsoluteEpisodeNumber,
|
||||
Title = e.Title,
|
||||
AirDate = e.AirDate,
|
||||
AirDateUtc = e.AirDateUtc,
|
||||
Runtime = e.Runtime,
|
||||
HasFile = e.HasFile
|
||||
}).ToList(),
|
||||
ExistingFiles = subject.Episodes
|
||||
.Where(e => e.EpisodeFileId > 0 && e.EpisodeFile?.Value != null)
|
||||
.Select(e => e.EpisodeFile.Value)
|
||||
.Distinct()
|
||||
.Select(ef => new ExternalExistingFilePayload
|
||||
{
|
||||
Quality = ef.Quality?.Quality?.Name,
|
||||
Size = ef.Size,
|
||||
Languages = ef.Languages ?? new List<Language>(),
|
||||
RelativePath = ef.RelativePath,
|
||||
ReleaseGroup = ef.ReleaseGroup,
|
||||
SceneName = ef.SceneName,
|
||||
DateAdded = ef.DateAdded
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -717,6 +717,30 @@
|
|||
"Extend": "Extend",
|
||||
"External": "External",
|
||||
"ExternalUpdater": "{appName} is configured to use an external update mechanism",
|
||||
"ExternalDecisions": "External Decisions",
|
||||
"ExternalDecisionSettings": "External Decision Settings",
|
||||
"ExternalDecisionSettingsApiKeyHelpText": "Sent as X-Api-Key header",
|
||||
"ExternalDecisionSettingsApiKeyLabel": "API Key",
|
||||
"ExternalDecisionSettingsSummary": "External decisions for download decision rejection and prioritization",
|
||||
"ExternalDecisionSettingsTimeoutHelpText": "Maximum wait time for the external service. Falls back to default behavior on timeout.",
|
||||
"ExternalDecisionSettingsTimeoutLabel": "Timeout",
|
||||
"ExternalDecisionSettingsUrlHelpText": "URL of the external service endpoint",
|
||||
"ExternalDecisionSettingsUrlLabel": "URL",
|
||||
"ExternalDecisionsLoadError": "Unable to load external decisions",
|
||||
"ExternalDecisionTagsHelpText": "Only apply this decision to series with at least one matching tag",
|
||||
"DecisionType": "Decision Type",
|
||||
"ExternalDecisionTypeHelpText": "The type of decision to apply (Rejection or Prioritization)",
|
||||
"ExternalDecisionPriority": "Priority",
|
||||
"ExternalDecisionPriorityHelpText": "Priority from 1 (Highest) to 50 (Lowest). Default: 25. Decisions with lower values run first. Used to control the order in which external decisions are evaluated.",
|
||||
"ExternalDecisionTypeRejection": "Rejection",
|
||||
"ExternalDecisionTypePrioritization": "Prioritization",
|
||||
"AddExternalDecision": "Add External Decision",
|
||||
"AddExternalDecisionError": "Unable to add a new external decision, please try again.",
|
||||
"AddExternalDecisionImplementation": "Add External Decision - {implementationName}",
|
||||
"EditExternalDecisionImplementation": "Edit External Decision - {implementationName}",
|
||||
"DeleteExternalDecision": "Delete External Decision",
|
||||
"DeleteExternalDecisionMessageText": "Are you sure you want to delete the external decision '{name}'?",
|
||||
"ExternalPriorityScore": "External Priority Score",
|
||||
"ExtraFileExtensionsHelpText": "Comma separated list of extra files to import (.nfo will be imported as .nfo-orig)",
|
||||
"ExtraFileExtensionsHelpTextsExamples": "Examples: '.sub, .nfo' or 'sub,nfo'",
|
||||
"Failed": "Failed",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ public class RemoteEpisode
|
|||
public TorrentSeedConfiguration SeedConfiguration { get; set; }
|
||||
public List<CustomFormat> CustomFormats { get; set; }
|
||||
public int CustomFormatScore { get; set; }
|
||||
public int ExternalPriorityScore { get; set; }
|
||||
public SeriesMatchType SeriesMatchType { get; set; }
|
||||
public List<Language> Languages { get; set; }
|
||||
public ReleaseSourceType ReleaseSource { get; set; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
using NzbDrone.Core.DecisionEngine.ExternalDecisions;
|
||||
using Sonarr.Api.V5.Provider;
|
||||
|
||||
namespace Sonarr.Api.V5.ExternalDecision;
|
||||
|
||||
public class ExternalDecisionBulkResource : ProviderBulkResource<ExternalDecisionBulkResource>
|
||||
{
|
||||
}
|
||||
|
||||
public class ExternalDecisionBulkResourceMapper : ProviderBulkResourceMapper<ExternalDecisionBulkResource, ExternalDecisionDefinition>
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Core.DecisionEngine.ExternalDecisions;
|
||||
using NzbDrone.SignalR;
|
||||
using Sonarr.Api.V5.Provider;
|
||||
using Sonarr.Http;
|
||||
|
||||
namespace Sonarr.Api.V5.ExternalDecision;
|
||||
|
||||
[V5ApiController]
|
||||
public class ExternalDecisionController : ProviderControllerBase<ExternalDecisionResource, ExternalDecisionBulkResource, IExternalDecision, ExternalDecisionDefinition>
|
||||
{
|
||||
public static readonly ExternalDecisionResourceMapper ResourceMapper = new();
|
||||
public static readonly ExternalDecisionBulkResourceMapper BulkResourceMapper = new();
|
||||
|
||||
public ExternalDecisionController(IBroadcastSignalRMessage signalRBroadcaster, ExternalDecisionFactory externalDecisionFactory)
|
||||
: base(signalRBroadcaster, externalDecisionFactory, "externaldecision", ResourceMapper, BulkResourceMapper)
|
||||
{
|
||||
SharedValidator.RuleFor(c => c.Priority).InclusiveBetween(1, 50);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public override ActionResult<ExternalDecisionResource> UpdateProvider([FromBody] ExternalDecisionBulkResource providerResource)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public override ActionResult DeleteProviders([FromBody] ExternalDecisionBulkResource resource)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
using NzbDrone.Core.DecisionEngine.ExternalDecisions;
|
||||
using Sonarr.Api.V5.Provider;
|
||||
|
||||
namespace Sonarr.Api.V5.ExternalDecision;
|
||||
|
||||
public class ExternalDecisionResource : ProviderResource<ExternalDecisionResource>
|
||||
{
|
||||
public bool Enable { get; set; }
|
||||
public ExternalDecisionType DecisionType { get; set; }
|
||||
public int Priority { get; set; }
|
||||
}
|
||||
|
||||
public class ExternalDecisionResourceMapper : ProviderResourceMapper<ExternalDecisionResource, ExternalDecisionDefinition>
|
||||
{
|
||||
public override ExternalDecisionResource ToResource(ExternalDecisionDefinition definition)
|
||||
{
|
||||
var resource = base.ToResource(definition);
|
||||
|
||||
resource.Enable = definition.Enable;
|
||||
resource.DecisionType = definition.DecisionType;
|
||||
resource.Priority = definition.Priority;
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
public override ExternalDecisionDefinition ToModel(ExternalDecisionResource resource, ExternalDecisionDefinition? existingDefinition)
|
||||
{
|
||||
var definition = base.ToModel(resource, existingDefinition);
|
||||
|
||||
definition.Enable = resource.Enable;
|
||||
definition.DecisionType = resource.DecisionType;
|
||||
definition.Priority = resource.Priority;
|
||||
|
||||
return definition;
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ public class ReleaseResource : RestResource
|
|||
public int ReleaseWeight { get; set; }
|
||||
public List<CustomFormatResource>? CustomFormats { get; set; }
|
||||
public int CustomFormatScore { get; set; }
|
||||
public int ExternalPriorityScore { get; set; }
|
||||
public AlternateTitleResource? SceneMapping { get; set; }
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ public static ReleaseResource ToResource(this DownloadDecision model)
|
|||
EpisodeRequested = remoteEpisode.EpisodeRequested,
|
||||
DownloadAllowed = remoteEpisode.DownloadAllowed,
|
||||
CustomFormatScore = remoteEpisode.CustomFormatScore,
|
||||
ExternalPriorityScore = remoteEpisode.ExternalPriorityScore,
|
||||
CustomFormats = remoteEpisode.CustomFormats?.ToResource(false),
|
||||
SceneMapping = remoteEpisode.SceneMapping?.ToResource(),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue