New: List Support

Closes #309
This commit is contained in:
Qstick 2019-11-10 16:02:24 -05:00 committed by Mark McDowall
parent 49eb3ab2cf
commit 62f6c855bc
91 changed files with 4161 additions and 32 deletions

View File

@ -21,6 +21,7 @@ import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementCo
import Profiles from 'Settings/Profiles/Profiles';
import Quality from 'Settings/Quality/Quality';
import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import NotificationSettings from 'Settings/Notifications/NotificationSettings';
import MetadataSettings from 'Settings/Metadata/MetadataSettings';
@ -170,6 +171,11 @@ function AppRoutes(props) {
component={DownloadClientSettingsConnector}
/>
<Route
path="/settings/importlists"
component={ImportListSettingsConnector}
/>
<Route
path="/settings/connect"
component={NotificationSettings}

View File

@ -8,7 +8,7 @@ import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
import { fetchSeries } from 'Store/Actions/seriesActions';
import { fetchTags } from 'Store/Actions/tagActions';
import { fetchQualityProfiles, fetchLanguageProfiles, fetchUISettings } from 'Store/Actions/settingsActions';
import { fetchQualityProfiles, fetchLanguageProfiles, fetchImportLists, fetchUISettings } from 'Store/Actions/settingsActions';
import { fetchStatus } from 'Store/Actions/systemActions';
import ErrorPage from './ErrorPage';
import LoadingPage from './LoadingPage';
@ -49,6 +49,7 @@ const selectIsPopulated = createSelector(
(state) => state.settings.ui.isPopulated,
(state) => state.settings.qualityProfiles.isPopulated,
(state) => state.settings.languageProfiles.isPopulated,
(state) => state.settings.importLists.isPopulated,
(state) => state.system.status.isPopulated,
(
seriesIsPopulated,
@ -57,6 +58,7 @@ const selectIsPopulated = createSelector(
uiSettingsIsPopulated,
qualityProfilesIsPopulated,
languageProfilesIsPopulated,
importListsIsPopulated,
systemStatusIsPopulated
) => {
return (
@ -66,6 +68,7 @@ const selectIsPopulated = createSelector(
uiSettingsIsPopulated &&
qualityProfilesIsPopulated &&
languageProfilesIsPopulated &&
importListsIsPopulated &&
systemStatusIsPopulated
);
}
@ -78,6 +81,7 @@ const selectErrors = createSelector(
(state) => state.settings.ui.error,
(state) => state.settings.qualityProfiles.error,
(state) => state.settings.languageProfiles.error,
(state) => state.settings.importLists.error,
(state) => state.system.status.error,
(
seriesError,
@ -86,6 +90,7 @@ const selectErrors = createSelector(
uiSettingsError,
qualityProfilesError,
languageProfilesError,
importListsError,
systemStatusError
) => {
const hasError = !!(
@ -95,6 +100,7 @@ const selectErrors = createSelector(
uiSettingsError ||
qualityProfilesError ||
languageProfilesError ||
importListsError ||
systemStatusError
);
@ -106,6 +112,7 @@ const selectErrors = createSelector(
uiSettingsError,
qualityProfilesError,
languageProfilesError,
importListsError,
systemStatusError
};
}
@ -153,6 +160,9 @@ function createMapDispatchToProps(dispatch, props) {
dispatchFetchLanguageProfiles() {
dispatch(fetchLanguageProfiles());
},
dispatchFetchImportLists() {
dispatch(fetchImportLists());
},
dispatchFetchUISettings() {
dispatch(fetchUISettings());
},
@ -188,6 +198,7 @@ class PageConnector extends Component {
this.props.dispatchFetchTags();
this.props.dispatchFetchQualityProfiles();
this.props.dispatchFetchLanguageProfiles();
this.props.dispatchFetchImportLists();
this.props.dispatchFetchUISettings();
this.props.dispatchFetchStatus();
}
@ -211,6 +222,7 @@ class PageConnector extends Component {
dispatchFetchTags,
dispatchFetchQualityProfiles,
dispatchFetchLanguageProfiles,
dispatchFetchImportLists,
dispatchFetchUISettings,
dispatchFetchStatus,
...otherProps
@ -249,6 +261,7 @@ PageConnector.propTypes = {
dispatchFetchTags: PropTypes.func.isRequired,
dispatchFetchQualityProfiles: PropTypes.func.isRequired,
dispatchFetchLanguageProfiles: PropTypes.func.isRequired,
dispatchFetchImportLists: PropTypes.func.isRequired,
dispatchFetchUISettings: PropTypes.func.isRequired,
dispatchFetchStatus: PropTypes.func.isRequired,
onSidebarVisibleChange: PropTypes.func.isRequired

View File

@ -111,6 +111,10 @@ const links = [
title: 'Download Clients',
to: '/settings/downloadclients'
},
{
title: 'Import Lists',
to: '/settings/importlists'
},
{
title: 'Connect',
to: '/settings/connect'

View File

@ -22,7 +22,8 @@ class DeleteSeriesModalContent extends Component {
super(props, context);
this.state = {
deleteFiles: false
deleteFiles: false,
addImportListExclusion: false
};
}
@ -33,11 +34,16 @@ class DeleteSeriesModalContent extends Component {
this.setState({ deleteFiles: value });
}
onAddImportListExclusionChange = ({ value }) => {
this.setState({ addImportListExclusion: value });
}
onDeleteSeriesConfirmed = () => {
const deleteFiles = this.state.deleteFiles;
const addImportListExclusion = this.state.addImportListExclusion;
this.setState({ deleteFiles: false });
this.props.onDeletePress(deleteFiles);
this.setState({ deleteFiles: false, addImportListExclusion: false });
this.props.onDeletePress(deleteFiles, addImportListExclusion);
}
//
@ -57,6 +63,7 @@ class DeleteSeriesModalContent extends Component {
} = statistics;
const deleteFiles = this.state.deleteFiles;
const addImportListExclusion = this.state.addImportListExclusion;
let deleteFilesLabel = `Delete ${episodeFileCount} Episode Files`;
let deleteFilesHelpText = 'Delete the episode files and series folder';
@ -83,6 +90,19 @@ class DeleteSeriesModalContent extends Component {
{path}
</div>
<FormGroup>
<FormLabel>Add List Exclusion</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="addImportListExclusion"
value={addImportListExclusion}
helpText="Prevent series from being added to Sonarr by lists"
kind={kinds.DANGER}
onChange={this.onAddImportListExclusionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{deleteFilesLabel}</FormLabel>

View File

@ -24,10 +24,11 @@ class DeleteSeriesModalContentConnector extends Component {
//
// Listeners
onDeletePress = (deleteFiles) => {
onDeletePress = (deleteFiles, addImportListExclusion) => {
this.props.deleteSeries({
id: this.props.seriesId,
deleteFiles
deleteFiles,
addImportListExclusion
});
this.props.onModalClose(true);

View File

@ -0,0 +1,27 @@
import PropTypes from 'prop-types';
import React from 'react';
import { sizes } from 'Helpers/Props';
import Modal from 'Components/Modal/Modal';
import EditImportListExclusionModalContentConnector from './EditImportListExclusionModalContentConnector';
function EditImportListExclusionModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditImportListExclusionModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditImportListExclusionModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditImportListExclusionModal;

View File

@ -0,0 +1,43 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditImportListExclusionModal from './EditImportListExclusionModal';
function mapStateToProps() {
return {};
}
const mapDispatchToProps = {
clearPendingChanges
};
class EditImportListExclusionModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearPendingChanges({ section: 'settings.importListExclusions' });
this.props.onModalClose();
}
//
// Render
render() {
return (
<EditImportListExclusionModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditImportListExclusionModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(mapStateToProps, mapDispatchToProps)(EditImportListExclusionModalConnector);

View File

@ -0,0 +1,11 @@
.body {
composes: modalBody from '~Components/Modal/ModalBody.css';
flex: 1 1 430px;
}
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: auto;
}

View File

@ -0,0 +1,135 @@
import PropTypes from 'prop-types';
import React from 'react';
import { inputTypes, kinds } from 'Helpers/Props';
import { stringSettingShape, numberSettingShape } from 'Helpers/Props/Shapes/settingShape';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
import styles from './EditImportListExclusionModalContent.css';
function EditImportListExclusionModalContent(props) {
const {
id,
isFetching,
error,
isSaving,
saveError,
item,
onInputChange,
onSavePress,
onModalClose,
onDeleteImportListExclusionPress,
...otherProps
} = props;
const {
title,
tvdbId
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? 'Edit Import List Exclusion' : 'Add Import List Exclusion'}
</ModalHeader>
<ModalBody className={styles.body}>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to add a new import list exclusion, please try again.</div>
}
{
!isFetching && !error &&
<Form
{...otherProps}
>
<FormGroup>
<FormLabel>Title</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="title"
helpText="The name of the series to exclude"
{...title}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>TVDB ID</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="tvdbId"
helpText="The TVDB ID of the series to exclude"
{...tvdbId}
onChange={onInputChange}
/>
</FormGroup>
</Form>
}
</ModalBody>
<ModalFooter>
{
id &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteImportListExclusionPress}
>
Delete
</Button>
}
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
Save
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
const ImportListExclusionShape = {
title: PropTypes.shape(stringSettingShape).isRequired,
tvdbId: PropTypes.shape(numberSettingShape).isRequired
};
EditImportListExclusionModalContent.propTypes = {
id: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.shape(ImportListExclusionShape).isRequired,
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteImportListExclusionPress: PropTypes.func
};
export default EditImportListExclusionModalContent;

View File

@ -0,0 +1,118 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import selectSettings from 'Store/Selectors/selectSettings';
import { setImportListExclusionValue, saveImportListExclusion } from 'Store/Actions/settingsActions';
import EditImportListExclusionModalContent from './EditImportListExclusionModalContent';
const newImportListExclusion = {
title: '',
tvdbId: 0
};
function createImportListExclusionSelector() {
return createSelector(
(state, { id }) => id,
(state) => state.settings.importListExclusions,
(id, importListExclusions) => {
const {
isFetching,
error,
isSaving,
saveError,
pendingChanges,
items
} = importListExclusions;
const mapping = id ? _.find(items, { id }) : newImportListExclusion;
const settings = selectSettings(mapping, pendingChanges, saveError);
return {
id,
isFetching,
error,
isSaving,
saveError,
item: settings.settings,
...settings
};
}
);
}
function createMapStateToProps() {
return createSelector(
createImportListExclusionSelector(),
(importListExclusion) => {
return {
...importListExclusion
};
}
);
}
const mapDispatchToProps = {
setImportListExclusionValue,
saveImportListExclusion
};
class EditImportListExclusionModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.id) {
Object.keys(newImportListExclusion).forEach((name) => {
this.props.setImportListExclusionValue({
name,
value: newImportListExclusion[name]
});
});
}
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setImportListExclusionValue({ name, value });
}
onSavePress = () => {
this.props.saveImportListExclusion({ id: this.props.id });
}
//
// Render
render() {
return (
<EditImportListExclusionModalContent
{...this.props}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
/>
);
}
}
EditImportListExclusionModalContentConnector.propTypes = {
id: PropTypes.number,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setImportListExclusionValue: PropTypes.func.isRequired,
saveImportListExclusion: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditImportListExclusionModalContentConnector);

View File

@ -0,0 +1,23 @@
.importListExclusion {
display: flex;
align-items: stretch;
margin-bottom: 10px;
height: 30px;
border-bottom: 1px solid $borderColor;
line-height: 30px;
}
.title {
flex: 0 0 300px;
}
.tvdbId {
flex: 0 0 400px;
}
.actions {
display: flex;
justify-content: flex-end;
flex: 1 0 auto;
padding-right: 10px;
}

View File

@ -0,0 +1,111 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
import { icons, kinds } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import EditImportListExclusionModalConnector from './EditImportListExclusionModalConnector';
import styles from './ImportListExclusion.css';
class ImportListExclusion extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditImportListExclusionModalOpen: false,
isDeleteImportListExclusionModalOpen: false
};
}
//
// Listeners
onEditImportListExclusionPress = () => {
this.setState({ isEditImportListExclusionModalOpen: true });
}
onEditImportListExclusionModalClose = () => {
this.setState({ isEditImportListExclusionModalOpen: false });
}
onDeleteImportListExclusionPress = () => {
this.setState({
isEditImportListExclusionModalOpen: false,
isDeleteImportListExclusionModalOpen: true
});
}
onDeleteImportListExclusionModalClose = () => {
this.setState({ isDeleteImportListExclusionModalOpen: false });
}
onConfirmDeleteImportListExclusion = () => {
this.props.onConfirmDeleteImportListExclusion(this.props.id);
}
//
// Render
render() {
const {
id,
title,
tvdbId
} = this.props;
return (
<div
className={classNames(
styles.importListExclusion
)}
>
<div className={styles.title}>{title}</div>
<div className={styles.tvdbId}>{tvdbId}</div>
<div className={styles.actions}>
<Link
onPress={this.onEditImportListExclusionPress}
>
<Icon name={icons.EDIT} />
</Link>
</div>
<EditImportListExclusionModalConnector
id={id}
isOpen={this.state.isEditImportListExclusionModalOpen}
onModalClose={this.onEditImportListExclusionModalClose}
onDeleteImportListExclusionPress={this.onDeleteImportListExclusionPress}
/>
<ConfirmModal
isOpen={this.state.isDeleteImportListExclusionModalOpen}
kind={kinds.DANGER}
title="Delete Import List Exclusion"
message="Are you sure you want to delete this import list exclusion?"
confirmLabel="Delete"
onConfirm={this.onConfirmDeleteImportListExclusion}
onCancel={this.onDeleteImportListExclusionModalClose}
/>
</div>
);
}
}
ImportListExclusion.propTypes = {
id: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
tvdbId: PropTypes.number.isRequired,
onConfirmDeleteImportListExclusion: PropTypes.func.isRequired
};
ImportListExclusion.defaultProps = {
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};
export default ImportListExclusion;

View File

@ -0,0 +1,23 @@
.importListExclusionsHeader {
display: flex;
margin-bottom: 10px;
font-weight: bold;
}
.host {
flex: 0 0 300px;
}
.path {
flex: 0 0 400px;
}
.addImportListExclusion {
display: flex;
justify-content: flex-end;
padding-right: 10px;
}
.addButton {
text-align: center;
}

View File

@ -0,0 +1,100 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import PageSectionContent from 'Components/Page/PageSectionContent';
import ImportListExclusion from './ImportListExclusion';
import EditImportListExclusionModalConnector from './EditImportListExclusionModalConnector';
import styles from './ImportListExclusions.css';
class ImportListExclusions extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddImportListExclusionModalOpen: false
};
}
//
// Listeners
onAddImportListExclusionPress = () => {
this.setState({ isAddImportListExclusionModalOpen: true });
}
onModalClose = () => {
this.setState({ isAddImportListExclusionModalOpen: false });
}
//
// Render
render() {
const {
items,
onConfirmDeleteImportListExclusion,
...otherProps
} = this.props;
return (
<FieldSet legend="Import List Exclusions">
<PageSectionContent
errorMessage="Unable to load Import List Exclusions"
{...otherProps}
>
<div className={styles.importListExclusionsHeader}>
<div className={styles.host}>Title</div>
<div className={styles.path}>TVDB ID</div>
</div>
<div>
{
items.map((item, index) => {
return (
<ImportListExclusion
key={item.id}
{...item}
{...otherProps}
index={index}
onConfirmDeleteImportListExclusion={onConfirmDeleteImportListExclusion}
/>
);
})
}
</div>
<div className={styles.addImportListExclusion}>
<Link
className={styles.addButton}
onPress={this.onAddImportListExclusionPress}
>
<Icon name={icons.ADD} />
</Link>
</div>
<EditImportListExclusionModalConnector
isOpen={this.state.isAddImportListExclusionModalOpen}
onModalClose={this.onModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
}
ImportListExclusions.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteImportListExclusion: PropTypes.func.isRequired
};
export default ImportListExclusions;

View File

@ -0,0 +1,59 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchImportListExclusions, deleteImportListExclusion } from 'Store/Actions/settingsActions';
import ImportListExclusions from './ImportListExclusions';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.importListExclusions,
(importListExclusions) => {
return {
...importListExclusions
};
}
);
}
const mapDispatchToProps = {
fetchImportListExclusions,
deleteImportListExclusion
};
class ImportListExclusionsConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchImportListExclusions();
}
//
// Listeners
onConfirmDeleteImportListExclusion = (id) => {
this.props.deleteImportListExclusion({ id });
}
//
// Render
render() {
return (
<ImportListExclusions
{...this.state}
{...this.props}
onConfirmDeleteImportListExclusion={this.onConfirmDeleteImportListExclusion}
/>
);
}
}
ImportListExclusionsConnector.propTypes = {
fetchImportListExclusions: PropTypes.func.isRequired,
deleteImportListExclusion: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportListExclusionsConnector);

View File

@ -0,0 +1,90 @@
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import { icons } from 'Helpers/Props';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import ImportListsConnector from './ImportLists/ImportListsConnector';
import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector';
class ImportListSettings extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
hasPendingChanges: false
};
}
//
// Listeners
setListOptionsRef = (ref) => {
this._listOptions = ref;
}
onHasPendingChange = (hasPendingChanges) => {
this.setState({
hasPendingChanges
});
}
onSavePress = () => {
this._listOptions.getWrappedInstance().save();
}
//
// Render
render() {
const {
isTestingAll,
dispatchTestAllImportLists
} = this.props;
const {
isSaving,
hasPendingChanges
} = this.state;
return (
<PageContent title="Import List Settings">
<SettingsToolbarConnector
isSaving={isSaving}
hasPendingChanges={hasPendingChanges}
additionalButtons={
<Fragment>
<PageToolbarSeparator />
<PageToolbarButton
label="Test All Lists"
iconName={icons.TEST}
isSpinning={isTestingAll}
onPress={dispatchTestAllImportLists}
/>
</Fragment>
}
onSavePress={this.onSavePress}
/>
<PageContentBody>
<ImportListsConnector />
<ImportListsExclusionsConnector />
</PageContentBody>
</PageContent>
);
}
}
ImportListSettings.propTypes = {
isTestingAll: PropTypes.bool.isRequired,
dispatchTestAllImportLists: PropTypes.func.isRequired
};
export default ImportListSettings;

