feature: external decision (renamed from external hook)

This commit is contained in:
Alexandre Masson 2026-03-02 15:14:13 +01:00
parent 5bde924239
commit 82bac51866
66 changed files with 3228 additions and 3 deletions

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ interface CssExports {
'customFormatScore': string;
'download': string;
'downloadIcon': string;
'externalPriorityScore': string;
'history': string;
'indexer': string;
'indexerFlags': string;

View file

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

View file

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

View file

@ -55,6 +55,7 @@ export interface Release extends ModelBase {
releaseWeight: number;
customFormats: CustomFormat[];
customFormatScore: number;
externalPriorityScore: number;
indexerFlags: number;
sceneMapping?: AlternateTitle;
}

View file

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

View file

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

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
.externalDecisions {
display: flex;
justify-content: center;
flex-wrap: wrap;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -76,5 +76,6 @@ public enum DownloadRejectionReason
DiskCustomFormatScoreIncrement,
DiskUpgradesNotAllowed,
DiskNotUpgrade,
BeforeAirDate
BeforeAirDate,
ExternalRejection
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
using NzbDrone.Core.ThingiProvider.Status;
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
{
public class ExternalDecisionStatus : ProviderStatusBase
{
}
}

View file

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

View file

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

View file

@ -0,0 +1,8 @@
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions
{
public enum ExternalDecisionType
{
Rejection = 0,
Prioritization = 1
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads
{
public class ExternalPrioritizationResponse
{
public Dictionary<string, int> Scores { get; set; }
}
}

View file

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

View file

@ -0,0 +1,8 @@
namespace NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads
{
public class ExternalRejectionResponse
{
public bool Approved { get; set; } = true;
public string Reason { get; set; }
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ public enum SpecificationPriority
Default = 0,
Parsing = 0,
Database = 0,
Disk = 1
Disk = 1,
External = 2
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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