diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index 4ab75eb22..42fd9eca6 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -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} /> + + 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 diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js index 6ffdf53cc..ecc434901 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -111,6 +111,10 @@ const links = [ title: 'Download Clients', to: '/settings/downloadclients' }, + { + title: 'Import Lists', + to: '/settings/importlists' + }, { title: 'Connect', to: '/settings/connect' diff --git a/frontend/src/Series/Delete/DeleteSeriesModalContent.js b/frontend/src/Series/Delete/DeleteSeriesModalContent.js index b6375d842..acda974cd 100644 --- a/frontend/src/Series/Delete/DeleteSeriesModalContent.js +++ b/frontend/src/Series/Delete/DeleteSeriesModalContent.js @@ -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} + + Add List Exclusion + + + + {deleteFilesLabel} diff --git a/frontend/src/Series/Delete/DeleteSeriesModalContentConnector.js b/frontend/src/Series/Delete/DeleteSeriesModalContentConnector.js index 73033f957..436c7d4b9 100644 --- a/frontend/src/Series/Delete/DeleteSeriesModalContentConnector.js +++ b/frontend/src/Series/Delete/DeleteSeriesModalContentConnector.js @@ -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); diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModal.js b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModal.js new file mode 100644 index 000000000..72566b289 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModal.js @@ -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 ( + + + + ); +} + +EditImportListExclusionModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditImportListExclusionModal; diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalConnector.js b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalConnector.js new file mode 100644 index 000000000..f9a511675 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalConnector.js @@ -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 ( + + ); + } +} + +EditImportListExclusionModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditImportListExclusionModalConnector); diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css new file mode 100644 index 000000000..97e132552 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css @@ -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; +} diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.js b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.js new file mode 100644 index 000000000..f99567a73 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.js @@ -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 ( + + + {id ? 'Edit Import List Exclusion' : 'Add Import List Exclusion'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new import list exclusion, please try again.
+ } + + { + !isFetching && !error && +
+ + Title + + + + + + TVDB ID + + + +
+ } +
+ + + { + id && + + } + + + + + Save + + +
+ ); +} + +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; diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContentConnector.js b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContentConnector.js new file mode 100644 index 000000000..be3c24af4 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContentConnector.js @@ -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 ( + + ); + } +} + +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); diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.css b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.css new file mode 100644 index 000000000..627620813 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.css @@ -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; +} diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.js b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.js new file mode 100644 index 000000000..4b1d3da2d --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.js @@ -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 ( +
+
{title}
+
{tvdbId}
+ +
+ + + +
+ + + + +
+ ); + } +} + +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; diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css new file mode 100644 index 000000000..99e1c1e99 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css @@ -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; +} diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.js b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.js new file mode 100644 index 000000000..bb338fca3 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.js @@ -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 ( +
+ +
+
Title
+
TVDB ID
+
+ +
+ { + items.map((item, index) => { + return ( + + ); + }) + } +
+ +
+ + + +
+ + + +
+
+ ); + } +} + +ImportListExclusions.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteImportListExclusion: PropTypes.func.isRequired +}; + +export default ImportListExclusions; diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionsConnector.js b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionsConnector.js new file mode 100644 index 000000000..c5f15f43d --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionsConnector.js @@ -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 ( + + ); + } +} + +ImportListExclusionsConnector.propTypes = { + fetchImportListExclusions: PropTypes.func.isRequired, + deleteImportListExclusion: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportListExclusionsConnector); diff --git a/frontend/src/Settings/ImportLists/ImportListSettings.js b/frontend/src/Settings/ImportLists/ImportListSettings.js new file mode 100644 index 000000000..6b7c53e99 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListSettings.js @@ -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 ( + + + + + + + } + onSavePress={this.onSavePress} + /> + + + + + + + ); + } +} + +ImportListSettings.propTypes = { + isTestingAll: PropTypes.bool.isRequired, + dispatchTestAllImportLists: PropTypes.func.isRequired +}; + +export default ImportListSettings; diff --git a/frontend/src/Settings/ImportLists/ImportListSettingsConnector.js b/frontend/src/Settings/ImportLists/ImportListSettingsConnector.js new file mode 100644 index 000000000..7607faef7 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListSettingsConnector.js @@ -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); diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.css b/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.css new file mode 100644 index 000000000..5e69c30a4 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.css @@ -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'; + } +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.js new file mode 100644 index 000000000..a76169040 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.js @@ -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 ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets && + + + + + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + +
+
+
+ ); + } +} + +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; diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModal.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModal.js new file mode 100644 index 000000000..a188d6b4a --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModal.js @@ -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 ( + + + + ); +} + +AddImportListModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddImportListModal; diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.css b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.css new file mode 100644 index 000000000..8454ca79f --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.css @@ -0,0 +1,5 @@ +.lists { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.js new file mode 100644 index 000000000..4169d303b --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.js @@ -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 ( + + + Add List + + + + { + isSchemaFetching ? + : + null + } + + { + !isSchemaFetching && !!schemaError ? +
Unable to add a new list, please try again.
: + null + } + + { + isSchemaPopulated && !schemaError ? +
+ + +
Sonarr supports multiple lists for importing Series into the database.
+
For more information on the individual lists, click on the info buttons.
+
+ { + Object.keys(listGroups).map((key) => { + return ( +
+
+ { + listGroups[key].map((list) => { + return ( + + ); + }) + } +
+
+ ); + }) + } +
: + null + } +
+ + + +
+ ); + } +} + +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; diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContentConnector.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContentConnector.js new file mode 100644 index 000000000..e464ccb93 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContentConnector.js @@ -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 ( + + ); + } +} + +AddImportListModalContentConnector.propTypes = { + fetchImportListSchema: PropTypes.func.isRequired, + selectImportListSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddImportListModalContentConnector); diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListPresetMenuItem.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListPresetMenuItem.js new file mode 100644 index 000000000..477044ae0 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListPresetMenuItem.js @@ -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 ( + + {name} + + ); + } +} + +AddImportListPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddImportListPresetMenuItem; diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModal.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModal.js new file mode 100644 index 000000000..b673ae9a4 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModal.js @@ -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 ( + + + + ); +} + +EditImportListModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditImportListModal; diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalConnector.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalConnector.js new file mode 100644 index 000000000..72d39817b --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalConnector.js @@ -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 ( + + ); + } +} + +EditImportListModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestImportList: PropTypes.func.isRequired, + dispatchCancelSaveImportList: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditImportListModalConnector); diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.css b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.css new file mode 100644 index 000000000..db5a98500 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.css @@ -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; +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js new file mode 100644 index 000000000..1bbae4177 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js @@ -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 ( + + + {id ? 'Edit List' : 'Add List'} + + + + { + isFetching ? + : + null + } + + { + !isFetching && !!error ? +
Unable to add a new list, please try again.
: + null + } + + { + !isFetching && !error ? +
+ + Name + + + + + + Enable Automatic Add + + + + + + + Monitor + + + } + title="Monitoring Options" + body={} + position={tooltipPositions.RIGHT} + /> + + + + + + + Root Folder + + + + + + Quality Profile + + + + + + Language Profile + + + + + + Sonarr Tags + + + + + { + !!fields && !!fields.length && +
+ { + fields.map((field) => { + return ( + + ); + }) + } +
+ } + +
: + null + } +
+ + { + id && + + } + + + Test + + + + + + Save + + +
+ ); +} + +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; diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContentConnector.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContentConnector.js new file mode 100644 index 000000000..1227ecfea --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContentConnector.js @@ -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 ( + + ); + } +} + +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); diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportList.css b/frontend/src/Settings/ImportLists/ImportLists/ImportList.css new file mode 100644 index 000000000..4796748f7 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportList.css @@ -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; +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportList.js b/frontend/src/Settings/ImportLists/ImportLists/ImportList.js new file mode 100644 index 000000000..f0b1044e7 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportList.js @@ -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 ( + +
+ {name} +
+ +
+ { + enableAutomaticAdd && + + } + +
+ + + + +
+ ); + } +} + +ImportList.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enableAutomaticAdd: PropTypes.bool.isRequired, + onConfirmDeleteImportList: PropTypes.func.isRequired +}; + +export default ImportList; diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportLists.css b/frontend/src/Settings/ImportLists/ImportLists/ImportLists.css new file mode 100644 index 000000000..3db4e69d6 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportLists.css @@ -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; +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportLists.js b/frontend/src/Settings/ImportLists/ImportLists/ImportLists.js new file mode 100644 index 000000000..7f654050e --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportLists.js @@ -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 ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + + +
+
+ ); + } +} + +ImportLists.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteImportList: PropTypes.func.isRequired +}; + +export default ImportLists; diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js b/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js new file mode 100644 index 000000000..3938da4ae --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js @@ -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 ( + + ); + } +} + +ListsConnector.propTypes = { + fetchImportLists: PropTypes.func.isRequired, + deleteImportList: PropTypes.func.isRequired, + fetchRootFolders: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ListsConnector); diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js index bb1d3b370..4fcc913cd 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js @@ -18,6 +18,7 @@ function TagDetailsModalContent(props) { isTagUsed, series, delayProfiles, + importLists, notifications, releaseProfiles, onModalClose, @@ -95,6 +96,21 @@ function TagDetailsModalContent(props) { } + { + !!importLists.length && +
+ { + importLists.map((item) => { + return ( +
+ {item.name} +
+ ); + }) + } +
+ } + { !!releaseProfiles.length &&
@@ -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, diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js index 8cfee15c7..470dbb059 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js @@ -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 }; diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js index 0cb9ee208..00a13ec82 100644 --- a/frontend/src/Settings/Tags/Tag.js +++ b/frontend/src/Settings/Tags/Tag.js @@ -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 &&
{ - !!seriesIds.length && + seriesIds.length ?
{seriesIds.length} series -
+
: + null } { - !!delayProfileIds.length && + delayProfileIds.length ?
{delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'} -
+ : + null } { - !!notificationIds.length && + importListIds.length ? +
+ {importListIds.length} import list{importListIds.length > 1 && 's'} +
: + null + } + + { + notificationIds.length ?
{notificationIds.length} connection{notificationIds.length > 1 && 's'} -
+ : + null } { - !!restrictionIds.length && + restrictionIds.length ?
{restrictionIds.length} restriction{restrictionIds.length > 1 && 's'} -
+ : + null } } @@ -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: [] diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js index 3439f6d0d..70b727387 100644 --- a/frontend/src/Settings/Tags/TagsConnector.js +++ b/frontend/src/Settings/Tags/TagsConnector.js @@ -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 }; diff --git a/frontend/src/Store/Actions/Settings/importListExclusions.js b/frontend/src/Store/Actions/Settings/importListExclusions.js new file mode 100644 index 000000000..b584e9e28 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/importListExclusions.js @@ -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) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/importLists.js b/frontend/src/Store/Actions/Settings/importLists.js new file mode 100644 index 000000000..e819299e2 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/importLists.js @@ -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; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/seriesActions.js b/frontend/src/Store/Actions/seriesActions.js index 3e576c22d..5fd71a4ce 100644 --- a/frontend/src/Store/Actions/seriesActions.js +++ b/frontend/src/Store/Actions/seriesActions.js @@ -199,7 +199,8 @@ export const deleteSeries = createThunk(DELETE_SERIES, (payload) => { return { ...payload, queryParams: { - deleteFiles: payload.deleteFiles + deleteFiles: payload.deleteFiles, + addImportListExclusion: payload.addImportListExclusion } }; }); diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index b8640dead..23ca76789 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -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, diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.js b/frontend/src/Store/Selectors/createProfileInUseSelector.js index 540b61d26..861a89147 100644 --- a/frontend/src/Store/Selectors/createProfileInUseSelector.js +++ b/frontend/src/Store/Selectors/createProfileInUseSelector.js @@ -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); } ); } diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/ImportListStatusCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/ImportListStatusCheckFixture.cs new file mode 100644 index 000000000..3316eb007 --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/ImportListStatusCheckFixture.cs @@ -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 + { + private List _importLists = new List(); + private List _blockedImportLists = new List(); + + [SetUp] + public void SetUp() + { + Mocker.GetMock() + .Setup(v => v.GetAvailableProviders()) + .Returns(_importLists); + + Mocker.GetMock() + .Setup(v => v.GetBlockedProviders()) + .Returns(_blockedImportLists); + } + + private Mock GivenImportList(int id, double backoffHours, double failureHours) + { + var mockImportList = new Mock(); + 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(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerStatusCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerStatusCheckFixture.cs index 50beaae7e..e3deac674 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerStatusCheckFixture.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerStatusCheckFixture.cs @@ -26,10 +26,8 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks .Returns(_blockedIndexers); } - private Mock GivenIndexer(int i, double backoffHours, double failureHours) + private Mock GivenIndexer(int id, double backoffHours, double failureHours) { - var id = i; - var mockIndexer = new Mock(); mockIndexer.SetupGet(s => s.Definition).Returns(new IndexerDefinition { Id = id }); mockIndexer.SetupGet(s => s.SupportsSearch).Returns(true); diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedImportListStatusFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedImportListStatusFixture.cs new file mode 100644 index 000000000..e051327ee --- /dev/null +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedImportListStatusFixture.cs @@ -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 + { + private ImportListDefinition _importList; + + [SetUp] + public void Setup() + { + _importList = Builder.CreateNew() + .BuildNew(); + } + + private void GivenIndexer() + { + Db.Insert(_importList); + } + + [Test] + public void should_delete_orphaned_indexerstatus() + { + var status = Builder.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.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); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/ImportListTests/ImportListStatusServiceFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/ImportListStatusServiceFixture.cs new file mode 100644 index 000000000..5da877f36 --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/ImportListStatusServiceFixture.cs @@ -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 + { + private DateTime _epoch; + + [SetUp] + public void SetUp() + { + _epoch = DateTime.UtcNow; + + Mocker.GetMock() + .SetupGet(v => v.StartTime) + .Returns(_epoch - TimeSpan.FromHours(1)); + } + + private void WithStatus(ImportListStatus status) + { + Mocker.GetMock() + .Setup(v => v.FindByProviderId(1)) + .Returns(status); + + Mocker.GetMock() + .Setup(v => v.All()) + .Returns(new[] { status }); + } + + private void VerifyUpdate() + { + Mocker.GetMock() + .Verify(v => v.Upsert(It.IsAny()), Times.Once()); + } + + private void VerifyNoUpdate() + { + Mocker.GetMock() + .Verify(v => v.Upsert(It.IsAny()), 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(); + } + } +} diff --git a/src/NzbDrone.Core.Test/ImportListTests/ImportListSyncServiceFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/ImportListSyncServiceFixture.cs new file mode 100644 index 000000000..213e813cb --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/ImportListSyncServiceFixture.cs @@ -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 + { + private List _importListReports; + + [SetUp] + public void SetUp() + { + var importListItem1 = new ImportListItemInfo + { + Title = "Breaking Bad" + }; + + _importListReports = new List{importListItem1}; + + Mocker.GetMock() + .Setup(v => v.Fetch()) + .Returns(_importListReports); + + Mocker.GetMock() + .Setup(v => v.SearchForNewSeries(It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(v => v.Get(It.IsAny())) + .Returns(new ImportListDefinition{ ShouldMonitor = MonitorTypes.All }); + + Mocker.GetMock() + .Setup(v => v.Fetch()) + .Returns(_importListReports); + + Mocker.GetMock() + .Setup(v => v.All()) + .Returns(new List()); + } + + private void WithTvdbId() + { + _importListReports.First().TvdbId = 81189; + } + + private void WithExistingSeries() + { + Mocker.GetMock() + .Setup(v => v.FindByTvdbId(_importListReports.First().TvdbId)) + .Returns(new Series{TvdbId = _importListReports.First().TvdbId }); + } + + private void WithExcludedSeries() + { + Mocker.GetMock() + .Setup(v => v.All()) + .Returns(new List { + new ImportListExclusion { + TvdbId = 81189 + } + }); + } + + private void WithMonitorType(MonitorTypes monitor) + { + Mocker.GetMock() + .Setup(v => v.Get(It.IsAny())) + .Returns(new ImportListDefinition{ ShouldMonitor = monitor }); + } + + [Test] + public void should_search_if_series_title_and_no_series_id() + { + Subject.Execute(new ImportListSyncCommand()); + + Mocker.GetMock() + .Verify(v => v.SearchForNewSeries(It.IsAny()), Times.Once()); + } + + [Test] + public void should_not_search_if_series_title_and_series_id() + { + WithTvdbId(); + Subject.Execute(new ImportListSyncCommand()); + + Mocker.GetMock() + .Verify(v => v.SearchForNewSeries(It.IsAny()), Times.Never()); + } + + + [Test] + public void should_not_add_if_existing_series() + { + WithTvdbId(); + WithExistingSeries(); + + Subject.Execute(new ImportListSyncCommand()); + + Mocker.GetMock() + .Verify(v => v.AddSeries(It.Is>(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() + .Verify(v => v.AddSeries(It.Is>(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() + .Verify(v => v.AddSeries(It.Is>(t => t.Count == 0))); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/142_import_lists.cs b/src/NzbDrone.Core/Datastore/Migration/142_import_lists.cs new file mode 100644 index 000000000..1351a852c --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/142_import_lists.cs @@ -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(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 32c778d8f..a8cd220d8 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -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().RegisterDefinition("ImportLists") + .Ignore(i => i.ListType) + .Ignore(i => i.Enable); + Mapper.Entity().RegisterModel("SceneMappings"); Mapper.Entity().RegisterModel("History") @@ -135,6 +141,7 @@ namespace NzbDrone.Core.Datastore Mapper.Entity().RegisterModel("IndexerStatus"); Mapper.Entity().RegisterModel("DownloadClientStatus"); + Mapper.Entity().RegisterModel("ImportListStatus"); Mapper.Entity().RegisterModel("CustomFilters"); @@ -142,6 +149,7 @@ namespace NzbDrone.Core.Datastore .AutoMapChildModels(); Mapper.Entity().RegisterModel("UpdateHistory"); + Mapper.Entity().RegisterModel("ImportListExclusions"); } private static void RegisterMappers() diff --git a/src/NzbDrone.Core/HealthCheck/Checks/ImportListStatusCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/ImportListStatusCheck.cs new file mode 100644 index 000000000..744f4f691 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/ImportListStatusCheck.cs @@ -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))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderStatusChangedEvent))] + 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"); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedImportListStatus.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedImportListStatus.cs new file mode 100644 index 000000000..656463a43 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedImportListStatus.cs @@ -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)"); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Exceptions/ImportListException.cs b/src/NzbDrone.Core/ImportLists/Exceptions/ImportListException.cs new file mode 100644 index 000000000..cdc61c095 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Exceptions/ImportListException.cs @@ -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; + } +} diff --git a/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusion.cs b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusion.cs new file mode 100644 index 000000000..bbaaceac9 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusion.cs @@ -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; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionExistsValidator.cs b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionExistsValidator.cs new file mode 100644 index 000000000..fa8983bf6 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionExistsValidator.cs @@ -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)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionRepository.cs b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionRepository.cs new file mode 100644 index 000000000..effd3cf3e --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionRepository.cs @@ -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 FindByTvdbId(int tvdbId); + } + + public class ImportListExclusionRepository : BasicRepository, IImportListExclusionRepository + { + public ImportListExclusionRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public ImportListExclusion FindByTvdbId(int tvdbId) + { + return Query.Where(m => m.TvdbId == tvdbId).SingleOrDefault(); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionService.cs b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionService.cs new file mode 100644 index 000000000..54f361180 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionService.cs @@ -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 All(); + void Delete(int id); + ImportListExclusion Get(int id); + ImportListExclusion FindByTvdbId(int tvdbId); + ImportListExclusion Update(ImportListExclusion importListExclusion); + } + + public class ImportListExclusionService : IImportListExclusionService, IHandleAsync + { + 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 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); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs b/src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs new file mode 100644 index 000000000..ae0e9081d --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs @@ -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 Fetch(); + List 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 Fetch() + { + var result = new List(); + + 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(); + 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 FetchSingleList(ImportListDefinition definition) + { + var result = new List(); + + 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(); + 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; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/HttpImportListBase.cs b/src/NzbDrone.Core/ImportLists/HttpImportListBase.cs new file mode 100644 index 000000000..a68ac84c1 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/HttpImportListBase.cs @@ -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 : ImportListBase + 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 Fetch() + { + return FetchItems(g => g.GetListItems(), true); + } + + protected virtual IList FetchItems(Func pageableRequestChainSelector, bool isRecent = false) + { + var releases = new List(); + 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(); + + 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 page) + { + return PageSize != 0 && page.Count >= PageSize; + } + + protected virtual IList 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 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; + } + } + +} diff --git a/src/NzbDrone.Core/ImportLists/IImportList.cs b/src/NzbDrone.Core/ImportLists/IImportList.cs new file mode 100644 index 000000000..5aad5bb9d --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/IImportList.cs @@ -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 Fetch(); + } +} diff --git a/src/NzbDrone.Core/ImportLists/IImportListRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/IImportListRequestGenerator.cs new file mode 100644 index 000000000..b0cbe0410 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/IImportListRequestGenerator.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.ImportLists +{ + public interface IImportListRequestGenerator + { + ImportListPageableRequestChain GetListItems(); + } +} diff --git a/src/NzbDrone.Core/ImportLists/IImportListSettings.cs b/src/NzbDrone.Core/ImportLists/IImportListSettings.cs new file mode 100644 index 000000000..3fbd7b7e3 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/IImportListSettings.cs @@ -0,0 +1,9 @@ +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.ImportLists +{ + public interface IImportListSettings : IProviderConfig + { + string BaseUrl { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/IProcessImportListResponse.cs b/src/NzbDrone.Core/ImportLists/IProcessImportListResponse.cs new file mode 100644 index 000000000..fef025282 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/IProcessImportListResponse.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists +{ + public interface IParseImportListResponse + { + IList ParseResponse(ImportListResponse importListResponse); + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListBase.cs b/src/NzbDrone.Core/ImportLists/ImportListBase.cs new file mode 100644 index 000000000..a705619b4 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListBase.cs @@ -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 : 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 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 query) { return null; } + + protected TSettings Settings => (TSettings)Definition.Settings; + + public abstract IList Fetch(); + + protected virtual IList CleanupListItems(IEnumerable 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(); + + 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 failures); + + public override string ToString() + { + return Definition.Name; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListDefinition.cs b/src/NzbDrone.Core/ImportLists/ImportListDefinition.cs new file mode 100644 index 000000000..e185452d2 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListDefinition.cs @@ -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; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListFactory.cs b/src/NzbDrone.Core/ImportLists/ImportListFactory.cs new file mode 100644 index 000000000..79165d275 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListFactory.cs @@ -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 + { + List AutomaticAddEnabled(bool filterBlockedImportLists = true); + } + + public class ImportListFactory : ProviderFactory, IImportListFactory + { + private readonly IImportListStatusService _importListStatusService; + private readonly Logger _logger; + + public ImportListFactory(IImportListStatusService importListStatusService, + IImportListRepository providerRepository, + IEnumerable providers, + IContainer container, + IEventAggregator eventAggregator, + Logger logger) + : base(providerRepository, providers, container, eventAggregator, logger) + { + _importListStatusService = importListStatusService; + _logger = logger; + } + + protected override List 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 AutomaticAddEnabled(bool filterBlockedImportLists = true) + { + var enabledImportLists = GetAvailableProviders().Where(n => ((ImportListDefinition)n.Definition).EnableAutomaticAdd); + + if (filterBlockedImportLists) + { + return FilterBlockedImportLists(enabledImportLists).ToList(); + } + + return enabledImportLists.ToList(); + } + + private IEnumerable FilterBlockedImportLists(IEnumerable 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; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListPageableRequest.cs b/src/NzbDrone.Core/ImportLists/ImportListPageableRequest.cs new file mode 100644 index 000000000..64b4fbe81 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListPageableRequest.cs @@ -0,0 +1,25 @@ +using System.Collections; +using System.Collections.Generic; + +namespace NzbDrone.Core.ImportLists +{ + public class ImportListPageableRequest : IEnumerable + { + private readonly IEnumerable _enumerable; + + public ImportListPageableRequest(IEnumerable enumerable) + { + _enumerable = enumerable; + } + + public IEnumerator GetEnumerator() + { + return _enumerable.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return _enumerable.GetEnumerator(); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListPageableRequestChain.cs b/src/NzbDrone.Core/ImportLists/ImportListPageableRequestChain.cs new file mode 100644 index 000000000..9b2503c35 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListPageableRequestChain.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.ImportLists +{ + public class ImportListPageableRequestChain + { + private List> _chains; + + public ImportListPageableRequestChain() + { + _chains = new List>(); + _chains.Add(new List()); + } + + public int Tiers => _chains.Count; + + public IEnumerable GetAllTiers() + { + return _chains.SelectMany(v => v); + } + + public IEnumerable GetTier(int index) + { + return _chains[index]; + } + + public void Add(IEnumerable request) + { + if (request == null) + { + return; + } + + _chains.Last().Add(new ImportListPageableRequest(request)); + } + + public void AddTier(IEnumerable request) + { + AddTier(); + Add(request); + } + + public void AddTier() + { + if (_chains.Last().Count == 0) + { + return; + } + + _chains.Add(new List()); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListRepository.cs b/src/NzbDrone.Core/ImportLists/ImportListRepository.cs new file mode 100644 index 000000000..3471d39a0 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListRepository.cs @@ -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 + { + void UpdateSettings(ImportListDefinition model); + } + + public class ImportListRepository : ProviderRepository, IImportListRepository + { + public ImportListRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public void UpdateSettings(ImportListDefinition model) + { + SetFields(model, m => m.Settings); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListRequest.cs b/src/NzbDrone.Core/ImportLists/ImportListRequest.cs new file mode 100644 index 000000000..f95b9b95e --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListRequest.cs @@ -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; + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListResponse.cs b/src/NzbDrone.Core/ImportLists/ImportListResponse.cs new file mode 100644 index 000000000..2dc465b8d --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListResponse.cs @@ -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; + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListStatus.cs b/src/NzbDrone.Core/ImportLists/ImportListStatus.cs new file mode 100644 index 000000000..e58976744 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListStatus.cs @@ -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; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListStatusRepository.cs b/src/NzbDrone.Core/ImportLists/ImportListStatusRepository.cs new file mode 100644 index 000000000..8bf500a7a --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListStatusRepository.cs @@ -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 + { + } + + public class ImportListStatusRepository : ProviderStatusRepository, IImportListStatusRepository + + { + public ImportListStatusRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListStatusService.cs b/src/NzbDrone.Core/ImportLists/ImportListStatusService.cs new file mode 100644 index 000000000..9898a3f78 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListStatusService.cs @@ -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 + { + ImportListItemInfo GetLastSyncListInfo(int importListId); + + void UpdateListSyncStatus(int importListId, ImportListItemInfo listItemInfo); + } + + public class ImportListStatusService : ProviderStatusServiceBase, 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); + } + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListSyncCommand.cs b/src/NzbDrone.Core/ImportLists/ImportListSyncCommand.cs new file mode 100644 index 000000000..d08820caf --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListSyncCommand.cs @@ -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; + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs new file mode 100644 index 000000000..2359443a5 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs @@ -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 + { + 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 reports) + { + var seriesToAdd = new List(); + + _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(); + } + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListType.cs b/src/NzbDrone.Core/ImportLists/ImportListType.cs new file mode 100644 index 000000000..deabff71a --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListType.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.ImportLists +{ + public enum ImportListType + { + Program, + Trakt, + Other + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListUpdatedHandler.cs b/src/NzbDrone.Core/ImportLists/ImportListUpdatedHandler.cs new file mode 100644 index 000000000..fbd14ea05 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListUpdatedHandler.cs @@ -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> + { + private readonly IManageCommandQueue _commandQueueManager; + + public ImportListUpdatedHandler(IManageCommandQueue commandQueueManager) + { + _commandQueueManager = commandQueueManager; + } + + public void Handle(ProviderUpdatedEvent message) + { + _commandQueueManager.Push(new ImportListSyncCommand(message.Definition.Id)); + } + } +} diff --git a/src/NzbDrone.Core/Jobs/TaskManager.cs b/src/NzbDrone.Core/Jobs/TaskManager.cs index 6e64bcee8..b5ae30389 100644 --- a/src/NzbDrone.Core/Jobs/TaskManager.cs +++ b/src/NzbDrone.Core/Jobs/TaskManager.cs @@ -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(), diff --git a/src/NzbDrone.Core/Parser/Model/ImportListItemInfo.cs b/src/NzbDrone.Core/Parser/Model/ImportListItemInfo.cs new file mode 100644 index 000000000..de929ae7d --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/ImportListItemInfo.cs @@ -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); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Languages/LanguageProfileService.cs b/src/NzbDrone.Core/Profiles/Languages/LanguageProfileService.cs index d40ec31ec..b55e3140f 100644 --- a/src/NzbDrone.Core/Profiles/Languages/LanguageProfileService.cs +++ b/src/NzbDrone.Core/Profiles/Languages/LanguageProfileService.cs @@ -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 { 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); } diff --git a/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs index 02cae433d..28698115f 100644 --- a/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs @@ -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 { 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); diff --git a/src/NzbDrone.Core/Tags/TagDetails.cs b/src/NzbDrone.Core/Tags/TagDetails.cs index c378fa48b..ddd5717b6 100644 --- a/src/NzbDrone.Core/Tags/TagDetails.cs +++ b/src/NzbDrone.Core/Tags/TagDetails.cs @@ -11,12 +11,13 @@ namespace NzbDrone.Core.Tags public List NotificationIds { get; set; } public List RestrictionIds { get; set; } public List DelayProfileIds { get; set; } + public List 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()); } } } diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs index ddd07f409..b804c7954 100644 --- a/src/NzbDrone.Core/Tags/TagService.cs +++ b/src/NzbDrone.Core/Tags/TagService.cs @@ -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() diff --git a/src/NzbDrone.Core/Tv/Events/SeriesDeletedEvent.cs b/src/NzbDrone.Core/Tv/Events/SeriesDeletedEvent.cs index e04d8f60e..02dbdbd25 100644 --- a/src/NzbDrone.Core/Tv/Events/SeriesDeletedEvent.cs +++ b/src/NzbDrone.Core/Tv/Events/SeriesDeletedEvent.cs @@ -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; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index 020f605a2..baa437780 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -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 GetAllSeries(); List 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 GetAllSeries() diff --git a/src/Sonarr.Api.V3/ImportLists/ImportListExclusionModule.cs b/src/Sonarr.Api.V3/ImportLists/ImportListExclusionModule.cs new file mode 100644 index 000000000..0e6b9d84b --- /dev/null +++ b/src/Sonarr.Api.V3/ImportLists/ImportListExclusionModule.cs @@ -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 + { + 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 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); + } + } +} diff --git a/src/Sonarr.Api.V3/ImportLists/ImportListExclusionResource.cs b/src/Sonarr.Api.V3/ImportLists/ImportListExclusionResource.cs new file mode 100644 index 000000000..42287d07e --- /dev/null +++ b/src/Sonarr.Api.V3/ImportLists/ImportListExclusionResource.cs @@ -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 ToResource(this IEnumerable filters) + { + return filters.Select(ToResource).ToList(); + } + } +} diff --git a/src/Sonarr.Api.V3/ImportLists/ImportListModule.cs b/src/Sonarr.Api.V3/ImportLists/ImportListModule.cs new file mode 100644 index 000000000..2b6146dca --- /dev/null +++ b/src/Sonarr.Api.V3/ImportLists/ImportListModule.cs @@ -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 + { + 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); + } + } +} diff --git a/src/Sonarr.Api.V3/ImportLists/ImportListResource.cs b/src/Sonarr.Api.V3/ImportLists/ImportListResource.cs new file mode 100644 index 000000000..17b2e1ada --- /dev/null +++ b/src/Sonarr.Api.V3/ImportLists/ImportListResource.cs @@ -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 + { + 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; + } + } +} diff --git a/src/Sonarr.Api.V3/Tags/TagDetailsResource.cs b/src/Sonarr.Api.V3/Tags/TagDetailsResource.cs index d2d39dc32..7a55aa410 100644 --- a/src/Sonarr.Api.V3/Tags/TagDetailsResource.cs +++ b/src/Sonarr.Api.V3/Tags/TagDetailsResource.cs @@ -9,6 +9,7 @@ namespace Sonarr.Api.V3.Tags { public string Label { get; set; } public List DelayProfileIds { get; set; } + public List ImportListIds { get; set; } public List NotificationIds { get; set; } public List RestrictionIds { get; set; } public List 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