View File

@ -0,0 +1,21 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { testAllImportLists } from 'Store/Actions/settingsActions';
import ImportListSettings from './ImportListSettings';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.importLists.isTestingAll,
(isTestingAll) => {
return {
isTestingAll
};
}
);
}
const mapDispatchToProps = {
dispatchTestAllImportLists: testAllImportLists
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportListSettings);

View File

@ -0,0 +1,44 @@
.list {
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;
}
.presetsMenu {
composes: menu from '~Components/Menu/Menu.css';
display: inline-block;
margin: 0 5px;
}
.presetsMenuButton {
composes: button from '~Components/Link/Button.css';
&::after {
margin-left: 5px;
content: '\25BE';
}
}

View File

@ -0,0 +1,110 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { sizes } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import Menu from 'Components/Menu/Menu';
import MenuContent from 'Components/Menu/MenuContent';
import AddImportListPresetMenuItem from './AddImportListPresetMenuItem';
import styles from './AddImportListItem.css';
class AddImportListItem extends Component {
//
// Listeners
onImportListSelect = () => {
const {
implementation
} = this.props;
this.props.onImportListSelect({ implementation });
}
//
// Render
render() {
const {
implementation,
implementationName,
infoLink,
presets,
onImportListSelect
} = this.props;
const hasPresets = !!(presets && presets.length);
return (
<div
className={styles.list}
>
<Link
className={styles.underlay}
onPress={this.onImportListSelect}
/>
<div className={styles.overlay}>
<div className={styles.name}>
{implementationName}
</div>
<div className={styles.actions}>
{
hasPresets &&
<span>
<Button
size={sizes.SMALL}
onPress={this.onListSelect}
>
Custom
</Button>
<Menu className={styles.presetsMenu}>
<Button
className={styles.presetsMenuButton}
size={sizes.SMALL}
>
Presets
</Button>
<MenuContent>
{
presets.map((preset) => {
return (
<AddImportListPresetMenuItem
key={preset.name}
name={preset.name}
implementation={implementation}
onPress={onImportListSelect}
/>
);
})
}
</MenuContent>
</Menu>
</span>
}
<Button
to={infoLink}
size={sizes.SMALL}
>
More info
</Button>
</div>
</div>
</div>
);
}
}
AddImportListItem.propTypes = {
implementation: PropTypes.string.isRequired,
implementationName: PropTypes.string.isRequired,
infoLink: PropTypes.string.isRequired,
presets: PropTypes.arrayOf(PropTypes.object),
onImportListSelect: PropTypes.func.isRequired
};
export default AddImportListItem;

View File

@ -0,0 +1,25 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AddImportListModalContentConnector from './AddImportListModalContentConnector';
function AddImportListModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddImportListModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddImportListModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddImportListModal;

View File

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

View File

@ -0,0 +1,105 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { kinds } from 'Helpers/Props';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import AddImportListItem from './AddImportListItem';
import styles from './AddImportListModalContent.css';
import titleCase from 'Utilities/String/titleCase';
class AddImportListModalContent extends Component {
//
// Render
render() {
const {
isSchemaFetching,
isSchemaPopulated,
schemaError,
listGroups,
onImportListSelect,
onModalClose
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Add List
</ModalHeader>
<ModalBody>
{
isSchemaFetching ?
<LoadingIndicator /> :
null
}
{
!isSchemaFetching && !!schemaError ?
<div>Unable to add a new list, please try again.</div> :
null
}
{
isSchemaPopulated && !schemaError ?
<div>
<Alert kind={kinds.INFO}>
<div>Sonarr supports multiple lists for importing Series into the database.</div>
<div>For more information on the individual lists, click on the info buttons.</div>
</Alert>
{
Object.keys(listGroups).map((key) => {
return (
<FieldSet legend={`${titleCase(key)} List`} key={key}>
<div className={styles.lists}>
{
listGroups[key].map((list) => {
return (
<AddImportListItem
key={list.implementation}
implementation={list.implementation}
{...list}
onImportListSelect={onImportListSelect}
/>
);
})
}
</div>
</FieldSet>
);
})
}
</div> :
null
}
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
AddImportListModalContent.propTypes = {
isSchemaFetching: PropTypes.bool.isRequired,
isSchemaPopulated: PropTypes.bool.isRequired,
schemaError: PropTypes.object,
listGroups: PropTypes.object.isRequired,
onImportListSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddImportListModalContent;

View File

@ -0,0 +1,76 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchImportListSchema, selectImportListSchema } from 'Store/Actions/settingsActions';
import AddImportListModalContent from './AddImportListModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.importLists,
(importLists) => {
const {
isSchemaFetching,
isSchemaPopulated,
schemaError,
schema
} = importLists;
const listGroups = _.chain(schema)
.sortBy((o) => o.listOrder)
.groupBy('listType')
.value();
return {
isSchemaFetching,
isSchemaPopulated,
schemaError,
listGroups
};
}
);
}
const mapDispatchToProps = {
fetchImportListSchema,
selectImportListSchema
};
class AddImportListModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchImportListSchema();
}
//
// Listeners
onImportListSelect = ({ implementation, name }) => {
this.props.selectImportListSchema({ implementation, presetName: name });
this.props.onModalClose({ listSelected: true });
}
//
// Render
render() {
return (
<AddImportListModalContent
{...this.props}
onImportListSelect={this.onImportListSelect}
/>
);
}
}
AddImportListModalContentConnector.propTypes = {
fetchImportListSchema: PropTypes.func.isRequired,
selectImportListSchema: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddImportListModalContentConnector);

View File

@ -0,0 +1,49 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import MenuItem from 'Components/Menu/MenuItem';
class AddImportListPresetMenuItem extends Component {
//
// Listeners
onPress = () => {
const {
name,
implementation
} = this.props;
this.props.onPress({
name,
implementation
});
}
//
// Render
render() {
const {
name,
implementation,
...otherProps
} = this.props;
return (
<MenuItem
{...otherProps}
onPress={this.onPress}
>
{name}
</MenuItem>
);
}
}
AddImportListPresetMenuItem.propTypes = {
name: PropTypes.string.isRequired,
implementation: PropTypes.string.isRequired,
onPress: PropTypes.func.isRequired
};
export default AddImportListPresetMenuItem;

View File

@ -0,0 +1,25 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import EditImportListModalContentConnector from './EditImportListModalContentConnector';
function EditImportListModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditImportListModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditImportListModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditImportListModal;

View File

@ -0,0 +1,65 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import { cancelTestImportList, cancelSaveImportList } from 'Store/Actions/settingsActions';
import EditImportListModal from './EditImportListModal';
function createMapDispatchToProps(dispatch, props) {
const section = 'settings.importLists';
return {
dispatchClearPendingChanges() {
dispatch(clearPendingChanges({ section }));
},
dispatchCancelTestImportList() {
dispatch(cancelTestImportList({ section }));
},
dispatchCancelSaveImportList() {
dispatch(cancelSaveImportList({ section }));
}
};
}
class EditImportListModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.dispatchClearPendingChanges();
this.props.dispatchCancelTestImportList();
this.props.dispatchCancelSaveImportList();
this.props.onModalClose();
}
//
// Render
render() {
const {
dispatchClearPendingChanges,
dispatchCancelTestImportList,
dispatchCancelSaveImportList,
...otherProps
} = this.props;
return (
<EditImportListModal
{...otherProps}
onModalClose={this.onModalClose}
/>
);
}
}
EditImportListModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
dispatchClearPendingChanges: PropTypes.func.isRequired,
dispatchCancelTestImportList: PropTypes.func.isRequired,
dispatchCancelSaveImportList: PropTypes.func.isRequired
};
export default connect(null, createMapDispatchToProps)(EditImportListModalConnector);

View File

@ -0,0 +1,15 @@
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: auto;
}
.hideLanguageProfile {
composes: group from '~Components/Form/FormGroup.css';
display: none;
}
.labelIcon {
margin-left: 8px;
}

View File

