diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx index 364063ac1..cce8a7c16 100644 --- a/frontend/src/App/AppRoutes.tsx +++ b/frontend/src/App/AppRoutes.tsx @@ -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() { + + translate('Connect'), to: '/settings/connect', }, + { + title: () => translate('ExternalDecisions'), + to: '/settings/externaldecisions', + }, { title: () => translate('Metadata'), to: '/settings/metadata', diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css index adade0935..6a7e3cb21 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css @@ -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'; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts index 1ae29abb0..f71ff1b51 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts @@ -6,6 +6,7 @@ interface CssExports { 'customFormatScore': string; 'download': string; 'downloadIcon': string; + 'externalPriorityScore': string; 'history': string; 'indexer': string; 'indexerFlags': string; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx index 0977e60b8..03608e30a 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx @@ -84,6 +84,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) { languages, customFormatScore, customFormats, + externalPriorityScore, sceneMapping, mappedSeriesId, mappedSeasonNumber, @@ -287,6 +288,10 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) { /> + + {formatCustomFormatScore(externalPriorityScore)} + + {indexerFlags ? ( translate('ExternalPriorityScore'), + }), + isSortable: true, + isVisible: true, + }, { name: 'indexerFlags', label: createElement(Icon, { diff --git a/frontend/src/InteractiveSearch/useReleases.ts b/frontend/src/InteractiveSearch/useReleases.ts index 7b10a0399..de1ba7c36 100644 --- a/frontend/src/InteractiveSearch/useReleases.ts +++ b/frontend/src/InteractiveSearch/useReleases.ts @@ -55,6 +55,7 @@ export interface Release extends ModelBase { releaseWeight: number; customFormats: CustomFormat[]; customFormatScore: number; + externalPriorityScore: number; indexerFlags: number; sceneMapping?: AlternateTitle; } diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisionSettings.tsx b/frontend/src/Settings/ExternalDecisions/ExternalDecisionSettings.tsx new file mode 100644 index 000000000..bf5d87651 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisionSettings.tsx @@ -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 ( + + + + + + + + ); +} + +export default ExternalDecisionSettings; diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionItem.css b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionItem.css new file mode 100644 index 000000000..659f13559 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionItem.css @@ -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; +} diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionItem.css.d.ts b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionItem.css.d.ts new file mode 100644 index 000000000..b29c0ca21 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionItem.css.d.ts @@ -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; diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionItem.tsx b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionItem.tsx new file mode 100644 index 000000000..ec6b9aaa6 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionItem.tsx @@ -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 ( +
+ + +
+
{implementationName}
+ +
+ +
+
+
+ ); +} + +export default AddExternalDecisionItem; diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionModal.tsx b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionModal.tsx new file mode 100644 index 000000000..3b5f997ad --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionModal.tsx @@ -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 ( + + + + ); +} + +export default AddExternalDecisionModal; diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionModalContent.css b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionModalContent.css new file mode 100644 index 000000000..8fb48eadd --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionModalContent.css @@ -0,0 +1,5 @@ +.externalDecisions { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionModalContent.css.d.ts b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionModalContent.css.d.ts new file mode 100644 index 000000000..e413a3c93 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionModalContent.css.d.ts @@ -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; diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionModalContent.tsx b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionModalContent.tsx new file mode 100644 index 000000000..7360a20de --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/AddExternalDecisionModalContent.tsx @@ -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 ( + + {translate('AddExternalDecision')} + + + {isSchemaFetching && !isSchemaFetched ? : null} + + {!isSchemaFetching && !!schemaError ? ( + + {translate('AddExternalDecisionError')} + + ) : null} + + {isSchemaFetched && !schemaError ? ( +
+ {schema.map((schema) => { + return ( + + ); + })} +
+ ) : null} +
+ + + + +
+ ); +} + +export default AddExternalDecisionModalContent; diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/EditExternalDecisionModal.tsx b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/EditExternalDecisionModal.tsx new file mode 100644 index 000000000..445be38f0 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/EditExternalDecisionModal.tsx @@ -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 ( + + + + ); +} + +export default EditExternalDecisionModal; diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/EditExternalDecisionModalContent.css b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/EditExternalDecisionModalContent.css new file mode 100644 index 000000000..8e1c16507 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/EditExternalDecisionModalContent.css @@ -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; +} diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/EditExternalDecisionModalContent.css.d.ts b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/EditExternalDecisionModalContent.css.d.ts new file mode 100644 index 000000000..6b5941c3b --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/EditExternalDecisionModalContent.css.d.ts @@ -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; diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/EditExternalDecisionModalContent.tsx b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/EditExternalDecisionModalContent.tsx new file mode 100644 index 000000000..8d0e4e2b9 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/EditExternalDecisionModalContent.tsx @@ -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) => { + 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 ( + + + {id + ? translate('EditExternalDecisionImplementation', { + implementationName, + }) + : translate('AddExternalDecisionImplementation', { + implementationName, + })} + + + +
+ {message ? ( + + {message.value.message} + + ) : null} + + + {translate('Name')} + + + + + + {translate('Enable')} + + + + + + {translate('DecisionType')} + + + + + + {translate('ExternalDecisionPriority')} + + + + + + {translate('Tags')} + + + + + {fields.map((field) => { + return ( + + ); + })} + +
+ + + {id ? ( + + ) : null} + + + + + {translate('Test')} + + + + + + {translate('Save')} + + +
+ ); +} + +export default EditExternalDecisionModalContent; diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecision.css b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecision.css new file mode 100644 index 000000000..23018a724 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecision.css @@ -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; +} diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecision.css.d.ts b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecision.css.d.ts new file mode 100644 index 000000000..f6b7c12e9 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecision.css.d.ts @@ -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; diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecision.tsx b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecision.tsx new file mode 100644 index 000000000..081859d29 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecision.tsx @@ -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 ( + +
{name}
+ + + + {showPriority ? ( + + ) : null} + + {enable ? null : ( + + )} + + + + + + +
+ ); +} + +export default ExternalDecision; diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecisions.css b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecisions.css new file mode 100644 index 000000000..a9a720266 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecisions.css @@ -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); +} diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecisions.css.d.ts b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecisions.css.d.ts new file mode 100644 index 000000000..390df7670 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecisions.css.d.ts @@ -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; diff --git a/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecisions.tsx b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecisions.tsx new file mode 100644 index 000000000..0f1a5d267 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/ExternalDecisions/ExternalDecisions.tsx @@ -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 ( +
+ +
+ {items.map((item) => ( + + ))} + + +
+ +
+
+
+ + + + +
+
+ ); +} + +export default ExternalDecisions; diff --git a/frontend/src/Settings/ExternalDecisions/useExternalDecisions.ts b/frontend/src/Settings/ExternalDecisions/useExternalDecisions.ts new file mode 100644 index 000000000..935ea4633 --- /dev/null +++ b/frontend/src/Settings/ExternalDecisions/useExternalDecisions.ts @@ -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({ + path: PATH, + }); +}; + +export const useManageExternalDecision = ( + id: number | undefined, + selectedSchema?: SelectedSchema +) => { + const schema = useSelectedSchema(PATH, selectedSchema); + + if (selectedSchema && !schema) { + throw new Error( + 'A selected schema is required to manage an external decision' + ); + } + + const manage = useManageProviderSettings( + 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(id, PATH); + + return { + ...result, + deleteExternalDecision: result.deleteProvider, + }; +}; + +export const useExternalDecisionSchema = (enabled: boolean = true) => { + return useProviderSchema(PATH, enabled); +}; diff --git a/frontend/src/Settings/Settings.tsx b/frontend/src/Settings/Settings.tsx index c42cd9a7d..c65f31297 100644 --- a/frontend/src/Settings/Settings.tsx +++ b/frontend/src/Settings/Settings.tsx @@ -76,6 +76,14 @@ function Settings() { {translate('ConnectSettingsSummary')} + + {translate('ExternalDecisions')} + + +
+ {translate('ExternalDecisionSettingsSummary')} +
+ {translate('Metadata')} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/ExternalPrioritizationServiceFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/ExternalPrioritizationServiceFixture.cs new file mode 100644 index 000000000..e35352286 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/ExternalPrioritizationServiceFixture.cs @@ -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 + { + private List _decisions; + private Series _series; + private Mock _decisionMock; + private ExternalDecisionDefinition _decisionDefinition; + + [SetUp] + public void Setup() + { + _series = new Series + { + Id = 1, + TvdbId = 123456, + Title = "Test Series", + Tags = new HashSet { 1, 2 }, + QualityProfileId = 1 + }; + + _decisions = new List + { + 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(), + Enable = true + }; + + _decisionMock = new Mock(); + _decisionMock.SetupGet(h => h.Definition).Returns(_decisionDefinition); + + Mocker.GetMock() + .Setup(f => f.PrioritizationDecisionsEnabled()) + .Returns(new List()); + } + + 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 + { + new Episode + { + Id = 100, + SeasonNumber = 1, + EpisodeNumber = 1, + Title = "Pilot" + } + }, + CustomFormats = new List(), + Languages = new List() + }; + + return new DownloadDecision(remoteEpisode); + } + + private void GivenDecisionReturnsScores(Dictionary scores) + { + _decisionMock.Setup(h => h.EvaluatePrioritization(It.IsAny())) + .Returns(new ExternalPrioritizationResponse { Scores = scores }); + + Mocker.GetMock() + .Setup(f => f.PrioritizationDecisionsEnabled()) + .Returns(new List { _decisionMock.Object }); + } + + private void GivenDecisionReturnsEmpty() + { + _decisionMock.Setup(h => h.EvaluatePrioritization(It.IsAny())) + .Returns(new ExternalPrioritizationResponse { Scores = new Dictionary() }); + + Mocker.GetMock() + .Setup(f => f.PrioritizationDecisionsEnabled()) + .Returns(new List { _decisionMock.Object }); + } + + private void GivenDecisionReturnsNull() + { + _decisionMock.Setup(h => h.EvaluatePrioritization(It.IsAny())) + .Returns((ExternalPrioritizationResponse)null); + + Mocker.GetMock() + .Setup(f => f.PrioritizationDecisionsEnabled()) + .Returns(new List { _decisionMock.Object }); + } + + private void GivenDecisionThrows() + { + _decisionMock.Setup(h => h.EvaluatePrioritization(It.IsAny())) + .Throws(new Exception("Connection timeout")); + + Mocker.GetMock() + .Setup(f => f.PrioritizationDecisionsEnabled()) + .Returns(new List { _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 + { + { "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()), 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()), Times.Once); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_record_failure_on_exception() + { + GivenDecisionThrows(); + + Subject.PopulateExternalPriorityScores(_decisions); + + Mocker.GetMock() + .Verify(s => s.RecordFailure(1), Times.Once); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_record_success_on_score_assignment() + { + GivenDecisionReturnsScores(new Dictionary { { "guid-3", 100 }, { "guid-1", 50 }, { "guid-2", 75 } }); + + Subject.PopulateExternalPriorityScores(_decisions); + + Mocker.GetMock() + .Verify(s => s.RecordSuccess(1), Times.Once); + } + + [Test] + public void should_assign_zero_score_to_guids_missing_from_response() + { + GivenDecisionReturnsScores(new Dictionary { { "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 + { + { "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(); + var definition1 = new ExternalDecisionDefinition + { + Id = 1, + Name = "Decision 1", + DecisionType = ExternalDecisionType.Prioritization, + Tags = new HashSet(), + Enable = true + }; + decision1.SetupGet(h => h.Definition).Returns(definition1); + decision1.Setup(h => h.EvaluatePrioritization(It.IsAny())) + .Returns(new ExternalPrioritizationResponse { Scores = new Dictionary { { "guid-3", 100 }, { "guid-2", 50 }, { "guid-1", 25 } } }); + + var decision2 = new Mock(); + var definition2 = new ExternalDecisionDefinition + { + Id = 2, + Name = "Decision 2", + DecisionType = ExternalDecisionType.Prioritization, + Tags = new HashSet(), + Enable = true + }; + decision2.SetupGet(h => h.Definition).Returns(definition2); + decision2.Setup(h => h.EvaluatePrioritization(It.IsAny())) + .Returns(new ExternalPrioritizationResponse { Scores = new Dictionary { { "guid-1", 200 }, { "guid-3", 150 }, { "guid-2", 10 } } }); + + Mocker.GetMock() + .Setup(f => f.PrioritizationDecisionsEnabled()) + .Returns(new List { 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(), + CustomFormats = new List(), + Languages = new List() + }); + + var allDecisions = _decisions.Concat(new[] { noSeriesDecision }).ToList(); + + GivenDecisionReturnsScores(new Dictionary { { "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())) + .Callback(r => capturedRequest = r) + .Returns(new ExternalPrioritizationResponse { Scores = new Dictionary() }); + + Mocker.GetMock() + .Setup(f => f.PrioritizationDecisionsEnabled()) + .Returns(new List { _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(); + var definition1 = new ExternalDecisionDefinition + { + Id = 1, + Name = "Decision 1", + DecisionType = ExternalDecisionType.Prioritization, + Tags = new HashSet(), + Enable = true + }; + decision1.SetupGet(h => h.Definition).Returns(definition1); + decision1.Setup(h => h.EvaluatePrioritization(It.IsAny())) + .Throws(new Exception("Decision 1 failure")); + + var decision2 = new Mock(); + var definition2 = new ExternalDecisionDefinition + { + Id = 2, + Name = "Decision 2", + DecisionType = ExternalDecisionType.Prioritization, + Tags = new HashSet(), + Enable = true + }; + decision2.SetupGet(h => h.Definition).Returns(definition2); + decision2.Setup(h => h.EvaluatePrioritization(It.IsAny())) + .Returns(new ExternalPrioritizationResponse { Scores = new Dictionary { { "guid-3", 100 }, { "guid-1", 50 }, { "guid-2", 75 } } }); + + Mocker.GetMock() + .Setup(f => f.PrioritizationDecisionsEnabled()) + .Returns(new List { 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()), Times.Once); + decision2.Verify(h => h.EvaluatePrioritization(It.IsAny()), Times.Once); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_skip_decision_when_tags_dont_match() + { + _decisionDefinition.Tags = new HashSet { 99 }; + + _decisionMock.Setup(h => h.EvaluatePrioritization(It.IsAny())) + .Returns(new ExternalPrioritizationResponse { Scores = new Dictionary { { "guid-3", 100 }, { "guid-1", 50 }, { "guid-2", 75 } } }); + + Mocker.GetMock() + .Setup(f => f.PrioritizationDecisionsEnabled()) + .Returns(new List { _decisionMock.Object }); + + Subject.PopulateExternalPriorityScores(_decisions); + + _decisions.Should().OnlyContain(d => d.RemoteEpisode.ExternalPriorityScore == 0); + + _decisionMock.Verify(h => h.EvaluatePrioritization(It.IsAny()), Times.Never); + } + + [Test] + public void should_apply_decision_when_decision_has_no_tags() + { + _decisionDefinition.Tags = new HashSet(); + + GivenDecisionReturnsScores(new Dictionary { { "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()), Times.Once); + } + + [Test] + public void should_apply_decision_when_tags_intersect() + { + _decisionDefinition.Tags = new HashSet { 2, 5 }; + + GivenDecisionReturnsScores(new Dictionary { { "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()), Times.Once); + } + + [Test] + public void should_skip_decision_when_series_has_no_tags_and_decision_has_tags() + { + _decisionDefinition.Tags = new HashSet { 1 }; + _series.Tags = new HashSet(); + + _decisionMock.Setup(h => h.EvaluatePrioritization(It.IsAny())) + .Returns(new ExternalPrioritizationResponse { Scores = new Dictionary { { "guid-3", 100 }, { "guid-1", 50 }, { "guid-2", 75 } } }); + + Mocker.GetMock() + .Setup(f => f.PrioritizationDecisionsEnabled()) + .Returns(new List { _decisionMock.Object }); + + Subject.PopulateExternalPriorityScores(_decisions); + + _decisions.Should().OnlyContain(d => d.RemoteEpisode.ExternalPriorityScore == 0); + + _decisionMock.Verify(h => h.EvaluatePrioritization(It.IsAny()), Times.Never); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/ExternalRejectionSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/ExternalRejectionSpecificationFixture.cs new file mode 100644 index 000000000..aa3981303 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/ExternalRejectionSpecificationFixture.cs @@ -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 + { + private RemoteEpisode _remoteEpisode; + private Mock _decisionMock; + private ExternalDecisionDefinition _decisionDefinition; + + [SetUp] + public void Setup() + { + _remoteEpisode = new RemoteEpisode + { + Series = new Series + { + Id = 1, + TvdbId = 123456, + Title = "Test Series", + Tags = new HashSet { 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 + { + new Episode + { + Id = 100, + SeasonNumber = 1, + EpisodeNumber = 1, + Title = "Pilot" + } + }, + CustomFormats = new List(), + Languages = new List() + }; + + _decisionDefinition = new ExternalDecisionDefinition + { + Id = 1, + Name = "Test Decision", + DecisionType = ExternalDecisionType.Rejection, + Tags = new HashSet(), + Enable = true + }; + + _decisionMock = new Mock(); + _decisionMock.SetupGet(h => h.Definition).Returns(_decisionDefinition); + + Mocker.GetMock() + .Setup(f => f.RejectionDecisionsEnabled()) + .Returns(new List()); + } + + private void GivenHookApproves() + { + _decisionMock.Setup(h => h.EvaluateRejection(It.IsAny())) + .Returns(new ExternalRejectionResponse { Approved = true }); + + Mocker.GetMock() + .Setup(f => f.RejectionDecisionsEnabled()) + .Returns(new List { _decisionMock.Object }); + } + + private void GivenHookRejects(string reason) + { + _decisionMock.Setup(h => h.EvaluateRejection(It.IsAny())) + .Returns(new ExternalRejectionResponse { Approved = false, Reason = reason }); + + Mocker.GetMock() + .Setup(f => f.RejectionDecisionsEnabled()) + .Returns(new List { _decisionMock.Object }); + } + + private void GivenHookThrows() + { + _decisionMock.Setup(h => h.EvaluateRejection(It.IsAny())) + .Throws(new Exception("Connection timeout")); + + Mocker.GetMock() + .Setup(f => f.RejectionDecisionsEnabled()) + .Returns(new List { _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 { 99 }; + + _decisionMock.Setup(h => h.EvaluateRejection(It.IsAny())) + .Returns(new ExternalRejectionResponse { Approved = false, Reason = "Should not reach" }); + + Mocker.GetMock() + .Setup(f => f.RejectionDecisionsEnabled()) + .Returns(new List { _decisionMock.Object }); + + Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue(); + + _decisionMock.Verify(h => h.EvaluateRejection(It.IsAny()), Times.Never); + } + + [Test] + public void should_apply_decision_when_decision_has_no_tags() + { + _decisionDefinition.Tags = new HashSet(); + + 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 { 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(); + var definition1 = new ExternalDecisionDefinition + { + Id = 1, + Name = "Decision 1", + DecisionType = ExternalDecisionType.Rejection, + Tags = new HashSet(), + Enable = true + }; + decision1.SetupGet(h => h.Definition).Returns(definition1); + decision1.Setup(h => h.EvaluateRejection(It.IsAny())) + .Returns(new ExternalRejectionResponse { Approved = true }); + + var decision2 = new Mock(); + var definition2 = new ExternalDecisionDefinition + { + Id = 2, + Name = "Decision 2", + DecisionType = ExternalDecisionType.Rejection, + Tags = new HashSet(), + Enable = true + }; + decision2.SetupGet(h => h.Definition).Returns(definition2); + decision2.Setup(h => h.EvaluateRejection(It.IsAny())) + .Returns(new ExternalRejectionResponse { Approved = false, Reason = "Rejected by decision 2" }); + + Mocker.GetMock() + .Setup(f => f.RejectionDecisionsEnabled()) + .Returns(new List { decision1.Object, decision2.Object }); + + var result = Subject.IsSatisfiedBy(_remoteEpisode, new()); + + result.Accepted.Should().BeFalse(); + decision1.Verify(h => h.EvaluateRejection(It.IsAny()), Times.Once); + decision2.Verify(h => h.EvaluateRejection(It.IsAny()), 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() + .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() + .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())) + .Callback(r => capturedRequest = r) + .Returns(new ExternalRejectionResponse { Approved = true }); + + Mocker.GetMock() + .Setup(f => f.RejectionDecisionsEnabled()) + .Returns(new List { _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 { 1 }; + _remoteEpisode.Series.Tags = new HashSet(); + + GivenHookRejects("Should not reach"); + + Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue(); + + _decisionMock.Verify(h => h.EvaluateRejection(It.IsAny()), Times.Never); + } + + [Test] + public void should_record_success_on_rejection() + { + GivenHookRejects("Some reason"); + + Subject.IsSatisfiedBy(_remoteEpisode, new()); + + Mocker.GetMock() + .Verify(s => s.RecordSuccess(1), Times.Once); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs index 9c2120212..bd36be1a9 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs @@ -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() + .Setup(s => s.PopulateExternalPriorityScores(It.IsAny>())); + _series = Builder.CreateNew() .With(e => e.Runtime = 60) .With(e => e.QualityProfile = new QualityProfile diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/WebhookExternalDecisionProxyFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/WebhookExternalDecisionProxyFixture.cs new file mode 100644 index 000000000..de409cf85 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/WebhookExternalDecisionProxyFixture.cs @@ -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 + { + 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() + .Setup(c => c.Post(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), json, HttpStatusCode.OK)); + } + + private void GivenEmptyResponse() + { + Mocker.GetMock() + .Setup(c => c.Post(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), string.Empty, HttpStatusCode.OK)); + } + + private void GivenNoContentResponse() + { + Mocker.GetMock() + .Setup(c => c.Post(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), Array.Empty(), HttpStatusCode.NoContent)); + } + + private void GivenErrorResponse(HttpStatusCode statusCode) + { + Mocker.GetMock() + .Setup(c => c.Post(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), "error", statusCode)); + } + + [Test] + public void should_send_post_request() + { + GivenSuccessfulResponse("{\"approved\": true}"); + + Subject.SendRejectionRequest(_payload, _settings); + + Mocker.GetMock() + .Verify(c => c.Post(It.IsAny()), Times.Once); + } + + [Test] + public void should_set_api_key_header() + { + HttpRequest capturedRequest = null; + + Mocker.GetMock() + .Setup(c => c.Post(It.IsAny())) + .Callback(r => capturedRequest = r) + .Returns(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() + .Setup(c => c.Post(It.IsAny())) + .Callback(r => capturedRequest = r) + .Returns(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() + .Setup(c => c.Post(It.IsAny())) + .Callback(r => capturedRequest = r) + .Returns(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() + .Setup(c => c.Post(It.IsAny())) + .Callback(r => capturedRequest = r) + .Returns(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() + .Setup(c => c.Post(It.IsAny())) + .Callback(r => capturedRequest = r) + .Returns(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() + .Setup(c => c.Post(It.IsAny())) + .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() + .Setup(c => c.Post(It.IsAny())) + .Callback(r => capturedRequest = r) + .Returns(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(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() + .Setup(c => c.Post(It.IsAny())) + .Callback(r => capturedRequest = r) + .Returns(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(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/228_add_external_decisions.cs b/src/NzbDrone.Core/Datastore/Migration/228_add_external_decisions.cs new file mode 100644 index 000000000..16a6ee17d --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/228_add_external_decisions.cs @@ -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(); + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 8d4839478..0ea3bdc58 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -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("ImportListExclusions").RegisterModel(); Mapper.Entity("AutoTagging").RegisterModel(); + + Mapper.Entity("ExternalDecisions").RegisterModel() + .Ignore(x => x.ImplementationName); + + Mapper.Entity("ExternalDecisionStatus").RegisterModel(); } private static void RegisterMappers() diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs index e7a68d864..a6fd00b62 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs @@ -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 => diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs index 69c49c480..5f4828c9d 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs @@ -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 PrioritizeDecisions(List decisions) { + _externalPrioritizationService.PopulateExternalPriorityScores(decisions); + return decisions.Where(c => c.RemoteEpisode.Series != null) .GroupBy(c => c.RemoteEpisode.Series.Id, (seriesId, downloadDecisions) => { diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadRejectionReason.cs b/src/NzbDrone.Core/DecisionEngine/DownloadRejectionReason.cs index f4e36ed4f..f06c98afd 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadRejectionReason.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadRejectionReason.cs @@ -76,5 +76,6 @@ public enum DownloadRejectionReason DiskCustomFormatScoreIncrement, DiskUpgradesNotAllowed, DiskNotUpgrade, - BeforeAirDate + BeforeAirDate, + ExternalRejection } diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionBase.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionBase.cs new file mode 100644 index 000000000..501c03511 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionBase.cs @@ -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 : IExternalDecision + where TSettings : ExternalDecisionSettingsBase, new() + { + public abstract string Name { get; } + public Type ConfigContract => typeof(TSettings); + public virtual ProviderMessage Message => null; + public IEnumerable DefaultDefinitions => new List(); + 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 query) + { + return null; + } + + public override string ToString() + { + return GetType().Name; + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionDefinition.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionDefinition.cs new file mode 100644 index 000000000..8a42fb49d --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionDefinition.cs @@ -0,0 +1,31 @@ +using System; +using Equ; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.DecisionEngine.ExternalDecisions +{ + public class ExternalDecisionDefinition : ProviderDefinition, IEquatable + { + public const int DefaultPriority = 25; + + private static readonly MemberwiseEqualityComparer Comparer = MemberwiseEqualityComparer.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); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionFactory.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionFactory.cs new file mode 100644 index 000000000..26c2b199c --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionFactory.cs @@ -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 + { + List RejectionDecisionsEnabled(); + List PrioritizationDecisionsEnabled(); + } + + public class ExternalDecisionFactory : ProviderFactory, IExternalDecisionFactory + { + private readonly IExternalDecisionStatusService _externalDecisionStatusService; + private readonly Logger _logger; + + public ExternalDecisionFactory(IExternalDecisionStatusService externalDecisionStatusService, + IExternalDecisionRepository providerRepository, + IEnumerable providers, + IServiceProvider container, + IEventAggregator eventAggregator, + Logger logger) + : base(providerRepository, providers, container, eventAggregator, logger) + { + _externalDecisionStatusService = externalDecisionStatusService; + _logger = logger; + } + + protected override List Active() + { + return base.Active().Where(c => c.Enable).ToList(); + } + + public List 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 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 FilterBlockedDecisions(IEnumerable 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; + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionRepository.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionRepository.cs new file mode 100644 index 000000000..dc7ef82ce --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionRepository.cs @@ -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 + { + } + + public class ExternalDecisionRepository : ProviderRepository, IExternalDecisionRepository + { + public ExternalDecisionRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionSettingsBase.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionSettingsBase.cs new file mode 100644 index 000000000..e26d93027 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionSettingsBase.cs @@ -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 : IProviderConfig, IEquatable + where TSettings : ExternalDecisionSettingsBase + { + private static readonly MemberwiseEqualityComparer Comparer = MemberwiseEqualityComparer.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); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionStatus.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionStatus.cs new file mode 100644 index 000000000..5b519705b --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionStatus.cs @@ -0,0 +1,8 @@ +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.DecisionEngine.ExternalDecisions +{ + public class ExternalDecisionStatus : ProviderStatusBase + { + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionStatusRepository.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionStatusRepository.cs new file mode 100644 index 000000000..3948f58ab --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionStatusRepository.cs @@ -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 + { + } + + public class ExternalDecisionStatusRepository : ProviderStatusRepository, IExternalDecisionStatusRepository + { + public ExternalDecisionStatusRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionStatusService.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionStatusService.cs new file mode 100644 index 000000000..d99ca5634 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionStatusService.cs @@ -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 + { + } + + public class ExternalDecisionStatusService : ProviderStatusServiceBase, IExternalDecisionStatusService + { + public ExternalDecisionStatusService(IExternalDecisionStatusRepository providerStatusRepository, IEventAggregator eventAggregator, IRuntimeInfo runtimeInfo, Logger logger) + : base(providerStatusRepository, eventAggregator, runtimeInfo, logger) + { + MinimumTimeSinceInitialFailure = TimeSpan.FromMinutes(5); + MaximumEscalationLevel = 5; + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionType.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionType.cs new file mode 100644 index 000000000..7c03dbfa5 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalDecisionType.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.DecisionEngine.ExternalDecisions +{ + public enum ExternalDecisionType + { + Rejection = 0, + Prioritization = 1 + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalPrioritizationService.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalPrioritizationService.cs new file mode 100644 index 000000000..797b3b9d1 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/ExternalPrioritizationService.cs @@ -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 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 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 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 decisions, Dictionary 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 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(), + CustomFormatScore = d.RemoteEpisode.CustomFormatScore, + Size = release.Size, + Protocol = release.DownloadProtocol.ToString().ToLowerInvariant(), + Languages = d.RemoteEpisode.Languages ?? new List(), + 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() + }; + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/IExternalDecision.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/IExternalDecision.cs new file mode 100644 index 000000000..1eb68f08b --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/IExternalDecision.cs @@ -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); + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalEpisodePayload.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalEpisodePayload.cs new file mode 100644 index 000000000..fa156a97a --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalEpisodePayload.cs @@ -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; } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalExistingFilePayload.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalExistingFilePayload.cs new file mode 100644 index 000000000..b157e32d7 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalExistingFilePayload.cs @@ -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 Languages { get; set; } + public string RelativePath { get; set; } + public string ReleaseGroup { get; set; } + public string SceneName { get; set; } + public DateTime DateAdded { get; set; } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalPrioritizationRequest.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalPrioritizationRequest.cs new file mode 100644 index 000000000..75316d061 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalPrioritizationRequest.cs @@ -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 Releases { get; set; } + public ExternalSeriesPayload Series { get; set; } + public List Episodes { get; set; } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalPrioritizationResponse.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalPrioritizationResponse.cs new file mode 100644 index 000000000..518fd6f82 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalPrioritizationResponse.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads +{ + public class ExternalPrioritizationResponse + { + public Dictionary Scores { get; set; } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalRejectionRequest.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalRejectionRequest.cs new file mode 100644 index 000000000..30aa1c9ea --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalRejectionRequest.cs @@ -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 Episodes { get; set; } + public List ExistingFiles { get; set; } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalRejectionResponse.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalRejectionResponse.cs new file mode 100644 index 000000000..e1b39e2b1 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalRejectionResponse.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.DecisionEngine.ExternalDecisions.Payloads +{ + public class ExternalRejectionResponse + { + public bool Approved { get; set; } = true; + public string Reason { get; set; } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalReleasePayload.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalReleasePayload.cs new file mode 100644 index 000000000..540e7df87 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalReleasePayload.cs @@ -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 CustomFormats { get; set; } + public int CustomFormatScore { get; set; } + public long Size { get; set; } + public string Protocol { get; set; } + public List 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; } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalSeriesPayload.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalSeriesPayload.cs new file mode 100644 index 000000000..e9e9ee02c --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/Payloads/ExternalSeriesPayload.cs @@ -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 Tags { get; set; } + public int QualityProfileId { get; set; } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/WebhookExternalDecision.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/WebhookExternalDecision.cs new file mode 100644 index 000000000..ffd6eb915 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/WebhookExternalDecision.cs @@ -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 + { + 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(); + + try + { + var testSeries = new ExternalSeriesPayload + { + Id = 0, + TvdbId = 0, + Title = "Test Series", + Year = 2024, + Status = "Continuing", + SeriesType = "Standard", + Runtime = 45, + Tags = new HashSet(), + QualityProfileId = 0 + }; + + var testEpisodes = new List + { + 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 episodes) + { + return new ExternalRejectionRequest + { + DecisionType = nameof(ExternalDecisionType.Rejection), + Release = BuildTestRelease("test-guid"), + Series = series, + Episodes = episodes + }; + } + + private static ExternalPrioritizationRequest BuildTestPrioritizationRequest(ExternalSeriesPayload series, List episodes) + { + return new ExternalPrioritizationRequest + { + DecisionType = nameof(ExternalDecisionType.Prioritization), + Releases = new List + { + 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(), + Languages = new List() + }; + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/WebhookExternalDecisionProxy.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/WebhookExternalDecisionProxy.cs new file mode 100644 index 000000000..41b97636c --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/WebhookExternalDecisionProxy.cs @@ -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(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(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; + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/WebhookExternalDecisionSettings.cs b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/WebhookExternalDecisionSettings.cs new file mode 100644 index 000000000..290f29cf8 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/ExternalDecisions/WebhookExternalDecisionSettings.cs @@ -0,0 +1,39 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.DecisionEngine.ExternalDecisions +{ + public class WebhookExternalDecisionSettingsValidator : AbstractValidator + { + public WebhookExternalDecisionSettingsValidator() + { + RuleFor(c => c.Url).IsValidUrl(); + RuleFor(c => c.Timeout).InclusiveBetween(1, 120); + } + } + + public class WebhookExternalDecisionSettings : ExternalDecisionSettingsBase + { + 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)); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/SpecificationPriority.cs b/src/NzbDrone.Core/DecisionEngine/SpecificationPriority.cs index e3eb0b9d7..9bce5213e 100644 --- a/src/NzbDrone.Core/DecisionEngine/SpecificationPriority.cs +++ b/src/NzbDrone.Core/DecisionEngine/SpecificationPriority.cs @@ -5,6 +5,7 @@ public enum SpecificationPriority Default = 0, Parsing = 0, Database = 0, - Disk = 1 + Disk = 1, + External = 2 } } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/ExternalRejectionSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/ExternalRejectionSpecification.cs new file mode 100644 index 000000000..4af2a1000 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/ExternalRejectionSpecification.cs @@ -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(), + CustomFormatScore = subject.CustomFormatScore, + Size = release.Size, + Protocol = release.DownloadProtocol.ToString().ToLowerInvariant(), + Languages = subject.Languages ?? new List(), + 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(), + RelativePath = ef.RelativePath, + ReleaseGroup = ef.ReleaseGroup, + SceneName = ef.SceneName, + DateAdded = ef.DateAdded + }) + .ToList() + }; + } + } +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index db9577107..25eadd5f3 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -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", diff --git a/src/NzbDrone.Core/Parser/Model/RemoteEpisode.cs b/src/NzbDrone.Core/Parser/Model/RemoteEpisode.cs index 62e788ae7..278842459 100644 --- a/src/NzbDrone.Core/Parser/Model/RemoteEpisode.cs +++ b/src/NzbDrone.Core/Parser/Model/RemoteEpisode.cs @@ -22,6 +22,7 @@ public class RemoteEpisode public TorrentSeedConfiguration SeedConfiguration { get; set; } public List CustomFormats { get; set; } public int CustomFormatScore { get; set; } + public int ExternalPriorityScore { get; set; } public SeriesMatchType SeriesMatchType { get; set; } public List Languages { get; set; } public ReleaseSourceType ReleaseSource { get; set; } diff --git a/src/Sonarr.Api.V5/ExternalDecision/ExternalDecisionBulkResource.cs b/src/Sonarr.Api.V5/ExternalDecision/ExternalDecisionBulkResource.cs new file mode 100644 index 000000000..7820b630b --- /dev/null +++ b/src/Sonarr.Api.V5/ExternalDecision/ExternalDecisionBulkResource.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.DecisionEngine.ExternalDecisions; +using Sonarr.Api.V5.Provider; + +namespace Sonarr.Api.V5.ExternalDecision; + +public class ExternalDecisionBulkResource : ProviderBulkResource +{ +} + +public class ExternalDecisionBulkResourceMapper : ProviderBulkResourceMapper +{ +} diff --git a/src/Sonarr.Api.V5/ExternalDecision/ExternalDecisionController.cs b/src/Sonarr.Api.V5/ExternalDecision/ExternalDecisionController.cs new file mode 100644 index 000000000..9a7af76f5 --- /dev/null +++ b/src/Sonarr.Api.V5/ExternalDecision/ExternalDecisionController.cs @@ -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 +{ + 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 UpdateProvider([FromBody] ExternalDecisionBulkResource providerResource) + { + throw new NotImplementedException(); + } + + [NonAction] + public override ActionResult DeleteProviders([FromBody] ExternalDecisionBulkResource resource) + { + throw new NotImplementedException(); + } +} diff --git a/src/Sonarr.Api.V5/ExternalDecision/ExternalDecisionResource.cs b/src/Sonarr.Api.V5/ExternalDecision/ExternalDecisionResource.cs new file mode 100644 index 000000000..157b53244 --- /dev/null +++ b/src/Sonarr.Api.V5/ExternalDecision/ExternalDecisionResource.cs @@ -0,0 +1,36 @@ +using NzbDrone.Core.DecisionEngine.ExternalDecisions; +using Sonarr.Api.V5.Provider; + +namespace Sonarr.Api.V5.ExternalDecision; + +public class ExternalDecisionResource : ProviderResource +{ + public bool Enable { get; set; } + public ExternalDecisionType DecisionType { get; set; } + public int Priority { get; set; } +} + +public class ExternalDecisionResourceMapper : ProviderResourceMapper +{ + 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; + } +} diff --git a/src/Sonarr.Api.V5/Release/ReleaseResource.cs b/src/Sonarr.Api.V5/Release/ReleaseResource.cs index 1f7407e78..dfad0eb2f 100644 --- a/src/Sonarr.Api.V5/Release/ReleaseResource.cs +++ b/src/Sonarr.Api.V5/Release/ReleaseResource.cs @@ -26,6 +26,7 @@ public class ReleaseResource : RestResource public int ReleaseWeight { get; set; } public List? 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(), };