@ -0,0 +1,251 @@
import PropTypes from 'prop-types';
import React from 'react';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
import Popover from 'Components/Tooltip/Popover';
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import styles from './EditImportListModalContent.css';
function EditImportListModalContent(props) {
const {
advancedSettings,
isFetching,
error,
isSaving,
isTesting,
saveError,
item,
onInputChange,
onFieldChange,
onModalClose,
onSavePress,
onTestPress,
onDeleteImportListPress,
showLanguageProfile,
...otherProps
} = props;
const {
id,
name,
enableAutomaticAdd,
shouldMonitor,
rootFolderPath,
qualityProfileId,
languageProfileId,
tags,
fields
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? 'Edit List' : 'Add List'}
</ModalHeader>
<ModalBody>
{
isFetching ?
<LoadingIndicator /> :
null
}
{
!isFetching && !!error ?
<div>Unable to add a new list, please try again.</div> :
null
}
{
!isFetching && !error ?
<Form {...otherProps}>
<FormGroup>
<FormLabel>Name</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Enable Automatic Add</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enableAutomaticAdd"
helpText={'Add series to Sonarr when syncs are performed via the UI or by Sonarr'}
{...enableAutomaticAdd}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
Monitor
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title="Monitoring Options"
body={<SeriesMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="shouldMonitor"
onChange={onInputChange}
{...shouldMonitor}
/>
</FormGroup>
<FormGroup>
<FormLabel>Root Folder</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
helpText={'Root Folder list items will be added to'}
{...rootFolderPath}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Quality Profile</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
helpText={'Quality Profile list items should be added with'}
{...qualityProfileId}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup className={showLanguageProfile ? undefined : styles.hideLanguageProfile}>
<FormLabel>Language Profile</FormLabel>
<FormInputGroup
type={inputTypes.LANGUAGE_PROFILE_SELECT}
name="languageProfileId"
helpText={'Language Profile list items should be added with'}
{...languageProfileId}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Sonarr Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText="Add series from this list with these tags"
{...tags}
onChange={onInputChange}
/>
</FormGroup>
{
!!fields && !!fields.length &&
<div>
{
fields.map((field) => {
return (
<ProviderFieldFormGroup
key={field.name}
advancedSettings={advancedSettings}
provider="importList"
providerData={item}
section="settings.importLists"
{...field}
onChange={onFieldChange}
/>
);
})
}
</div>
}
</Form> :
null
}
</ModalBody>
<ModalFooter>
{
id &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteImportListPress}
>
Delete
</Button>
}
<SpinnerErrorButton
isSpinning={isTesting}
error={saveError}
onPress={onTestPress}
>
Test
</SpinnerErrorButton>
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
Save
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
EditImportListModalContent.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
isTesting: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
showLanguageProfile: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onFieldChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onTestPress: PropTypes.func.isRequired,
onDeleteImportListPress: PropTypes.func
};
export default EditImportListModalContent;

View File

@ -0,0 +1,90 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import { setImportListValue, setImportListFieldValue, saveImportList, testImportList } from 'Store/Actions/settingsActions';
import EditImportListModalContent from './EditImportListModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
(state) => state.settings.languageProfiles,
createProviderSettingsSelector('importLists'),
(advancedSettings, languageProfiles, importList) => {
return {
advancedSettings,
showLanguageProfile: languageProfiles.items.length > 1,
...importList
};
}
);
}
const mapDispatchToProps = {
setImportListValue,
setImportListFieldValue,
saveImportList,
testImportList
};
class EditImportListModalContentConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setImportListValue({ name, value });
}
onFieldChange = ({ name, value }) => {
this.props.setImportListFieldValue({ name, value });
}
onSavePress = () => {
this.props.saveImportList({ id: this.props.id });
}
onTestPress = () => {
this.props.testImportList({ id: this.props.id });
}
//
// Render
render() {
return (
<EditImportListModalContent
{...this.props}
onSavePress={this.onSavePress}
onTestPress={this.onTestPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
);
}
}
EditImportListModalContentConnector.propTypes = {
id: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setImportListValue: PropTypes.func.isRequired,
setImportListFieldValue: PropTypes.func.isRequired,
saveImportList: PropTypes.func.isRequired,
testImportList: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditImportListModalContentConnector);

View File

@ -0,0 +1,19 @@
.list {
composes: card from '~Components/Card.css';
width: 290px;
}
.name {
@add-mixin truncate;
margin-bottom: 20px;
font-weight: 300;
font-size: 24px;
}
.enabled {
display: flex;
flex-wrap: wrap;
margin-top: 5px;
}

View File

@ -0,0 +1,108 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { kinds } from 'Helpers/Props';
import Card from 'Components/Card';
import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import EditImportListModalConnector from './EditImportListModalConnector';
import styles from './ImportList.css';
class ImportList extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditImportListModalOpen: false,
isDeleteImportListModalOpen: false
};
}
//
// Listeners
onEditImportListPress = () => {
this.setState({ isEditImportListModalOpen: true });
}
onEditImportListModalClose = () => {
this.setState({ isEditImportListModalOpen: false });
}
onDeleteImportListPress = () => {
this.setState({
isEditImportListModalOpen: false,
isDeleteImportListModalOpen: true
});
}
onDeleteImportListModalClose= () => {
this.setState({ isDeleteImportListModalOpen: false });
}
onConfirmDeleteImportList = () => {
this.props.onConfirmDeleteImportList(this.props.id);
}
//
// Render
render() {
const {
id,
name,
enableAutomaticAdd
} = this.props;
return (
<Card
className={styles.list}
overlayContent={true}
onPress={this.onEditImportListPress}
>
<div className={styles.name}>
{name}
</div>
<div className={styles.enabled}>
{
enableAutomaticAdd &&
<Label kind={kinds.SUCCESS}>
Automatic Add
</Label>
}
</div>
<EditImportListModalConnector
id={id}
isOpen={this.state.isEditImportListModalOpen}
onModalClose={this.onEditImportListModalClose}
onDeleteImportListPress={this.onDeleteImportListPress}
/>
<ConfirmModal
isOpen={this.state.isDeleteImportListModalOpen}
kind={kinds.DANGER}
title="Delete Import List"
message={`Are you sure you want to delete the list '${name}'?`}
confirmLabel="Delete"
onConfirm={this.onConfirmDeleteImportList}
onCancel={this.onDeleteImportListModalClose}
/>
</Card>
);
}
}
ImportList.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
enableAutomaticAdd: PropTypes.bool.isRequired,
onConfirmDeleteImportList: PropTypes.func.isRequired
};
export default ImportList;

View File

@ -0,0 +1,20 @@
.lists {
display: flex;
flex-wrap: wrap;
}
.addList {
composes: list from '~./ImportList.css';
background-color: $cardAlternateBackgroundColor;
color: $gray;
text-align: center;
}
.center {
display: inline-block;
padding: 5px 20px 0;
border: 1px solid $borderColor;
border-radius: 4px;
background-color: $white;
}

View File

@ -0,0 +1,117 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import sortByName from 'Utilities/Array/sortByName';
import { icons } from 'Helpers/Props';
import FieldSet from 'Components/FieldSet';
import Card from 'Components/Card';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import ImportList from './ImportList';
import AddImportListModal from './AddImportListModal';
import EditImportListModalConnector from './EditImportListModalConnector';
import styles from './ImportLists.css';
class ImportLists extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddImportListModalOpen: false,
isEditImportListModalOpen: false
};
}
//
// Listeners
onAddImportListPress = () => {
this.setState({ isAddImportListModalOpen: true });
}
onAddImportListModalClose = ({ listSelected = false } = {}) => {
this.setState({
isAddImportListModalOpen: false,
isEditImportListModalOpen: listSelected
});
}
onEditImportListModalClose = () => {
this.setState({ isEditImportListModalOpen: false });
}
//
// Render
render() {
const {
items,
onConfirmDeleteImportList,
...otherProps
} = this.props;
const {
isAddImportListModalOpen,
isEditImportListModalOpen
} = this.state;
return (
<FieldSet
legend="Import Lists"
>
<PageSectionContent
errorMessage="Unable to load Lists"
{...otherProps}
>
<div className={styles.lists}>
{
items.sort(sortByName).map((item) => {
return (
<ImportList
key={item.id}
{...item}
onConfirmDeleteImportList={onConfirmDeleteImportList}
/>
);
})
}
<Card
className={styles.addList}
onPress={this.onAddImportListPress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
</div>
</Card>
</div>
<AddImportListModal
isOpen={isAddImportListModalOpen}
onModalClose={this.onAddImportListModalClose}
/>
<EditImportListModalConnector
isOpen={isEditImportListModalOpen}
onModalClose={this.onEditImportListModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
}
ImportLists.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteImportList: PropTypes.func.isRequired
};
export default ImportLists;

View File

@ -0,0 +1,62 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchImportLists, deleteImportList } from 'Store/Actions/settingsActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import ImportLists from './ImportLists';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.importLists,
(importLists) => {
return {
...importLists
};
}
);
}
const mapDispatchToProps = {
fetchImportLists,
deleteImportList,
fetchRootFolders
};
class ListsConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchImportLists();
this.props.fetchRootFolders();
}
//
// Listeners
onConfirmDeleteImportList = (id) => {
this.props.deleteImportList({ id });
}
//
// Render
render() {
return (
<ImportLists
{...this.props}
onConfirmDeleteImportList={this.onConfirmDeleteImportList}
/>
);
}
}
ListsConnector.propTypes = {
fetchImportLists: PropTypes.func.isRequired,
deleteImportList: PropTypes.func.isRequired,
fetchRootFolders: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ListsConnector);

View File

@ -18,6 +18,7 @@ function TagDetailsModalContent(props) {
isTagUsed,
series,
delayProfiles,
importLists,
notifications,
releaseProfiles,
onModalClose,
@ -95,6 +96,21 @@ function TagDetailsModalContent(props) {
</FieldSet>
}
{
!!importLists.length &&
<FieldSet legend="Import Lists">
{
importLists.map((item) => {
return (
<div key={item.id}>
{item.name}
</div>
);
})
}
</FieldSet>
}
{
!!releaseProfiles.length &&
<FieldSet legend="Release Profiles">
@ -170,6 +186,7 @@ TagDetailsModalContent.propTypes = {
isTagUsed: PropTypes.bool.isRequired,
series: PropTypes.arrayOf(PropTypes.object).isRequired,
delayProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
importLists: PropTypes.arrayOf(PropTypes.object).isRequired,
notifications: PropTypes.arrayOf(PropTypes.object).isRequired,
releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired,

View File

@ -45,6 +45,14 @@ function createMatchingDelayProfilesSelector() {
);
}
function createMatchingImportListsSelector() {
return createSelector(
(state, { importListIds }) => importListIds,
(state) => state.settings.importLists.items,
findMatchingItems
);
}
function createMatchingNotificationsSelector() {
return createSelector(
(state, { notificationIds }) => notificationIds,
@ -65,12 +73,14 @@ function createMapStateToProps() {
return createSelector(
createMatchingSeriesSelector(),
createMatchingDelayProfilesSelector(),
createMatchingImportListsSelector(),
createMatchingNotificationsSelector(),
createMatchingReleaseProfilesSelector(),
(series, delayProfiles, notifications, releaseProfiles) => {
(series, delayProfiles, importLists, notifications, releaseProfiles) => {
return {
series,
delayProfiles,
importLists,
notifications,
releaseProfiles
};

View File

@ -53,6 +53,7 @@ class Tag extends Component {
const {
label,
delayProfileIds,
importListIds,
notificationIds,
restrictionIds,
seriesIds
@ -65,6 +66,7 @@ class Tag extends Component {
const isTagUsed = !!(
delayProfileIds.length ||
importListIds.length ||
notificationIds.length ||
restrictionIds.length ||
seriesIds.length
@ -84,31 +86,43 @@ class Tag extends Component {
isTagUsed &&
<div>
{
!!seriesIds.length &&
seriesIds.length ?
<div>
{seriesIds.length} series
</div>
</div> :
null
}
{
!!delayProfileIds.length &&
delayProfileIds.length ?
<div>
{delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'}
</div>
</div> :
null
}
{
!!notificationIds.length &&
importListIds.length ?
<div>
{importListIds.length} import list{importListIds.length > 1 && 's'}
</div> :
null
}
{
notificationIds.length ?
<div>
{notificationIds.length} connection{notificationIds.length > 1 && 's'}
</div>
</div> :
null
}
{
!!restrictionIds.length &&
restrictionIds.length ?
<div>
{restrictionIds.length} restriction{restrictionIds.length > 1 && 's'}
</div>
</div> :
null
}
</div>
}
@ -125,6 +139,7 @@ class Tag extends Component {
isTagUsed={isTagUsed}
seriesIds={seriesIds}
delayProfileIds={delayProfileIds}
importListIds={importListIds}
notificationIds={notificationIds}
restrictionIds={restrictionIds}
isOpen={isDetailsModalOpen}
@ -150,6 +165,7 @@ Tag.propTypes = {
id: PropTypes.number.isRequired,
label: PropTypes.string.isRequired,
delayProfileIds: PropTypes.arrayOf(PropTypes.number).isRequired,
importListIds: PropTypes.arrayOf(PropTypes.number).isRequired,
notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired,
restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired,
seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired,
@ -158,6 +174,7 @@ Tag.propTypes = {
Tag.defaultProps = {
delayProfileIds: [],
importListIds: [],
notificationIds: [],
restrictionIds: [],
seriesIds: []

View File

@ -3,7 +3,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchTagDetails } from 'Store/Actions/tagActions';
import { fetchDelayProfiles, fetchNotifications, fetchReleaseProfiles } from 'Store/Actions/settingsActions';
import { fetchDelayProfiles, fetchNotifications, fetchReleaseProfiles, fetchImportLists } from 'Store/Actions/settingsActions';
import Tags from './Tags';
function createMapStateToProps() {
@ -27,6 +27,7 @@ function createMapStateToProps() {
const mapDispatchToProps = {
dispatchFetchTagDetails: fetchTagDetails,
dispatchFetchDelayProfiles: fetchDelayProfiles,
dispatchFetchImportLists: fetchImportLists,
dispatchFetchNotifications: fetchNotifications,
dispatchFetchReleaseProfiles: fetchReleaseProfiles
};
@ -40,12 +41,14 @@ class MetadatasConnector extends Component {
const {
dispatchFetchTagDetails,
dispatchFetchDelayProfiles,
dispatchFetchImportLists,
dispatchFetchNotifications,
dispatchFetchReleaseProfiles
} = this.props;
dispatchFetchTagDetails();
dispatchFetchDelayProfiles();
dispatchFetchImportLists();
dispatchFetchNotifications();
dispatchFetchReleaseProfiles();
}
@ -65,6 +68,7 @@ class MetadatasConnector extends Component {
MetadatasConnector.propTypes = {
dispatchFetchTagDetails: PropTypes.func.isRequired,
dispatchFetchDelayProfiles: PropTypes.func.isRequired,
dispatchFetchImportLists: PropTypes.func.isRequired,
dispatchFetchNotifications: PropTypes.func.isRequired,
dispatchFetchReleaseProfiles: PropTypes.func.isRequired
};

View File

@ -0,0 +1,69 @@
import { createAction } from 'redux-actions';
import { createThunk } from 'Store/thunks';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
//
// Variables
const section = 'settings.importListExclusions';
//
// Actions Types
export const FETCH_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/fetchImportListExclusions';
export const SAVE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/saveImportListExclusion';
export const DELETE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/deleteImportListExclusion';
export const SET_IMPORT_LIST_EXCLUSION_VALUE = 'settings/importListExclusions/setImportListExclusionValue';
//
// Action Creators
export const fetchImportListExclusions = createThunk(FETCH_IMPORT_LIST_EXCLUSIONS);
export const saveImportListExclusion = createThunk(SAVE_IMPORT_LIST_EXCLUSION);
export const deleteImportListExclusion = createThunk(DELETE_IMPORT_LIST_EXCLUSION);
export const setImportListExclusionValue = createAction(SET_IMPORT_LIST_EXCLUSION_VALUE, (payload) => {
return {
section,
...payload
};
});
//
// Details
export default {
//
// State
defaultState: {
isFetching: false,
isPopulated: false,
error: null,
items: [],
isSaving: false,
saveError: null,
pendingChanges: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_IMPORT_LIST_EXCLUSIONS]: createFetchHandler(section, '/importlistexclusion'),
[SAVE_IMPORT_LIST_EXCLUSION]: createSaveProviderHandler(section, '/importlistexclusion'),
[DELETE_IMPORT_LIST_EXCLUSION]: createRemoveItemHandler(section, '/importlistexclusion')
},
//
// Reducers
reducers: {
[SET_IMPORT_LIST_EXCLUSION_VALUE]: createSetSettingValueReducer(section)
}
};

View File

@ -0,0 +1,118 @@
import { createAction } from 'redux-actions';
import { createThunk } from 'Store/thunks';
import selectProviderSchema from 'Utilities/State/selectProviderSchema';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
//
// Variables
const section = 'settings.importLists';
//
// Actions Types
export const FETCH_IMPORT_LISTS = 'settings/importlists/fetchImportLists';
export const FETCH_IMPORT_LIST_SCHEMA = 'settings/importlists/fetchImportListSchema';
export const SELECT_IMPORT_LIST_SCHEMA = 'settings/importlists/selectImportListSchema';
export const SET_IMPORT_LIST_VALUE = 'settings/importlists/setImportListValue';
export const SET_IMPORT_LIST_FIELD_VALUE = 'settings/importlists/setImportListFieldValue';
export const SAVE_IMPORT_LIST = 'settings/importlists/saveImportList';
export const CANCEL_SAVE_IMPORT_LIST = 'settings/importlists/cancelSaveImportList';
export const DELETE_IMPORT_LIST = 'settings/importlists/deleteImportList';
export const TEST_IMPORT_LIST = 'settings/importlists/testImportList';
export const CANCEL_TEST_IMPORT_LIST = 'settings/importlists/cancelTestImportList';
export const TEST_ALL_IMPORT_LISTS = 'settings/importlists/testAllImportLists';
//
// Action Creators
export const fetchImportLists = createThunk(FETCH_IMPORT_LISTS);
export const fetchImportListSchema = createThunk(FETCH_IMPORT_LIST_SCHEMA);
export const selectImportListSchema = createAction(SELECT_IMPORT_LIST_SCHEMA);
export const saveImportList = createThunk(SAVE_IMPORT_LIST);
export const cancelSaveImportList = createThunk(CANCEL_SAVE_IMPORT_LIST);
export const deleteImportList = createThunk(DELETE_IMPORT_LIST);
export const testImportList = createThunk(TEST_IMPORT_LIST);
export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST);
export const testAllImportLists = createThunk(TEST_ALL_IMPORT_LISTS);
export const setImportListValue = createAction(SET_IMPORT_LIST_VALUE, (payload) => {
return {
section,
...payload
};
});
export const setImportListFieldValue = createAction(SET_IMPORT_LIST_FIELD_VALUE, (payload) => {
return {
section,
...payload
};
});
//
// Details
export default {
//
// State
defaultState: {
isFetching: false,
isPopulated: false,
error: null,
isSchemaFetching: false,
isSchemaPopulated: false,
schemaError: null,
schema: [],
selectedSchema: {},
isSaving: false,
saveError: null,
isTesting: false,
isTestingAll: false,
items: [],
pendingChanges: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_IMPORT_LISTS]: createFetchHandler(section, '/importlist'),
[FETCH_IMPORT_LIST_SCHEMA]: createFetchSchemaHandler(section, '/importlist/schema'),
[SAVE_IMPORT_LIST]: createSaveProviderHandler(section, '/importlist'),
[CANCEL_SAVE_IMPORT_LIST]: createCancelSaveProviderHandler(section),
[DELETE_IMPORT_LIST]: createRemoveItemHandler(section, '/importlist'),
[TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'),
[CANCEL_TEST_IMPORT_LIST]: createCancelTestProviderHandler(section),
[TEST_ALL_IMPORT_LISTS]: createTestAllProvidersHandler(section, '/importlist')
},
//
// Reducers
reducers: {
[SET_IMPORT_LIST_VALUE]: createSetSettingValueReducer(section),
[SET_IMPORT_LIST_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
[SELECT_IMPORT_LIST_SCHEMA]: (state, { payload }) => {
return selectProviderSchema(state, section, payload, (selectedSchema) => {
selectedSchema.enableAutomaticAdd = true;
selectedSchema.shouldMonitor = 'all';
return selectedSchema;
});
}
}
};

View File

@ -199,7 +199,8 @@ export const deleteSeries = createThunk(DELETE_SERIES, (payload) => {
return {
...payload,
queryParams: {
deleteFiles: payload.deleteFiles
deleteFiles: payload.deleteFiles,
addImportListExclusion: payload.addImportListExclusion
}
};
});

View File

@ -5,6 +5,8 @@ import delayProfiles from './Settings/delayProfiles';
import downloadClients from './Settings/downloadClients';
import downloadClientOptions from './Settings/downloadClientOptions';
import general from './Settings/general';
import importLists from './Settings/importLists';
import importListExclusions from './Settings/importListExclusions';
import indexerOptions from './Settings/indexerOptions';
import indexers from './Settings/indexers';
import languageProfiles from './Settings/languageProfiles';
@ -23,6 +25,8 @@ export * from './Settings/delayProfiles';
export * from './Settings/downloadClients';
export * from './Settings/downloadClientOptions';
export * from './Settings/general';
export * from './Settings/importLists';
export * from './Settings/importListExclusions';
export * from './Settings/indexerOptions';
export * from './Settings/indexers';
export * from './Settings/languageProfiles';
@ -52,6 +56,8 @@ export const defaultState = {
downloadClients: downloadClients.defaultState,
downloadClientOptions: downloadClientOptions.defaultState,
general: general.defaultState,
importLists: importLists.defaultState,
importListExclusions: importListExclusions.defaultState,
indexerOptions: indexerOptions.defaultState,
indexers: indexers.defaultState,
languageProfiles: languageProfiles.defaultState,
@ -89,6 +95,8 @@ export const actionHandlers = handleThunks({
...downloadClients.actionHandlers,
...downloadClientOptions.actionHandlers,
...general.actionHandlers,
...importLists.actionHandlers,
...importListExclusions.actionHandlers,
...indexerOptions.actionHandlers,
...indexers.actionHandlers,
...languageProfiles.actionHandlers,
@ -117,6 +125,8 @@ export const reducers = createHandleActions({
...downloadClients.reducers,
...downloadClientOptions.reducers,
...general.reducers,
...importLists.reducers,
...importListExclusions.reducers,
...indexerOptions.reducers,
...indexers.reducers,
...languageProfiles.reducers,

View File

@ -1,4 +1,3 @@
import _ from 'lodash';
import { createSelector } from 'reselect';
import createAllSeriesSelector from './createAllSeriesSelector';
@ -6,12 +5,13 @@ function createProfileInUseSelector(profileProp) {
return createSelector(
(state, { id }) => id,
createAllSeriesSelector(),
(id, series) => {
(state) => state.settings.importLists.items,
(id, series, lists) => {
if (!id) {
return false;
}
return _.some(series, { [profileProp]: id });
return series.some((s) => s[profileProp] === id) || lists.some((list) => list[profileProp] === id);
}
);
}

View File

@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.HealthCheck.Checks;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.HealthCheck.Checks
{
[TestFixture]
public class ImportListStatusCheckFixture : CoreTest<ImportListStatusCheck>
{
private List<IImportList> _importLists = new List<IImportList>();
private List<ImportListStatus> _blockedImportLists = new List<ImportListStatus>();
[SetUp]
public void SetUp()
{
Mocker.GetMock<IImportListFactory>()
.Setup(v => v.GetAvailableProviders())
.Returns(_importLists);
Mocker.GetMock<IImportListStatusService>()
.Setup(v => v.GetBlockedProviders())
.Returns(_blockedImportLists);
}
private Mock<IImportList> GivenImportList(int id, double backoffHours, double failureHours)
{
var mockImportList = new Mock<IImportList>();
mockImportList.SetupGet(s => s.Definition).Returns(new ImportListDefinition { Id = id });
_importLists.Add(mockImportList.Object);
if (backoffHours != 0.0)
{
_blockedImportLists.Add(new ImportListStatus
{
ProviderId = id,
InitialFailure = DateTime.UtcNow.AddHours(-failureHours),
MostRecentFailure = DateTime.UtcNow.AddHours(-0.1),
EscalationLevel = 5,
DisabledTill = DateTime.UtcNow.AddHours(backoffHours)
});
}
return mockImportList;
}
[Test]
public void should_not_return_error_when_no_import_lists()
{
Subject.Check().ShouldBeOk();
}
[Test]
public void should_return_warning_if_import_list_unavailable()
{
GivenImportList(1, 10.0, 24.0);
GivenImportList(2, 0.0, 0.0);
Subject.Check().ShouldBeWarning();
}
[Test]
public void should_return_error_if_all_import_lists_unavailable()
{
GivenImportList(1, 10.0, 24.0);
Subject.Check().ShouldBeError();
}
[Test]
public void should_return_warning_if_few_import_lists_unavailable()
{
GivenImportList(1, 10.0, 24.0);
GivenImportList(2, 10.0, 24.0);
GivenImportList(3, 0.0, 0.0);
Subject.Check().ShouldBeWarning();
}
}
}

View File

@ -26,10 +26,8 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks
.Returns(_blockedIndexers);
}
private Mock<IIndexer> GivenIndexer(int i, double backoffHours, double failureHours)
private Mock<IIndexer> GivenIndexer(int id, double backoffHours, double failureHours)
{
var id = i;
var mockIndexer = new Mock<IIndexer>();
mockIndexer.SetupGet(s => s.Definition).Returns(new IndexerDefinition { Id = id });
mockIndexer.SetupGet(s => s.SupportsSearch).Returns(true);

View File

@ -0,0 +1,54 @@
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Housekeeping.Housekeepers;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Housekeeping.Housekeepers
{
[TestFixture]
public class CleanupOrphanedImportListFixture : DbTest<CleanupOrphanedImportListStatus, ImportListStatus>
{
private ImportListDefinition _importList;
[SetUp]
public void Setup()
{
_importList = Builder<ImportListDefinition>.CreateNew()
.BuildNew();
}
private void GivenIndexer()
{
Db.Insert(_importList);
}
[Test]
public void should_delete_orphaned_indexerstatus()
{
var status = Builder<ImportListStatus>.CreateNew()
.With(h => h.ProviderId = _importList.Id)
.BuildNew();
Db.Insert(status);
Subject.Clean();
AllStoredModels.Should().BeEmpty();
}
[Test]
public void should_not_delete_unorphaned_indexerstatus()
{
GivenIndexer();
var status = Builder<ImportListStatus>.CreateNew()
.With(h => h.ProviderId = _importList.Id)
.BuildNew();
Db.Insert(status);
Subject.Clean();
AllStoredModels.Should().HaveCount(1);
AllStoredModels.Should().Contain(h => h.ProviderId == _importList.Id);
}
}
}

View File

@ -0,0 +1,72 @@
using System;
using System.Linq;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.ImportListTests
{
public class ImportListStatusServiceFixture : CoreTest<ImportListStatusService>
{
private DateTime _epoch;
[SetUp]
public void SetUp()
{
_epoch = DateTime.UtcNow;
Mocker.GetMock<IRuntimeInfo>()
.SetupGet(v => v.StartTime)
.Returns(_epoch - TimeSpan.FromHours(1));
}
private void WithStatus(ImportListStatus status)
{
Mocker.GetMock<IImportListStatusRepository>()
.Setup(v => v.FindByProviderId(1))
.Returns(status);
Mocker.GetMock<IImportListStatusRepository>()
.Setup(v => v.All())
.Returns(new[] { status });
}
private void VerifyUpdate()
{
Mocker.GetMock<IImportListStatusRepository>()
.Verify(v => v.Upsert(It.IsAny<ImportListStatus>()), Times.Once());
}
private void VerifyNoUpdate()
{
Mocker.GetMock<IImportListStatusRepository>()
.Verify(v => v.Upsert(It.IsAny<ImportListStatus>()), Times.Never());
}
[Test]
public void should_cancel_backoff_on_success()
{
WithStatus(new ImportListStatus { EscalationLevel = 2 });
Subject.RecordSuccess(1);
VerifyUpdate();
var status = Subject.GetBlockedProviders().FirstOrDefault();
status.Should().BeNull();
}
[Test]
public void should_not_store_update_if_already_okay()
{
WithStatus(new ImportListStatus { EscalationLevel = 0 });
Subject.RecordSuccess(1);
VerifyNoUpdate();
}
}
}

View File

@ -0,0 +1,136 @@
using System.Linq;
using System.Collections.Generic;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Tv;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.ImportLists.Exclusions;
namespace NzbDrone.Core.Test.ImportListTests
{
public class ImportListSyncServiceFixture : CoreTest<ImportListSyncService>
{
private List<ImportListItemInfo> _importListReports;
[SetUp]
public void SetUp()
{
var importListItem1 = new ImportListItemInfo
{
Title = "Breaking Bad"
};
_importListReports = new List<ImportListItemInfo>{importListItem1};
Mocker.GetMock<IFetchAndParseImportList>()
.Setup(v => v.Fetch())
.Returns(_importListReports);
Mocker.GetMock<ISearchForNewSeries>()
.Setup(v => v.SearchForNewSeries(It.IsAny<string>()))
.Returns(new List<Series>());
Mocker.GetMock<IImportListFactory>()
.Setup(v => v.Get(It.IsAny<int>()))
.Returns(new ImportListDefinition{ ShouldMonitor = MonitorTypes.All });
Mocker.GetMock<IFetchAndParseImportList>()
.Setup(v => v.Fetch())
.Returns(_importListReports);
Mocker.GetMock<IImportListExclusionService>()
.Setup(v => v.All())
.Returns(new List<ImportListExclusion>());
}
private void WithTvdbId()
{
_importListReports.First().TvdbId = 81189;
}
private void WithExistingSeries()
{
Mocker.GetMock<ISeriesService>()
.Setup(v => v.FindByTvdbId(_importListReports.First().TvdbId))
.Returns(new Series{TvdbId = _importListReports.First().TvdbId });
}
private void WithExcludedSeries()
{
Mocker.GetMock<IImportListExclusionService>()
.Setup(v => v.All())
.Returns(new List<ImportListExclusion> {
new ImportListExclusion {
TvdbId = 81189
}
});
}
private void WithMonitorType(MonitorTypes monitor)
{
Mocker.GetMock<IImportListFactory>()
.Setup(v => v.Get(It.IsAny<int>()))
.Returns(new ImportListDefinition{ ShouldMonitor = monitor });
}
[Test]
public void should_search_if_series_title_and_no_series_id()
{
Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<ISearchForNewSeries>()
.Verify(v => v.SearchForNewSeries(It.IsAny<string>()), Times.Once());
}
[Test]
public void should_not_search_if_series_title_and_series_id()
{
WithTvdbId();
Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<ISearchForNewSeries>()
.Verify(v => v.SearchForNewSeries(It.IsAny<string>()), Times.Never());
}
[Test]
public void should_not_add_if_existing_series()
{
WithTvdbId();
WithExistingSeries();
Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(t=>t.Count == 0)));
}
[TestCase(MonitorTypes.None, false)]
[TestCase(MonitorTypes.All, true)]
public void should_add_if_not_existing_series(MonitorTypes monitor, bool expectedSeriesMonitored)
{
WithTvdbId();
WithMonitorType(monitor);
Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 1 && t.First().Monitored == expectedSeriesMonitored)));
}
[Test]
public void should_not_add_if_excluded_series()
{
WithTvdbId();
WithExcludedSeries();
Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 0)));
}
}
}

View File

@ -0,0 +1,36 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(142)]
public class import_lists : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Create.TableForModel("ImportLists")
.WithColumn("Name").AsString().Unique()
.WithColumn("Implementation").AsString()
.WithColumn("Settings").AsString().Nullable()
.WithColumn("ConfigContract").AsString().Nullable()
.WithColumn("EnableAutomaticAdd").AsBoolean().Nullable()
.WithColumn("RootFolderPath").AsString()
.WithColumn("ShouldMonitor").AsInt32()
.WithColumn("QualityProfileId").AsInt32()
.WithColumn("LanguageProfileId").AsInt32()
.WithColumn("Tags").AsString().Nullable();
Create.TableForModel("ImportListStatus")
.WithColumn("ProviderId").AsInt32().NotNullable().Unique()
.WithColumn("InitialFailure").AsDateTime().Nullable()
.WithColumn("MostRecentFailure").AsDateTime().Nullable()
.WithColumn("EscalationLevel").AsInt32().NotNullable()
.WithColumn("DisabledTill").AsDateTime().Nullable()
.WithColumn("LastSyncListInfo").AsString().Nullable();
Create.TableForModel("ImportListExclusions")
.WithColumn("TvdbId").AsString().NotNullable().Unique()
.WithColumn("Title").AsString().NotNullable();
}
}
}

View File

@ -40,6 +40,8 @@ using NzbDrone.Core.Languages;
using NzbDrone.Core.Profiles.Languages;
using NzbDrone.Core.Profiles.Releases;
using NzbDrone.Core.Update.History;
using NzbDrone.Core.ImportLists.Exclusions;
using NzbDrone.Core.ImportLists;
namespace NzbDrone.Core.Datastore
{
@ -81,6 +83,10 @@ namespace NzbDrone.Core.Datastore
.Ignore(d => d.Protocol)
.Ignore(d => d.Tags);
Mapper.Entity<ImportListDefinition>().RegisterDefinition("ImportLists")
.Ignore(i => i.ListType)
.Ignore(i => i.Enable);
Mapper.Entity<SceneMapping>().RegisterModel("SceneMappings");
Mapper.Entity<EpisodeHistory>().RegisterModel("History")
@ -135,6 +141,7 @@ namespace NzbDrone.Core.Datastore
Mapper.Entity<IndexerStatus>().RegisterModel("IndexerStatus");
Mapper.Entity<DownloadClientStatus>().RegisterModel("DownloadClientStatus");
Mapper.Entity<ImportListStatus>().RegisterModel("ImportListStatus");
Mapper.Entity<CustomFilter>().RegisterModel("CustomFilters");
@ -142,6 +149,7 @@ namespace NzbDrone.Core.Datastore
.AutoMapChildModels();
Mapper.Entity<UpdateHistory>().RegisterModel("UpdateHistory");
Mapper.Entity<ImportListExclusion>().RegisterModel("ImportListExclusions");
}
private static void RegisterMappers()

View File

@ -0,0 +1,44 @@
using System.Linq;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.ThingiProvider.Events;
namespace NzbDrone.Core.HealthCheck.Checks
{
[CheckOn(typeof(ProviderUpdatedEvent<IImportList>))]
[CheckOn(typeof(ProviderDeletedEvent<IImportList>))]
[CheckOn(typeof(ProviderStatusChangedEvent<IImportList>))]
public class ImportListStatusCheck : HealthCheckBase
{
private readonly IImportListFactory _providerFactory;
private readonly IImportListStatusService _providerStatusService;
public ImportListStatusCheck(IImportListFactory providerFactory, IImportListStatusService providerStatusService)
{
_providerFactory = providerFactory;
_providerStatusService = providerStatusService;
}
public override HealthCheck Check()
{
var enabledProviders = _providerFactory.GetAvailableProviders();
var backOffProviders = enabledProviders.Join(_providerStatusService.GetBlockedProviders(),
i => i.Definition.Id,
s => s.ProviderId,
(i, s) => new { ImportList = i, Status = s })
.ToList();
if (backOffProviders.Empty())
{
return new HealthCheck(GetType());
}
if (backOffProviders.Count == enabledProviders.Count)
{
return new HealthCheck(GetType(), HealthCheckResult.Error, "All import lists are unavailable due to failures", "#import-lists-are-unavailable-due-to-failures");
}
return new HealthCheck(GetType(), HealthCheckResult.Warning, string.Format("Import lists unavailable due to failures: {0}", string.Join(", ", backOffProviders.Select(v => v.ImportList.Definition.Name))), "#import-lsits-are-unavailable-due-to-failures");
}
}
}

View File

@ -0,0 +1,26 @@
using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.Housekeeping.Housekeepers
{
public class CleanupOrphanedImportListStatus : IHousekeepingTask
{
private readonly IMainDatabase _database;
public CleanupOrphanedImportListStatus(IMainDatabase database)
{
_database = database;
}
public void Clean()
{
var mapper = _database.GetDataMapper();
mapper.ExecuteNonQuery(@"DELETE FROM ImportListStatus
WHERE Id IN (
SELECT ImportListStatus.Id FROM ImportListStatus
LEFT OUTER JOIN ImportLists
ON ImportListStatus.ProviderId = ImportLists.Id
WHERE ImportLists.Id IS NULL)");
}
}
}

View File

@ -0,0 +1,23 @@
using NzbDrone.Common.Exceptions;
namespace NzbDrone.Core.ImportLists.Exceptions
{
public class ImportListException : NzbDroneException
{
private readonly ImportListResponse _importListResponse;
public ImportListException(ImportListResponse response, string message, params object[] args)
: base(message, args)
{
_importListResponse = response;
}
public ImportListException(ImportListResponse response, string message)
: base(message)
{
_importListResponse = response;
}
public ImportListResponse Response => _importListResponse;
}
}

View File

@ -0,0 +1,10 @@
using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.ImportLists.Exclusions
{
public class ImportListExclusion : ModelBase
{
public int TvdbId { get; set; }
public string Title { get; set; }
}
}

View File

@ -0,0 +1,22 @@
using FluentValidation.Validators;
namespace NzbDrone.Core.ImportLists.Exclusions
{
public class ImportListExclusionExistsValidator : PropertyValidator
{
private readonly IImportListExclusionService _importListExclusionService;
public ImportListExclusionExistsValidator(IImportListExclusionService importListExclusionService)
: base("This exclusion has already been added.")
{
_importListExclusionService = importListExclusionService;
}
protected override bool IsValid(PropertyValidatorContext context)
{
if (context.PropertyValue == null) return true;
return (!_importListExclusionService.All().Exists(s => s.TvdbId == (int)context.PropertyValue));
}
}
}

View File

@ -0,0 +1,24 @@
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
using System.Linq;
namespace NzbDrone.Core.ImportLists.Exclusions
{
public interface IImportListExclusionRepository : IBasicRepository<ImportListExclusion>
{
ImportListExclusion FindByTvdbId(int tvdbId);
}
public class ImportListExclusionRepository : BasicRepository<ImportListExclusion>, IImportListExclusionRepository
{
public ImportListExclusionRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
public ImportListExclusion FindByTvdbId(int tvdbId)
{
return Query.Where<ImportListExclusion>(m => m.TvdbId == tvdbId).SingleOrDefault();
}
}
}

View File

@ -0,0 +1,80 @@
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Tv.Events;
using System.Collections.Generic;
using System.Linq;
namespace NzbDrone.Core.ImportLists.Exclusions
{
public interface IImportListExclusionService
{
ImportListExclusion Add(ImportListExclusion importListExclusion);
List<ImportListExclusion> All();
void Delete(int id);
ImportListExclusion Get(int id);
ImportListExclusion FindByTvdbId(int tvdbId);
ImportListExclusion Update(ImportListExclusion importListExclusion);
}
public class ImportListExclusionService : IImportListExclusionService, IHandleAsync<SeriesDeletedEvent>
{
private readonly IImportListExclusionRepository _repo;
public ImportListExclusionService(IImportListExclusionRepository repo)
{
_repo = repo;
}
public ImportListExclusion Add(ImportListExclusion importListExclusion)
{
return _repo.Insert(importListExclusion);
}
public ImportListExclusion Update(ImportListExclusion importListExclusion)
{
return _repo.Update(importListExclusion);
}
public void Delete(int id)
{
_repo.Delete(id);
}
public ImportListExclusion Get(int id)
{
return _repo.Get(id);
}
public ImportListExclusion FindByTvdbId(int tvdbId)
{
return _repo.FindByTvdbId(tvdbId);
}
public List<ImportListExclusion> All()
{
return _repo.All().ToList();
}
public void HandleAsync(SeriesDeletedEvent message)
{
if (!message.AddImportListExclusion)
{
return;
}
var existingExclusion = _repo.FindByTvdbId(message.Series.TvdbId);
if (existingExclusion != null)
{
return;
}
var importExclusion = new ImportListExclusion
{
TvdbId = message.Series.TvdbId,
Title = message.Series.Title
};
_repo.Insert(importExclusion);
}
}
}

View File

@ -0,0 +1,127 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NLog;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Common.TPL;
using System;
using NzbDrone.Common.Extensions;
namespace NzbDrone.Core.ImportLists
{
public interface IFetchAndParseImportList
{
List<ImportListItemInfo> Fetch();
List<ImportListItemInfo> FetchSingleList(ImportListDefinition definition);
}
public class FetchAndParseImportListService : IFetchAndParseImportList
{
private readonly IImportListFactory _importListFactory;
private readonly Logger _logger;
public FetchAndParseImportListService(IImportListFactory importListFactory, Logger logger)
{
_importListFactory = importListFactory;
_logger = logger;
}
public List<ImportListItemInfo> Fetch()
{
var result = new List<ImportListItemInfo>();
var importLists = _importListFactory.AutomaticAddEnabled();
if (!importLists.Any())
{
_logger.Warn("No available import lists. check your configuration.");
return result;
}
_logger.Debug("Available import lists {0}", importLists.Count);
var taskList = new List<Task>();
var taskFactory = new TaskFactory(TaskCreationOptions.LongRunning, TaskContinuationOptions.None);
foreach (var importList in importLists)
{
var importListLocal = importList;
var task = taskFactory.StartNew(() =>
{
try
{
var importListReports = importListLocal.Fetch();
lock (result)
{
_logger.Debug("Found {0} from {1}", importListReports.Count, importList.Name);
result.AddRange(importListReports);
}
}
catch (Exception e)
{
_logger.Error(e, "Error during Import List Sync");
}
}).LogExceptions();
taskList.Add(task);
}
Task.WaitAll(taskList.ToArray());
result = result.DistinctBy(r => new {r.TvdbId, r.Title}).ToList();
_logger.Debug("Found {0} reports", result.Count);
return result;
}
public List<ImportListItemInfo> FetchSingleList(ImportListDefinition definition)
{
var result = new List<ImportListItemInfo>();
var importList = _importListFactory.GetInstance(definition);
if (importList == null || !definition.EnableAutomaticAdd)
{
_logger.Warn("No available import lists. check your configuration.");
return result;
}
var taskList = new List<Task>();
var taskFactory = new TaskFactory(TaskCreationOptions.LongRunning, TaskContinuationOptions.None);
var importListLocal = importList;
var task = taskFactory.StartNew(() =>
{
try
{
var importListReports = importListLocal.Fetch();
lock (result)
{
_logger.Debug("Found {0} from {1}", importListReports.Count, importList.Name);
result.AddRange(importListReports);
}
}
catch (Exception e)
{
_logger.Error(e, "Error during Import List Sync");
}
}).LogExceptions();
taskList.Add(task);
Task.WaitAll(taskList.ToArray());
result = result.DistinctBy(r => new { r.TvdbId, r.Title }).ToList();
return result;
}
}
}

View File

@ -0,0 +1,245 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using FluentValidation.Results;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Http.CloudFlare;
using NzbDrone.Core.ImportLists.Exceptions;
using NzbDrone.Core.Indexers.Exceptions;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.ImportLists
{
public abstract class HttpImportListBase<TSettings> : ImportListBase<TSettings>
where TSettings : IImportListSettings, new()
{
protected const int MaxNumResultsPerQuery = 1000;
protected readonly IHttpClient _httpClient;
public bool SupportsPaging => PageSize > 0;
public virtual int PageSize => 0;
public virtual TimeSpan RateLimit => TimeSpan.FromSeconds(2);
public abstract IImportListRequestGenerator GetRequestGenerator();
public abstract IParseImportListResponse GetParser();
public HttpImportListBase(IHttpClient httpClient, IImportListStatusService importListStatusService, IConfigService configService, IParsingService parsingService, Logger logger)
: base(importListStatusService, configService, parsingService, logger)
{
_httpClient = httpClient;
}
public override IList<ImportListItemInfo> Fetch()
{
return FetchItems(g => g.GetListItems(), true);
}
protected virtual IList<ImportListItemInfo> FetchItems(Func<IImportListRequestGenerator, ImportListPageableRequestChain> pageableRequestChainSelector, bool isRecent = false)
{
var releases = new List<ImportListItemInfo>();
var url = string.Empty;
try
{
var generator = GetRequestGenerator();
var parser = GetParser();
var pageableRequestChain = pageableRequestChainSelector(generator);
for (int i = 0; i < pageableRequestChain.Tiers; i++)
{
var pageableRequests = pageableRequestChain.GetTier(i);
foreach (var pageableRequest in pageableRequests)
{
var pagedReleases = new List<ImportListItemInfo>();
foreach (var request in pageableRequest)
{
url = request.Url.FullUri;
var page = FetchPage(request, parser);
pagedReleases.AddRange(page);
if (pagedReleases.Count >= MaxNumResultsPerQuery)
{
break;
}
if (!IsFullPage(page))
{
break;
}
}
releases.AddRange(pagedReleases.Where(IsValidItem));
}
if (releases.Any())
{
break;
}
}
_importListStatusService.RecordSuccess(Definition.Id);
}
catch (WebException webException)
{
if (webException.Status == WebExceptionStatus.NameResolutionFailure ||
webException.Status == WebExceptionStatus.ConnectFailure)
{
_importListStatusService.RecordConnectionFailure(Definition.Id);
}
else
{
_importListStatusService.RecordFailure(Definition.Id);
}
if (webException.Message.Contains("502") || webException.Message.Contains("503") ||
webException.Message.Contains("timed out"))
{
_logger.Warn("{0} server is currently unavailable. {1} {2}", this, url, webException.Message);
}
else
{
_logger.Warn("{0} {1} {2}", this, url, webException.Message);
}
}
catch (TooManyRequestsException ex)
{
if (ex.RetryAfter != TimeSpan.Zero)
{
_importListStatusService.RecordFailure(Definition.Id, ex.RetryAfter);
}
else
{
_importListStatusService.RecordFailure(Definition.Id, TimeSpan.FromHours(1));
}
_logger.Warn("API Request Limit reached for {0}", this);
}
catch (HttpException ex)
{
_importListStatusService.RecordFailure(Definition.Id);
_logger.Warn("{0} {1}", this, ex.Message);
}
catch (RequestLimitReachedException)
{
_importListStatusService.RecordFailure(Definition.Id, TimeSpan.FromHours(1));
_logger.Warn("API Request Limit reached for {0}", this);
}
catch (CloudFlareCaptchaException ex)
{
_importListStatusService.RecordFailure(Definition.Id);
ex.WithData("FeedUrl", url);
if (ex.IsExpired)
{
_logger.Error(ex, "Expired CAPTCHA token for {0}, please refresh in import list settings.", this);
}
else
{
_logger.Error(ex, "CAPTCHA token required for {0}, check import list settings.", this);
}
}
catch (ImportListException ex)
{
_importListStatusService.RecordFailure(Definition.Id);
_logger.Warn(ex, "{0}", url);
}
catch (Exception ex)
{
_importListStatusService.RecordFailure(Definition.Id);
ex.WithData("FeedUrl", url);
_logger.Error(ex, "An error occurred while processing feed. {0}", url);
}
return CleanupListItems(releases);
}
protected virtual bool IsValidItem(ImportListItemInfo release)
{
if (release.Title.IsNullOrWhiteSpace())
{
return false;
}
return true;
}
protected virtual bool IsFullPage(IList<ImportListItemInfo> page)
{
return PageSize != 0 && page.Count >= PageSize;
}
protected virtual IList<ImportListItemInfo> FetchPage(ImportListRequest request, IParseImportListResponse parser)
{
var response = FetchImportListResponse(request);
return parser.ParseResponse(response).ToList();
}
protected virtual ImportListResponse FetchImportListResponse(ImportListRequest request)
{
_logger.Debug("Downloading Feed " + request.HttpRequest.ToString(false));
if (request.HttpRequest.RateLimit < RateLimit)
{
request.HttpRequest.RateLimit = RateLimit;
}
return new ImportListResponse(request, _httpClient.Execute(request.HttpRequest));
}
protected override void Test(List<ValidationFailure> failures)
{
failures.AddIfNotNull(TestConnection());
}
protected virtual ValidationFailure TestConnection()
{
try
{
var parser = GetParser();
var generator = GetRequestGenerator();
var releases = FetchPage(generator.GetListItems().GetAllTiers().First().First(), parser);
if (releases.Empty())
{
return new ValidationFailure(string.Empty, "No results were returned from your import list, please check your settings.");
}
}
catch (RequestLimitReachedException)
{
_logger.Warn("Request limit reached");
}
catch (UnsupportedFeedException ex)
{
_logger.Warn(ex, "Import list feed is not supported");
return new ValidationFailure(string.Empty, "Import list feed is not supported: " + ex.Message);
}
catch (ImportListException ex)
{
_logger.Warn(ex, "Unable to connect to import list");
return new ValidationFailure(string.Empty, "Unable to connect to import list. " + ex.Message);
}
catch (Exception ex)
{
_logger.Warn(ex, "Unable to connect to import list");
return new ValidationFailure(string.Empty, "Unable to connect to import list, check the log for more details");
}
return null;
}
}
}

View File

@ -0,0 +1,12 @@
using System.Collections.Generic;
using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.ImportLists
{
public interface IImportList : IProvider
{
ImportListType ListType { get; }
IList<ImportListItemInfo> Fetch();
}
}

View File

@ -0,0 +1,7 @@
namespace NzbDrone.Core.ImportLists
{
public interface IImportListRequestGenerator
{
ImportListPageableRequestChain GetListItems();
}
}

View File

@ -0,0 +1,9 @@
using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.ImportLists
{
public interface IImportListSettings : IProviderConfig
{
string BaseUrl { get; set; }
}
}

View File

@ -0,0 +1,10 @@
using System.Collections.Generic;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.ImportLists
{
public interface IParseImportListResponse
{
IList<ImportListItemInfo> ParseResponse(ImportListResponse importListResponse);
}
}

View File

@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FluentValidation.Results;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.ImportLists
{
public abstract class ImportListBase<TSettings> : IImportList
where TSettings : IImportListSettings, new()
{
protected readonly IImportListStatusService _importListStatusService;
protected readonly IConfigService _configService;
protected readonly IParsingService _parsingService;
protected readonly Logger _logger;
public abstract string Name { get; }
public abstract ImportListType ListType {get; }
public ImportListBase(IImportListStatusService importListStatusService, IConfigService configService, IParsingService parsingService, Logger logger)
{
_importListStatusService = importListStatusService;
_configService = configService;
_parsingService = parsingService;
_logger = logger;
}
public Type ConfigContract => typeof(TSettings);
public virtual ProviderMessage Message => null;
public virtual IEnumerable<ProviderDefinition> DefaultDefinitions
{
get
{
var config = (IProviderConfig)new TSettings();
yield return new ImportListDefinition
{
Name = GetType().Name,
EnableAutomaticAdd = config.Validate().IsValid,
Implementation = GetType().Name,
Settings = config
};
}
}
public virtual ProviderDefinition Definition { get; set; }
public virtual object RequestAction(string action, IDictionary<string, string> query) { return null; }
protected TSettings Settings => (TSettings)Definition.Settings;
public abstract IList<ImportListItemInfo> Fetch();
protected virtual IList<ImportListItemInfo> CleanupListItems(IEnumerable<ImportListItemInfo> releases)
{
var result = releases.DistinctBy(r => new {r.Title, r.TvdbId}).ToList();
result.ForEach(c =>
{
c.ImportListId = Definition.Id;
c.ImportList = Definition.Name;
});
return result;
}
public ValidationResult Test()
{
var failures = new List<ValidationFailure>();
try
{
Test(failures);
}
catch (Exception ex)
{
_logger.Error(ex, "Test aborted due to exception");
failures.Add(new ValidationFailure(string.Empty, "Test was aborted due to an error: " + ex.Message));
}
return new ValidationResult(failures);
}
protected abstract void Test(List<ValidationFailure> failures);
public override string ToString()
{
return Definition.Name;
}
}
}

View File

@ -0,0 +1,19 @@
using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.ImportLists
{
public class ImportListDefinition : ProviderDefinition
{
public bool EnableAutomaticAdd { get; set; }
public MonitorTypes ShouldMonitor { get; set; }
public int QualityProfileId { get; set; }
public int LanguageProfileId { get; set; }
public string RootFolderPath { get; set; }
public override bool Enable => EnableAutomaticAdd;
public ImportListStatus Status { get; set; }
public ImportListType ListType { get; set; }
}
}

View File

@ -0,0 +1,86 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation.Results;
using NLog;
using NzbDrone.Common.Composition;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.ImportLists
{
public interface IImportListFactory : IProviderFactory<IImportList, ImportListDefinition>
{
List<IImportList> AutomaticAddEnabled(bool filterBlockedImportLists = true);
}
public class ImportListFactory : ProviderFactory<IImportList, ImportListDefinition>, IImportListFactory
{
private readonly IImportListStatusService _importListStatusService;
private readonly Logger _logger;
public ImportListFactory(IImportListStatusService importListStatusService,
IImportListRepository providerRepository,
IEnumerable<IImportList> providers,
IContainer container,
IEventAggregator eventAggregator,
Logger logger)
: base(providerRepository, providers, container, eventAggregator, logger)
{
_importListStatusService = importListStatusService;
_logger = logger;
}
protected override List<ImportListDefinition> Active()
{
return base.Active().Where(c => c.Enable).ToList();
}
public override void SetProviderCharacteristics(IImportList provider, ImportListDefinition definition)
{
base.SetProviderCharacteristics(provider, definition);
definition.ListType = provider.ListType;
}
public List<IImportList> AutomaticAddEnabled(bool filterBlockedImportLists = true)
{
var enabledImportLists = GetAvailableProviders().Where(n => ((ImportListDefinition)n.Definition).EnableAutomaticAdd);
if (filterBlockedImportLists)
{
return FilterBlockedImportLists(enabledImportLists).ToList();
}
return enabledImportLists.ToList();
}
private IEnumerable<IImportList> FilterBlockedImportLists(IEnumerable<IImportList> importLists)
{
var blockedImportLists = _importListStatusService.GetBlockedProviders().ToDictionary(v => v.ProviderId, v => v);
foreach (var importList in importLists)
{
ImportListStatus blockedImportListStatus;
if (blockedImportLists.TryGetValue(importList.Definition.Id, out blockedImportListStatus))
{
_logger.Debug("Temporarily ignoring import list {0} till {1} due to recent failures.", importList.Definition.Name, blockedImportListStatus.DisabledTill.Value.ToLocalTime());
continue;
}
yield return importList;
}
}
public override ValidationResult Test(ImportListDefinition definition)
{
var result = base.Test(definition);
if ((result == null || result.IsValid) && definition.Id != 0)
{
_importListStatusService.RecordSuccess(definition.Id);
}
return result;
}
}
}

View File

@ -0,0 +1,25 @@
using System.Collections;
using System.Collections.Generic;
namespace NzbDrone.Core.ImportLists
{
public class ImportListPageableRequest : IEnumerable<ImportListRequest>
{
private readonly IEnumerable<ImportListRequest> _enumerable;
public ImportListPageableRequest(IEnumerable<ImportListRequest> enumerable)
{
_enumerable = enumerable;
}
public IEnumerator<ImportListRequest> GetEnumerator()
{
return _enumerable.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return _enumerable.GetEnumerator();
}
}
}

View File

@ -0,0 +1,54 @@
using System.Collections.Generic;
using System.Linq;
namespace NzbDrone.Core.ImportLists
{
public class ImportListPageableRequestChain
{
private List<List<ImportListPageableRequest>> _chains;
public ImportListPageableRequestChain()
{
_chains = new List<List<ImportListPageableRequest>>();
_chains.Add(new List<ImportListPageableRequest>());
}
public int Tiers => _chains.Count;
public IEnumerable<ImportListPageableRequest> GetAllTiers()
{
return _chains.SelectMany(v => v);
}
public IEnumerable<ImportListPageableRequest> GetTier(int index)
{
return _chains[index];
}
public void Add(IEnumerable<ImportListRequest> request)
{
if (request == null)
{
return;
}
_chains.Last().Add(new ImportListPageableRequest(request));
}
public void AddTier(IEnumerable<ImportListRequest> request)
{
AddTier();
Add(request);
}
public void AddTier()
{
if (_chains.Last().Count == 0)
{
return;
}
_chains.Add(new List<ImportListPageableRequest>());
}
}
}

View File

@ -0,0 +1,24 @@
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.ImportLists
{
public interface IImportListRepository : IProviderRepository<ImportListDefinition>
{
void UpdateSettings(ImportListDefinition model);
}
public class ImportListRepository : ProviderRepository<ImportListDefinition>, IImportListRepository
{
public ImportListRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
public void UpdateSettings(ImportListDefinition model)
{
SetFields(model, m => m.Settings);
}
}
}

View File

@ -0,0 +1,21 @@
using NzbDrone.Common.Http;
namespace NzbDrone.Core.ImportLists
{
public class ImportListRequest
{
public HttpRequest HttpRequest { get; private set; }
public ImportListRequest(string url, HttpAccept httpAccept)
{
HttpRequest = new HttpRequest(url, httpAccept);
}
public ImportListRequest(HttpRequest httpRequest)
{
HttpRequest = httpRequest;
}
public HttpUri Url => HttpRequest.Url;
}
}

View File

@ -0,0 +1,24 @@
using NzbDrone.Common.Http;
namespace NzbDrone.Core.ImportLists
{
public class ImportListResponse
{
private readonly ImportListRequest _importListRequest;
private readonly HttpResponse _httpResponse;
public ImportListResponse(ImportListRequest importListRequest, HttpResponse httpResponse)
{
_importListRequest = importListRequest;
_httpResponse = httpResponse;
}
public ImportListRequest Request => _importListRequest;
public HttpRequest HttpRequest => _httpResponse.Request;
public HttpResponse HttpResponse => _httpResponse;
public string Content => _httpResponse.Content;
}
}

View File

@ -0,0 +1,10 @@
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.ThingiProvider.Status;
namespace NzbDrone.Core.ImportLists
{
public class ImportListStatus : ProviderStatusBase
{
public ImportListItemInfo LastSyncListInfo { get; set; }
}
}

View File

@ -0,0 +1,19 @@
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.ThingiProvider.Status;
namespace NzbDrone.Core.ImportLists
{
public interface IImportListStatusRepository : IProviderStatusRepository<ImportListStatus>
{
}
public class ImportListStatusRepository : ProviderStatusRepository<ImportListStatus>, IImportListStatusRepository
{
public ImportListStatusRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
}
}

View File

@ -0,0 +1,41 @@
using NLog;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.ThingiProvider.Status;
namespace NzbDrone.Core.ImportLists
{
public interface IImportListStatusService : IProviderStatusServiceBase<ImportListStatus>
{
ImportListItemInfo GetLastSyncListInfo(int importListId);
void UpdateListSyncStatus(int importListId, ImportListItemInfo listItemInfo);
}
public class ImportListStatusService : ProviderStatusServiceBase<IImportList, ImportListStatus>, IImportListStatusService
{
public ImportListStatusService(IImportListStatusRepository providerStatusRepository, IEventAggregator eventAggregator, IRuntimeInfo runtimeInfo, Logger logger)
: base(providerStatusRepository, eventAggregator, runtimeInfo, logger)
{
}
public ImportListItemInfo GetLastSyncListInfo(int importListId)
{
return GetProviderStatus(importListId).LastSyncListInfo;
}
public void UpdateListSyncStatus(int importListId, ImportListItemInfo listItemInfo)
{
lock (_syncRoot)
{
var status = GetProviderStatus(importListId);
status.LastSyncListInfo = listItemInfo;
_providerStatusRepository.Upsert(status);
}
}
}
}

View File

@ -0,0 +1,22 @@
using NzbDrone.Core.Messaging.Commands;
namespace NzbDrone.Core.ImportLists
{
public class ImportListSyncCommand : Command
{
public int? DefinitionId { get; set; }
public ImportListSyncCommand()
{
}
public ImportListSyncCommand(int? definition)
{
DefinitionId = definition;
}
public override bool SendUpdatesToClient => true;
public override bool UpdateScheduledTask => !DefinitionId.HasValue;
}
}

View File

@ -0,0 +1,154 @@
using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.ImportLists.Exclusions;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Tv;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.ImportLists
{
public class ImportListSyncService : IExecute<ImportListSyncCommand>
{
private readonly IImportListFactory _importListFactory;
private readonly IImportListExclusionService _importListExclusionService;
private readonly IFetchAndParseImportList _listFetcherAndParser;
private readonly ISearchForNewSeries _seriesSearchService;
private readonly ISeriesService _seriesService;
private readonly IAddSeriesService _addSeriesService;
private readonly Logger _logger;
public ImportListSyncService(IImportListFactory importListFactory,
IImportListExclusionService importListExclusionService,
IFetchAndParseImportList listFetcherAndParser,
ISearchForNewSeries seriesSearchService,
ISeriesService seriesService,
IAddSeriesService addSeriesService,
Logger logger)
{
_importListFactory = importListFactory;
_importListExclusionService = importListExclusionService;
_listFetcherAndParser = listFetcherAndParser;
_seriesSearchService = seriesSearchService;
_seriesService = seriesService;
_addSeriesService = addSeriesService;
_logger = logger;
}
private void SyncAll()
{
_logger.ProgressInfo("Starting Import List Sync");
var rssReleases = _listFetcherAndParser.Fetch();
var reports = rssReleases.ToList();
ProcessReports(reports);
}
private void SyncList(ImportListDefinition definition)
{
_logger.ProgressInfo(string.Format("Starting Import List Refresh for List {0}", definition.Name));
var rssReleases = _listFetcherAndParser.FetchSingleList(definition);
var reports = rssReleases.ToList();
ProcessReports(reports);
}
private void ProcessReports(List<ImportListItemInfo> reports)
{
var seriesToAdd = new List<Series>();
_logger.ProgressInfo("Processing {0} list items", reports.Count);
var reportNumber = 1;
var listExclusions = _importListExclusionService.All();
foreach (var report in reports)
{
_logger.ProgressTrace("Processing list item {0}/{1}", reportNumber, reports.Count);
reportNumber++;
var importList = _importListFactory.Get(report.ImportListId);
// Map TVDb if we only have a series name
if (report.TvdbId <= 0 && report.Title.IsNotNullOrWhiteSpace())
{
var mappedSeries = _seriesSearchService.SearchForNewSeries(report.Title)
.FirstOrDefault();
report.TvdbId = mappedSeries.TvdbId;
report.Title = mappedSeries?.Title;
}
// Check to see if series in DB
var existingSeries = _seriesService.FindByTvdbId(report.TvdbId);
// Break if Series Exists in DB
if (existingSeries != null)
{
_logger.Debug("{0} [{1}] Rejected, Series Exists in DB", report.TvdbId, report.Title);
continue;
}
// Check to see if series excluded
var excludedSeries = listExclusions.Where(s => s.TvdbId == report.TvdbId).SingleOrDefault();
if (excludedSeries != null)
{
_logger.Debug("{0} [{1}] Rejected due to list exlcusion", report.TvdbId, report.Title);
continue;
}
// Append Series if not already in DB or already on add list
if (seriesToAdd.All(s => s.TvdbId != report.TvdbId))
{
var monitored = importList.ShouldMonitor != MonitorTypes.None;
seriesToAdd.Add(new Series
{
TvdbId = report.TvdbId,
Monitored = monitored,
RootFolderPath = importList.RootFolderPath,
QualityProfileId = importList.QualityProfileId,
LanguageProfileId = importList.LanguageProfileId,
Tags = importList.Tags,
SeasonFolder = true,
AddOptions = new AddSeriesOptions
{
SearchForMissingEpisodes = monitored,
Monitor = importList.ShouldMonitor
}
});
}
}
_addSeriesService.AddSeries(seriesToAdd);
var message = string.Format("Import List Sync Completed. Items found: {0}, Series added: {1}", reports.Count, seriesToAdd.Count);
_logger.ProgressInfo(message);
}
public void Execute(ImportListSyncCommand message)
{
if (message.DefinitionId.HasValue)
{
SyncList(_importListFactory.Get(message.DefinitionId.Value));
}
else
{
SyncAll();
}
}
}
}

View File

@ -0,0 +1,9 @@
namespace NzbDrone.Core.ImportLists
{
public enum ImportListType
{
Program,
Trakt,
Other
}
}

View File

@ -0,0 +1,21 @@
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.ThingiProvider.Events;
namespace NzbDrone.Core.ImportLists
{
public class ImportListUpdatedHandler : IHandle<ProviderUpdatedEvent<IImportList>>
{
private readonly IManageCommandQueue _commandQueueManager;
public ImportListUpdatedHandler(IManageCommandQueue commandQueueManager)
{
_commandQueueManager = commandQueueManager;
}
public void Handle(ProviderUpdatedEvent<IImportList> message)
{
_commandQueueManager.Push(new ImportListSyncCommand(message.Definition.Id));
}
}
}

View File

@ -9,6 +9,7 @@ using NzbDrone.Core.DataAugmentation.Scene;
using NzbDrone.Core.Download;
using NzbDrone.Core.HealthCheck;
using NzbDrone.Core.Housekeeping;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Lifecycle;
using NzbDrone.Core.MediaFiles.Commands;
@ -76,6 +77,12 @@ namespace NzbDrone.Core.Jobs
TypeName = typeof(BackupCommand).FullName
},
new ScheduledTask
{
Interval = 24 * 60,
TypeName = typeof(ImportListSyncCommand).FullName
},
new ScheduledTask
{
Interval = GetRssSyncInterval(),

View File

@ -0,0 +1,19 @@
using System;
using System.Text;
namespace NzbDrone.Core.Parser.Model
{
public class ImportListItemInfo
{
public int ImportListId { get; set; }
public string ImportList { get; set; }
public string Title { get; set; }
public int TvdbId { get; set; }
public DateTime ReleaseDate { get; set; }
public override string ToString()
{
return string.Format("[{0}] {1}", ReleaseDate, Title);
}
}
}

View File

@ -5,6 +5,7 @@ using NzbDrone.Core.Lifecycle;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Tv;
using NzbDrone.Core.ImportLists;
namespace NzbDrone.Core.Profiles.Languages
{
@ -22,12 +23,14 @@ namespace NzbDrone.Core.Profiles.Languages
public class LanguageProfileService : ILanguageProfileService, IHandle<ApplicationStartedEvent>
{
private readonly ILanguageProfileRepository _profileRepository;
private readonly IImportListFactory _importListFactory;
private readonly ISeriesService _seriesService;
private readonly Logger _logger;
public LanguageProfileService(ILanguageProfileRepository profileRepository, ISeriesService seriesService, Logger logger)
public LanguageProfileService(ILanguageProfileRepository profileRepository, IImportListFactory importListFactory, ISeriesService seriesService, Logger logger)
{
_profileRepository = profileRepository;
_importListFactory = importListFactory;
_seriesService = seriesService;
_logger = logger;
}
@ -44,7 +47,7 @@ namespace NzbDrone.Core.Profiles.Languages
public void Delete(int id)
{
if (_seriesService.GetAllSeries().Any(c => c.LanguageProfileId == id))
if (_seriesService.GetAllSeries().Any(c => c.LanguageProfileId == id) || _importListFactory.All().Any(c => c.LanguageProfileId == id))
{
throw new LanguageProfileInUseException(id);
}

View File

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.Lifecycle;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Qualities;
@ -22,12 +23,14 @@ namespace NzbDrone.Core.Profiles.Qualities
public class QualityProfileService : IProfileService, IHandle<ApplicationStartedEvent>
{
private readonly IProfileRepository _profileRepository;
private readonly IImportListFactory _importListFactory;
private readonly ISeriesService _seriesService;
private readonly Logger _logger;
public QualityProfileService(IProfileRepository profileRepository, ISeriesService seriesService, Logger logger)
public QualityProfileService(IProfileRepository profileRepository, IImportListFactory importListFactory, ISeriesService seriesService, Logger logger)
{
_profileRepository = profileRepository;
_importListFactory = importListFactory;
_seriesService = seriesService;
_logger = logger;
}
@ -44,7 +47,7 @@ namespace NzbDrone.Core.Profiles.Qualities
public void Delete(int id)
{
if (_seriesService.GetAllSeries().Any(c => c.QualityProfileId == id))
if (_seriesService.GetAllSeries().Any(c => c.QualityProfileId == id) || _importListFactory.All().Any(c => c.QualityProfileId == id))
{
var profile = _profileRepository.Get(id);
throw new QualityProfileInUseException(profile.Name);

View File

@ -11,12 +11,13 @@ namespace NzbDrone.Core.Tags
public List<int> NotificationIds { get; set; }
public List<int> RestrictionIds { get; set; }
public List<int> DelayProfileIds { get; set; }
public List<int> ImportListIds { get; set; }
public bool InUse
{
get
{
return (SeriesIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any());
return (SeriesIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any() || ImportListIds.Any());
}
}
}

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Notifications;
using NzbDrone.Core.Profiles.Delay;
@ -28,6 +29,7 @@ namespace NzbDrone.Core.Tags
private readonly ITagRepository _repo;
private readonly IEventAggregator _eventAggregator;
private readonly IDelayProfileService _delayProfileService;
private readonly IImportListFactory _importListFactory;
private readonly INotificationFactory _notificationFactory;
private readonly IReleaseProfileService _releaseProfileService;
private readonly ISeriesService _seriesService;
@ -35,6 +37,7 @@ namespace NzbDrone.Core.Tags
public TagService(ITagRepository repo,
IEventAggregator eventAggregator,
IDelayProfileService delayProfileService,
IImportListFactory importListFactory,
INotificationFactory notificationFactory,
IReleaseProfileService releaseProfileService,
ISeriesService seriesService)
@ -42,6 +45,7 @@ namespace NzbDrone.Core.Tags
_repo = repo;
_eventAggregator = eventAggregator;
_delayProfileService = delayProfileService;
_importListFactory = importListFactory;
_notificationFactory = notificationFactory;
_releaseProfileService = releaseProfileService;
_seriesService = seriesService;
@ -73,6 +77,7 @@ namespace NzbDrone.Core.Tags
{
var tag = GetTag(tagId);
var delayProfiles = _delayProfileService.AllForTag(tagId);
var importLists = _importListFactory.AllForTag(tagId);
var notifications = _notificationFactory.AllForTag(tagId);
var restrictions = _releaseProfileService.AllForTag(tagId);
var series = _seriesService.AllForTag(tagId);
@ -82,6 +87,7 @@ namespace NzbDrone.Core.Tags
Id = tagId,
Label = tag.Label,
DelayProfileIds = delayProfiles.Select(c => c.Id).ToList(),
ImportListIds = importLists.Select(c => c.Id).ToList(),
NotificationIds = notifications.Select(c => c.Id).ToList(),
RestrictionIds = restrictions.Select(c => c.Id).ToList(),
SeriesIds = series.Select(c => c.Id).ToList()
@ -92,6 +98,7 @@ namespace NzbDrone.Core.Tags
{
var tags = All();
var delayProfiles = _delayProfileService.All();
var importLists = _importListFactory.All();
var notifications = _notificationFactory.All();
var restrictions = _releaseProfileService.All();
var series = _seriesService.GetAllSeries();
@ -105,6 +112,7 @@ namespace NzbDrone.Core.Tags
Id = tag.Id,
Label = tag.Label,
DelayProfileIds = delayProfiles.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
ImportListIds = importLists.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
NotificationIds = notifications.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
RestrictionIds = restrictions.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
SeriesIds = series.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList()

View File

@ -6,11 +6,13 @@ namespace NzbDrone.Core.Tv.Events
{
public Series Series { get; private set; }
public bool DeleteFiles { get; private set; }
public bool AddImportListExclusion { get; private set; }
public SeriesDeletedEvent(Series series, bool deleteFiles)
public SeriesDeletedEvent(Series series, bool deleteFiles, bool addImportListExclusion)
{
Series = series;
DeleteFiles = deleteFiles;
AddImportListExclusion = addImportListExclusion;
}
}
}

View File

@ -22,7 +22,7 @@ namespace NzbDrone.Core.Tv
Series FindByTitle(string title, int year);
Series FindByTitleInexact(string title);
Series FindByPath(string path);
void DeleteSeries(int seriesId, bool deleteFiles);
void DeleteSeries(int seriesId, bool deleteFiles, bool addImportListExclusion = false);
List<Series> GetAllSeries();
List<Series> AllForTag(int tagId);
Series UpdateSeries(Series series, bool updateEpisodesToMatchSeason = true, bool publishUpdatedEvent = true);
@ -145,11 +145,11 @@ namespace NzbDrone.Core.Tv
return _seriesRepository.FindByTitle(title.CleanSeriesTitle(), year);
}
public void DeleteSeries(int seriesId, bool deleteFiles)
public void DeleteSeries(int seriesId, bool deleteFiles, bool addImportListExclusion = false)
{
var series = _seriesRepository.Get(seriesId);
_seriesRepository.Delete(seriesId);
_eventAggregator.PublishEvent(new SeriesDeletedEvent(series, deleteFiles));
_eventAggregator.PublishEvent(new SeriesDeletedEvent(series, deleteFiles, addImportListExclusion));
}
public List<Series> GetAllSeries()

View File

@ -0,0 +1,55 @@
using System.Collections.Generic;
using NzbDrone.Core.ImportLists.Exclusions;
using Sonarr.Http;
using FluentValidation;
using NzbDrone.Core.Validation;
namespace Sonarr.Api.V3.ImportLists
{
public class ImportListExclusionModule : SonarrRestModule<ImportListExclusionResource>
{
private readonly IImportListExclusionService _importListExclusionService;
public ImportListExclusionModule(IImportListExclusionService importListExclusionService,
ImportListExclusionExistsValidator importListExclusionExistsValidator)
{
_importListExclusionService = importListExclusionService;
GetResourceById = GetImportListExclusion;
GetResourceAll = GetImportListExclusions;
CreateResource = AddImportListExclusion;
UpdateResource = UpdateImportListExclusion;
DeleteResource = DeleteImportListExclusionResource;
SharedValidator.RuleFor(c => c.TvdbId).NotEmpty().SetValidator(importListExclusionExistsValidator);
SharedValidator.RuleFor(c => c.Title).NotEmpty();
}
private ImportListExclusionResource GetImportListExclusion(int id)
{
return _importListExclusionService.Get(id).ToResource();
}
private List<ImportListExclusionResource> GetImportListExclusions()
{
return _importListExclusionService.All().ToResource();
}
private int AddImportListExclusion(ImportListExclusionResource resource)
{
var customFilter = _importListExclusionService.Add(resource.ToModel());
return customFilter.Id;
}
private void UpdateImportListExclusion(ImportListExclusionResource resource)
{
_importListExclusionService.Update(resource.ToModel());
}
private void DeleteImportListExclusionResource(int id)
{
_importListExclusionService.Delete(id);
}
}
}

View File

@ -0,0 +1,45 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.ImportLists.Exclusions;
using Sonarr.Http.REST;
namespace Sonarr.Api.V3.ImportLists
{
public class ImportListExclusionResource : RestResource
{
public int TvdbId { get; set; }
public string Title { get; set; }
}
public static class ImportListExclusionResourceMapper
{
public static ImportListExclusionResource ToResource(this ImportListExclusion model)
{
if (model == null) return null;
return new ImportListExclusionResource
{
Id = model.Id,
TvdbId = model.TvdbId,
Title = model.Title,
};
}
public static ImportListExclusion ToModel(this ImportListExclusionResource resource)
{
if (resource == null) return null;
return new ImportListExclusion
{
Id = resource.Id,
TvdbId = resource.TvdbId,
Title = resource.Title
};
}
public static List<ImportListExclusionResource> ToResource(this IEnumerable<ImportListExclusion> filters)
{
return filters.Select(ToResource).ToList();
}
}
}

View File

@ -0,0 +1,34 @@
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
namespace Sonarr.Api.V3.ImportLists
{
public class ImportListModule : ProviderModuleBase<ImportListResource, IImportList, ImportListDefinition>
{
public static readonly ImportListResourceMapper ResourceMapper = new ImportListResourceMapper();
public ImportListModule(ImportListFactory importListFactory,
ProfileExistsValidator profileExistsValidator,
LanguageProfileExistsValidator languageProfileExistsValidator
)
: base(importListFactory, "importlist", ResourceMapper)
{
Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.QualityProfileId));
Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.LanguageProfileId));
SharedValidator.RuleFor(c => c.RootFolderPath).IsValidPath();
SharedValidator.RuleFor(c => c.QualityProfileId).SetValidator(profileExistsValidator);
SharedValidator.RuleFor(c => c.LanguageProfileId).SetValidator(languageProfileExistsValidator);
}
protected override void Validate(ImportListDefinition definition, bool includeWarnings)
{
if (!definition.Enable)
{
return;
}
base.Validate(definition, includeWarnings);
}
}
}

View File

@ -0,0 +1,58 @@
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.Tv;
namespace Sonarr.Api.V3.ImportLists
{
public class ImportListResource : ProviderResource
{
public bool EnableAutomaticAdd { get; set; }
public MonitorTypes ShouldMonitor { get; set; }
public string RootFolderPath { get; set; }
public int QualityProfileId { get; set; }
public int LanguageProfileId { get; set; }
public ImportListType ListType { get; set; }
public int ListOrder { get; set; }
}
public class ImportListResourceMapper : ProviderResourceMapper<ImportListResource, ImportListDefinition>
{
public override ImportListResource ToResource(ImportListDefinition definition)
{
if (definition == null)
{
return null;
}
var resource = base.ToResource(definition);
resource.EnableAutomaticAdd = definition.EnableAutomaticAdd;
resource.ShouldMonitor = definition.ShouldMonitor;
resource.RootFolderPath = definition.RootFolderPath;
resource.QualityProfileId = definition.QualityProfileId;
resource.LanguageProfileId = definition.LanguageProfileId;
resource.ListType = definition.ListType;
resource.ListOrder = (int) definition.ListType;
return resource;
}
public override ImportListDefinition ToModel(ImportListResource resource)
{
if (resource == null)
{
return null;
}
var definition = base.ToModel(resource);
definition.EnableAutomaticAdd = resource.EnableAutomaticAdd;
definition.ShouldMonitor = resource.ShouldMonitor;
definition.RootFolderPath = resource.RootFolderPath;
definition.QualityProfileId = resource.QualityProfileId;
definition.LanguageProfileId = resource.LanguageProfileId;
definition.ListType = resource.ListType;
return definition;
}
}
}

View File

@ -9,6 +9,7 @@ namespace Sonarr.Api.V3.Tags
{
public string Label { get; set; }
public List<int> DelayProfileIds { get; set; }
public List<int> ImportListIds { get; set; }
public List<int> NotificationIds { get; set; }
public List<int> RestrictionIds { get; set; }
public List<int> SeriesIds { get; set; }
@ -25,6 +26,7 @@ namespace Sonarr.Api.V3.Tags
Id = model.Id,
Label = model.Label,
DelayProfileIds = model.DelayProfileIds,
ImportListIds = model.ImportListIds,
NotificationIds = model.NotificationIds,
RestrictionIds = model.RestrictionIds,
SeriesIds = model.SeriesIds