diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index 128b77105..66d925210 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -6,6 +6,7 @@ import Switch from 'Components/Router/Switch'; import HistoryConnector from 'History/HistoryConnector'; import IndexerIndexConnector from 'Indexer/Index/IndexerIndexConnector'; import SearchIndexConnector from 'Search/SearchIndexConnector'; +import ApplicationSettings from 'Settings/Applications/ApplicationSettings'; import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector'; import NotificationSettings from 'Settings/Notifications/NotificationSettings'; @@ -28,7 +29,7 @@ function AppRoutes(props) { return ( {/* - Movies + Indexers */} + + + + + + + + + ); +} + +export default ApplicationSettings; diff --git a/frontend/src/Settings/Applications/Applications/AddApplicationItem.css b/frontend/src/Settings/Applications/Applications/AddApplicationItem.css new file mode 100644 index 000000000..49fe3f997 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/AddApplicationItem.css @@ -0,0 +1,44 @@ +.application { + 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/Applications/Applications/AddApplicationItem.js b/frontend/src/Settings/Applications/Applications/AddApplicationItem.js new file mode 100644 index 000000000..ec4512f51 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/AddApplicationItem.js @@ -0,0 +1,111 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +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 { sizes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import AddApplicationPresetMenuItem from './AddApplicationPresetMenuItem'; +import styles from './AddApplicationItem.css'; + +class AddApplicationItem extends Component { + + // + // Listeners + + onApplicationSelect = () => { + const { + implementation + } = this.props; + + this.props.onApplicationSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onApplicationSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets && + + + + + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + +
+
+
+ ); + } +} + +AddApplicationItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string.isRequired, + presets: PropTypes.arrayOf(PropTypes.object), + onApplicationSelect: PropTypes.func.isRequired +}; + +export default AddApplicationItem; diff --git a/frontend/src/Settings/Applications/Applications/AddApplicationModal.js b/frontend/src/Settings/Applications/Applications/AddApplicationModal.js new file mode 100644 index 000000000..442df320d --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/AddApplicationModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddApplicationModalContentConnector from './AddApplicationModalContentConnector'; + +function AddApplicationModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddApplicationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddApplicationModal; diff --git a/frontend/src/Settings/Applications/Applications/AddApplicationModalContent.css b/frontend/src/Settings/Applications/Applications/AddApplicationModalContent.css new file mode 100644 index 000000000..c32cfeddf --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/AddApplicationModalContent.css @@ -0,0 +1,5 @@ +.applications { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Applications/Applications/AddApplicationModalContent.js b/frontend/src/Settings/Applications/Applications/AddApplicationModalContent.js new file mode 100644 index 000000000..6c88a991f --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/AddApplicationModalContent.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import translate from 'Utilities/String/translate'; +import AddApplicationItem from './AddApplicationItem'; +import styles from './AddApplicationModalContent.css'; + +class AddApplicationModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema, + onApplicationSelect, + onModalClose + } = this.props; + + return ( + + + Add Application + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
+ {translate('UnableToAddANewApplicationPleaseTryAgain')} +
+ } + + { + isSchemaPopulated && !schemaError && +
+
+ { + schema.map((application) => { + return ( + + ); + }) + } +
+
+ } +
+ + + +
+ ); + } +} + +AddApplicationModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + schema: PropTypes.arrayOf(PropTypes.object).isRequired, + onApplicationSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddApplicationModalContent; diff --git a/frontend/src/Settings/Applications/Applications/AddApplicationModalContentConnector.js b/frontend/src/Settings/Applications/Applications/AddApplicationModalContentConnector.js new file mode 100644 index 000000000..a48ba8ac4 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/AddApplicationModalContentConnector.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchApplicationSchema, selectApplicationSchema } from 'Store/Actions/settingsActions'; +import AddApplicationModalContent from './AddApplicationModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.applications, + (applications) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = applications; + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + }; + } + ); +} + +const mapDispatchToProps = { + fetchApplicationSchema, + selectApplicationSchema +}; + +class AddApplicationModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchApplicationSchema(); + } + + // + // Listeners + + onApplicationSelect = ({ implementation, name }) => { + this.props.selectApplicationSchema({ implementation, presetName: name }); + this.props.onModalClose({ applicationSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddApplicationModalContentConnector.propTypes = { + fetchApplicationSchema: PropTypes.func.isRequired, + selectApplicationSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddApplicationModalContentConnector); diff --git a/frontend/src/Settings/Applications/Applications/AddApplicationPresetMenuItem.js b/frontend/src/Settings/Applications/Applications/AddApplicationPresetMenuItem.js new file mode 100644 index 000000000..b4bad3014 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/AddApplicationPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddApplicationPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddApplicationPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddApplicationPresetMenuItem; diff --git a/frontend/src/Settings/Applications/Applications/Application.css b/frontend/src/Settings/Applications/Applications/Application.css new file mode 100644 index 000000000..93912850e --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Application.css @@ -0,0 +1,19 @@ +.application { + 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/Applications/Applications/Application.js b/frontend/src/Settings/Applications/Applications/Application.js new file mode 100644 index 000000000..4573d8357 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Application.js @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import { kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import EditApplicationModalConnector from './EditApplicationModalConnector'; +import styles from './Application.css'; + +class Application extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditApplicationModalOpen: false, + isDeleteApplicationModalOpen: false + }; + } + + // + // Listeners + + onEditApplicationPress = () => { + this.setState({ isEditApplicationModalOpen: true }); + } + + onEditApplicationModalClose = () => { + this.setState({ isEditApplicationModalOpen: false }); + } + + onDeleteApplicationPress = () => { + this.setState({ + isEditApplicationModalOpen: false, + isDeleteApplicationModalOpen: true + }); + } + + onDeleteApplicationModalClose= () => { + this.setState({ isDeleteApplicationModalOpen: false }); + } + + onConfirmDeleteApplication = () => { + this.props.onConfirmDeleteApplication(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + syncLevel + } = this.props; + + return ( + +
+ {name} +
+ + { + syncLevel === 'addOnly' && + + } + + { + syncLevel === 'fullSync' && + + } + + { + syncLevel === 'disabled' && + + } + + + + +
+ ); + } +} + +Application.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + syncLevel: PropTypes.string.isRequired, + onConfirmDeleteApplication: PropTypes.func +}; + +export default Application; diff --git a/frontend/src/Settings/Applications/Applications/Applications.css b/frontend/src/Settings/Applications/Applications/Applications.css new file mode 100644 index 000000000..bd7348438 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Applications.css @@ -0,0 +1,20 @@ +.applications { + display: flex; + flex-wrap: wrap; +} + +.addApplication { + composes: application from '~./Application.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/Applications/Applications/Applications.js b/frontend/src/Settings/Applications/Applications/Applications.js new file mode 100644 index 000000000..a4fc7d68e --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Applications.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Card from 'Components/Card'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import { icons } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import AddApplicationModal from './AddApplicationModal'; +import Application from './Application'; +import EditApplicationModalConnector from './EditApplicationModalConnector'; +import styles from './Applications.css'; + +class Applications extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddApplicationModalOpen: false, + isEditApplicationModalOpen: false + }; + } + + // + // Listeners + + onAddApplicationPress = () => { + this.setState({ isAddApplicationModalOpen: true }); + } + + onAddApplicationModalClose = ({ applicationSelected = false } = {}) => { + this.setState({ + isAddApplicationModalOpen: false, + isEditApplicationModalOpen: applicationSelected + }); + } + + onEditApplicationModalClose = () => { + this.setState({ isEditApplicationModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteApplication, + ...otherProps + } = this.props; + + const { + isAddApplicationModalOpen, + isEditApplicationModalOpen + } = this.state; + + return ( +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + + +
+
+ ); + } +} + +Applications.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteApplication: PropTypes.func.isRequired +}; + +export default Applications; diff --git a/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js b/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js new file mode 100644 index 000000000..062a7f8e8 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { deleteApplication, fetchApplications } from 'Store/Actions/settingsActions'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import sortByName from 'Utilities/Array/sortByName'; +import Applications from './Applications'; + +function createMapStateToProps() { + return createSelector( + createSortedSectionSelector('settings.applications', sortByName), + (applications) => applications + ); +} + +const mapDispatchToProps = { + fetchApplications, + deleteApplication +}; + +class ApplicationsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchApplications(); + } + + // + // Listeners + + onConfirmDeleteApplication = (id) => { + this.props.deleteApplication({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ApplicationsConnector.propTypes = { + fetchApplications: PropTypes.func.isRequired, + deleteApplication: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ApplicationsConnector); diff --git a/frontend/src/Settings/Applications/Applications/EditApplicationModal.js b/frontend/src/Settings/Applications/Applications/EditApplicationModal.js new file mode 100644 index 000000000..0e84c41e2 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/EditApplicationModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import EditApplicationModalContentConnector from './EditApplicationModalContentConnector'; + +function EditApplicationModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditApplicationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditApplicationModal; diff --git a/frontend/src/Settings/Applications/Applications/EditApplicationModalConnector.js b/frontend/src/Settings/Applications/Applications/EditApplicationModalConnector.js new file mode 100644 index 000000000..2a217398f --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/EditApplicationModalConnector.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 { cancelSaveApplication, cancelTestApplication } from 'Store/Actions/settingsActions'; +import EditApplicationModal from './EditApplicationModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.applications'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelTestApplication() { + dispatch(cancelTestApplication({ section })); + }, + + dispatchCancelSaveApplication() { + dispatch(cancelSaveApplication({ section })); + } + }; +} + +class EditApplicationModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelTestApplication(); + this.props.dispatchCancelSaveApplication(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelTestApplication, + dispatchCancelSaveApplication, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditApplicationModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestApplication: PropTypes.func.isRequired, + dispatchCancelSaveApplication: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditApplicationModalConnector); diff --git a/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.css b/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.css new file mode 100644 index 000000000..8e1c16507 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.css @@ -0,0 +1,11 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} + +.message { + composes: alert from '~Components/Alert.css'; + + margin-bottom: 30px; +} diff --git a/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js b/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js new file mode 100644 index 000000000..4893b5af1 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js @@ -0,0 +1,194 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Alert from 'Components/Alert'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './EditApplicationModalContent.css'; + +const syncLevelOptions = [ + { key: 'disabled', value: 'Disabled' }, + { key: 'addOnly', value: 'Add Only' }, + { key: 'fullSync', value: 'Full Sync' } +]; + +function EditApplicationModalContent(props) { + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteApplicationPress, + ...otherProps + } = props; + + const { + id, + name, + syncLevel, + tags, + fields, + message + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Application`} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
+ {translate('UnableToAddANewApplicationPleaseTryAgain')} +
+ } + + { + !isFetching && !error && +
+ { + !!message && + + {message.value.message} + + } + + + {translate('Name')} + + + + + + {'Sync Level'} + + + + + + {translate('Tags')} + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + + } +
+ + { + id && + + } + + + {translate('Test')} + + + + + + {translate('Save')} + + +
+ ); +} + +EditApplicationModalContent.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, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onTestPress: PropTypes.func.isRequired, + onDeleteApplicationPress: PropTypes.func +}; + +export default EditApplicationModalContent; diff --git a/frontend/src/Settings/Applications/Applications/EditApplicationModalContentConnector.js b/frontend/src/Settings/Applications/Applications/EditApplicationModalContentConnector.js new file mode 100644 index 000000000..a3b3e6560 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/EditApplicationModalContentConnector.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { saveApplication, setApplicationFieldValue, setApplicationValue, testApplication } from 'Store/Actions/settingsActions'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import EditApplicationModalContent from './EditApplicationModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('applications'), + (advancedSettings, application) => { + return { + advancedSettings, + ...application + }; + } + ); +} + +const mapDispatchToProps = { + setApplicationValue, + setApplicationFieldValue, + saveApplication, + testApplication +}; + +class EditApplicationModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setApplicationValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setApplicationFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveApplication({ id: this.props.id }); + } + + onTestPress = () => { + this.props.testApplication({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditApplicationModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setApplicationValue: PropTypes.func, + setApplicationFieldValue: PropTypes.func, + saveApplication: PropTypes.func, + testApplication: PropTypes.func, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditApplicationModalContentConnector); diff --git a/frontend/src/Store/Actions/Settings/applications.js b/frontend/src/Store/Actions/Settings/applications.js new file mode 100644 index 000000000..6e7c13b94 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/applications.js @@ -0,0 +1,115 @@ +import { createAction } from 'redux-actions'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; + +// +// Variables + +const section = 'settings.applications'; + +// +// Actions Types + +export const FETCH_APPLICATIONS = 'settings/applications/fetchApplications'; +export const FETCH_APPLICATION_SCHEMA = 'settings/applications/fetchApplicationSchema'; +export const SELECT_APPLICATION_SCHEMA = 'settings/applications/selectApplicationSchema'; +export const SET_APPLICATION_VALUE = 'settings/applications/setApplicationValue'; +export const SET_APPLICATION_FIELD_VALUE = 'settings/applications/setApplicationFieldValue'; +export const SAVE_APPLICATION = 'settings/applications/saveApplication'; +export const CANCEL_SAVE_APPLICATION = 'settings/applications/cancelSaveApplication'; +export const DELETE_APPLICATION = 'settings/applications/deleteApplication'; +export const TEST_APPLICATION = 'settings/applications/testApplication'; +export const CANCEL_TEST_APPLICATION = 'settings/applications/cancelTestApplication'; + +// +// Action Creators + +export const fetchApplications = createThunk(FETCH_APPLICATIONS); +export const fetchApplicationSchema = createThunk(FETCH_APPLICATION_SCHEMA); +export const selectApplicationSchema = createAction(SELECT_APPLICATION_SCHEMA); + +export const saveApplication = createThunk(SAVE_APPLICATION); +export const cancelSaveApplication = createThunk(CANCEL_SAVE_APPLICATION); +export const deleteApplication = createThunk(DELETE_APPLICATION); +export const testApplication = createThunk(TEST_APPLICATION); +export const cancelTestApplication = createThunk(CANCEL_TEST_APPLICATION); + +export const setApplicationValue = createAction(SET_APPLICATION_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setApplicationFieldValue = createAction(SET_APPLICATION_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, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_APPLICATIONS]: createFetchHandler(section, '/applications'), + [FETCH_APPLICATION_SCHEMA]: createFetchSchemaHandler(section, '/applications/schema'), + + [SAVE_APPLICATION]: createSaveProviderHandler(section, '/applications'), + [CANCEL_SAVE_APPLICATION]: createCancelSaveProviderHandler(section), + [DELETE_APPLICATION]: createRemoveItemHandler(section, '/applications'), + [TEST_APPLICATION]: createTestProviderHandler(section, '/applications'), + [CANCEL_TEST_APPLICATION]: createCancelTestProviderHandler(section) + }, + + // + // Reducers + + reducers: { + [SET_APPLICATION_VALUE]: createSetSettingValueReducer(section), + [SET_APPLICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_APPLICATION_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.onGrab = selectedSchema.supportsOnGrab; + selectedSchema.onDownload = selectedSchema.supportsOnDownload; + selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade; + selectedSchema.onRename = selectedSchema.supportsOnRename; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index f4528cfff..4a35d5d48 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -1,6 +1,7 @@ import { createAction } from 'redux-actions'; import { handleThunks } from 'Store/thunks'; import createHandleActions from './Creators/createHandleActions'; +import applications from './Settings/applications'; import general from './Settings/general'; import indexerFlags from './Settings/indexerFlags'; import indexerOptions from './Settings/indexerOptions'; @@ -13,6 +14,7 @@ export * from './Settings/indexerFlags'; export * from './Settings/indexerOptions'; export * from './Settings/languages'; export * from './Settings/notifications'; +export * from './Settings/applications'; export * from './Settings/ui'; // @@ -31,6 +33,7 @@ export const defaultState = { indexerOptions: indexerOptions.defaultState, languages: languages.defaultState, notifications: notifications.defaultState, + applications: applications.defaultState, ui: ui.defaultState }; @@ -57,6 +60,7 @@ export const actionHandlers = handleThunks({ ...indexerOptions.actionHandlers, ...languages.actionHandlers, ...notifications.actionHandlers, + ...applications.actionHandlers, ...ui.actionHandlers }); @@ -74,6 +78,7 @@ export const reducers = createHandleActions({ ...indexerOptions.reducers, ...languages.reducers, ...notifications.reducers, + ...applications.reducers, ...ui.reducers }, defaultState, section); diff --git a/package.json b/package.json index 163ad7363..cbe856643 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prowlarr", "version": "0.1.0", - "description": "Prowlarr is a Indexer/Tracker manager for with seamless integration for your favorite PVR apps", + "description": "Prowlarr is an Indexer/Tracker manager with seamless integration for your favorite PVR apps", "scripts": { "build": "gulp build", "start": "gulp watch", diff --git a/src/NzbDrone.Core/Applications/ApplicationBase.cs b/src/NzbDrone.Core/Applications/ApplicationBase.cs new file mode 100644 index 000000000..88539c3e8 --- /dev/null +++ b/src/NzbDrone.Core/Applications/ApplicationBase.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Applications +{ + public abstract class ApplicationBase : IApplications + where TSettings : IProviderConfig, new() + { + public abstract string Name { get; } + + public Type ConfigContract => typeof(TSettings); + + public virtual ProviderMessage Message => null; + + public ProviderDefinition Definition { get; set; } + public abstract ValidationResult Test(); + + protected TSettings Settings => (TSettings)Definition.Settings; + + public override string ToString() + { + return GetType().Name; + } + + public virtual IEnumerable DefaultDefinitions + { + get + { + var config = (IProviderConfig)new TSettings(); + + yield return new ApplicationDefinition + { + Name = GetType().Name, + SyncLevel = ApplicationSyncLevel.AddOnly, + Implementation = GetType().Name, + Settings = config + }; + } + } + + public virtual object RequestAction(string action, IDictionary query) + { + return null; + } + } +} diff --git a/src/NzbDrone.Core/Applications/ApplicationDefinition.cs b/src/NzbDrone.Core/Applications/ApplicationDefinition.cs new file mode 100644 index 000000000..3160898f1 --- /dev/null +++ b/src/NzbDrone.Core/Applications/ApplicationDefinition.cs @@ -0,0 +1,11 @@ +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Applications +{ + public class ApplicationDefinition : ProviderDefinition + { + public ApplicationSyncLevel SyncLevel { get; set; } + + public override bool Enable => SyncLevel == ApplicationSyncLevel.AddOnly || SyncLevel == ApplicationSyncLevel.FullSync; + } +} diff --git a/src/NzbDrone.Core/Applications/ApplicationFactory.cs b/src/NzbDrone.Core/Applications/ApplicationFactory.cs new file mode 100644 index 000000000..1de04a08c --- /dev/null +++ b/src/NzbDrone.Core/Applications/ApplicationFactory.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Composition; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Applications +{ + public interface IApplicationsFactory : IProviderFactory + { + } + + public class ApplicationFactory : ProviderFactory, IApplicationsFactory + { + public ApplicationFactory(IApplicationsRepository providerRepository, IEnumerable providers, IContainer container, IEventAggregator eventAggregator, Logger logger) + : base(providerRepository, providers, container, eventAggregator, logger) + { + } + } +} diff --git a/src/NzbDrone.Core/Applications/ApplicationRepository.cs b/src/NzbDrone.Core/Applications/ApplicationRepository.cs new file mode 100644 index 000000000..0cc11df1c --- /dev/null +++ b/src/NzbDrone.Core/Applications/ApplicationRepository.cs @@ -0,0 +1,24 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Applications +{ + public interface IApplicationsRepository : IProviderRepository + { + void UpdateSettings(ApplicationDefinition model); + } + + public class ApplicationRepository : ProviderRepository, IApplicationsRepository + { + public ApplicationRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public void UpdateSettings(ApplicationDefinition model) + { + SetFields(model, m => m.Settings); + } + } +} diff --git a/src/NzbDrone.Core/Applications/ApplicationService.cs b/src/NzbDrone.Core/Applications/ApplicationService.cs new file mode 100644 index 000000000..fa07ddfa1 --- /dev/null +++ b/src/NzbDrone.Core/Applications/ApplicationService.cs @@ -0,0 +1,16 @@ +using NLog; + +namespace NzbDrone.Core.Applications +{ + public class ApplicationService + { + private readonly IApplicationsFactory _applicationsFactory; + private readonly Logger _logger; + + public ApplicationService(IApplicationsFactory applicationsFactory, Logger logger) + { + _applicationsFactory = applicationsFactory; + _logger = logger; + } + } +} diff --git a/src/NzbDrone.Core/Applications/ApplicationSyncLevel.cs b/src/NzbDrone.Core/Applications/ApplicationSyncLevel.cs new file mode 100644 index 000000000..147375d64 --- /dev/null +++ b/src/NzbDrone.Core/Applications/ApplicationSyncLevel.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Applications +{ + public enum ApplicationSyncLevel + { + Disabled, + AddOnly, + FullSync + } +} diff --git a/src/NzbDrone.Core/Applications/IApplications.cs b/src/NzbDrone.Core/Applications/IApplications.cs new file mode 100644 index 000000000..4bb52a9e3 --- /dev/null +++ b/src/NzbDrone.Core/Applications/IApplications.cs @@ -0,0 +1,8 @@ +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Applications +{ + public interface IApplications : IProvider + { + } +} diff --git a/src/NzbDrone.Core/Applications/Radarr/Radarr.cs b/src/NzbDrone.Core/Applications/Radarr/Radarr.cs new file mode 100644 index 000000000..158446c2f --- /dev/null +++ b/src/NzbDrone.Core/Applications/Radarr/Radarr.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Applications.Radarr +{ + public class Radarr : ApplicationBase + { + public override string Name => "Radarr"; + + private readonly IRadarrV3Proxy _radarrV3Proxy; + + public Radarr(IRadarrV3Proxy radarrV3Proxy) + { + _radarrV3Proxy = radarrV3Proxy; + } + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_radarrV3Proxy.Test(Settings)); + + return new ValidationResult(failures); + } + } +} diff --git a/src/NzbDrone.Core/Applications/Radarr/RadarrSettings.cs b/src/NzbDrone.Core/Applications/Radarr/RadarrSettings.cs new file mode 100644 index 000000000..13de96c35 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Radarr/RadarrSettings.cs @@ -0,0 +1,36 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Applications.Radarr +{ + public class RadarrSettingsValidator : AbstractValidator + { + public RadarrSettingsValidator() + { + RuleFor(c => c.BaseUrl).IsValidUrl(); + RuleFor(c => c.ApiKey).NotEmpty(); + } + } + + public class RadarrSettings : IProviderConfig + { + private static readonly RadarrSettingsValidator Validator = new RadarrSettingsValidator(); + + public RadarrSettings() + { + } + + [FieldDefinition(0, Label = "Radarr Server", HelpText = "Radarr server URL, including http(s):// and port if needed")] + public string BaseUrl { get; set; } + + [FieldDefinition(1, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Radarr in Settings/General")] + public string ApiKey { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Applications/Radarr/RadarrStatus.cs b/src/NzbDrone.Core/Applications/Radarr/RadarrStatus.cs new file mode 100644 index 000000000..38c1110c9 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Radarr/RadarrStatus.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Applications.Radarr +{ + public class RadarrStatus + { + public string Version { get; set; } + } +} diff --git a/src/NzbDrone.Core/Applications/Radarr/RadarrV3Proxy.cs b/src/NzbDrone.Core/Applications/Radarr/RadarrV3Proxy.cs new file mode 100644 index 000000000..0c6fedd01 --- /dev/null +++ b/src/NzbDrone.Core/Applications/Radarr/RadarrV3Proxy.cs @@ -0,0 +1,78 @@ +using System; +using System.Net; +using FluentValidation.Results; +using Newtonsoft.Json; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.Applications.Radarr +{ + public interface IRadarrV3Proxy + { + ValidationFailure Test(RadarrSettings settings); + } + + public class RadarrV3Proxy : IRadarrV3Proxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public RadarrV3Proxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public RadarrStatus GetStatus(RadarrSettings settings) + { + return Execute("/api/v3/system/status", settings); + } + + public ValidationFailure Test(RadarrSettings settings) + { + try + { + GetStatus(settings); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + _logger.Error(ex, "API Key is invalid"); + return new ValidationFailure("ApiKey", "API Key is invalid"); + } + + _logger.Error(ex, "Unable to send test message"); + return new ValidationFailure("ApiKey", "Unable to send test message"); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to send test message"); + return new ValidationFailure("", "Unable to send test message"); + } + + return null; + } + + private TResource Execute(string resource, RadarrSettings settings) + where TResource : new() + { + if (settings.BaseUrl.IsNullOrWhiteSpace() || settings.ApiKey.IsNullOrWhiteSpace()) + { + return new TResource(); + } + + var baseUrl = settings.BaseUrl.TrimEnd('/'); + + var request = new HttpRequestBuilder(baseUrl).Resource(resource).Accept(HttpAccept.Json) + .SetHeader("X-Api-Key", settings.ApiKey).Build(); + + var response = _httpClient.Get(request); + + var results = JsonConvert.DeserializeObject(response.Content); + + return results; + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs b/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs index 6f88050c0..ed5678ba4 100644 --- a/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs +++ b/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs @@ -52,7 +52,9 @@ namespace NzbDrone.Core.Datastore.Migration .WithColumn("Name").AsString().Unique() .WithColumn("Implementation").AsString() .WithColumn("Settings").AsString().Nullable() - .WithColumn("ConfigContract").AsString().Nullable(); + .WithColumn("ConfigContract").AsString().Nullable() + .WithColumn("SyncLevel").AsInt32() + .WithColumn("Tags").AsString().Nullable(); Create.TableForModel("Tags") .WithColumn("Label").AsString().Unique(); diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index ef766fbca..ab82c8560 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using Dapper; using NzbDrone.Common.Reflection; +using NzbDrone.Core.Applications; using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; using NzbDrone.Core.CustomFilters; @@ -51,6 +52,9 @@ namespace NzbDrone.Core.Datastore .Ignore(x => x.ImplementationName) .Ignore(i => i.SupportsOnHealthIssue); + Mapper.Entity("Applications").RegisterModel() + .Ignore(x => x.ImplementationName); + Mapper.Entity("History").RegisterModel(); Mapper.Entity("Logs").RegisterModel(); diff --git a/src/Prowlarr.Api.V1/Applications/ApplicationModule.cs b/src/Prowlarr.Api.V1/Applications/ApplicationModule.cs new file mode 100644 index 000000000..22924a209 --- /dev/null +++ b/src/Prowlarr.Api.V1/Applications/ApplicationModule.cs @@ -0,0 +1,14 @@ +using NzbDrone.Core.Applications; + +namespace Prowlarr.Api.V1.Application +{ + public class ApplicationModule : ProviderModuleBase + { + public static readonly ApplicationResourceMapper ResourceMapper = new ApplicationResourceMapper(); + + public ApplicationModule(ApplicationFactory applicationsFactory) + : base(applicationsFactory, "applications", ResourceMapper) + { + } + } +} diff --git a/src/Prowlarr.Api.V1/Applications/ApplicationResource.cs b/src/Prowlarr.Api.V1/Applications/ApplicationResource.cs new file mode 100644 index 000000000..73b546d1f --- /dev/null +++ b/src/Prowlarr.Api.V1/Applications/ApplicationResource.cs @@ -0,0 +1,41 @@ +using NzbDrone.Core.Applications; + +namespace Prowlarr.Api.V1.Application +{ + public class ApplicationResource : ProviderResource + { + public ApplicationSyncLevel SyncLevel { get; set; } + public string TestCommand { get; set; } + } + + public class ApplicationResourceMapper : ProviderResourceMapper + { + public override ApplicationResource ToResource(ApplicationDefinition definition) + { + if (definition == null) + { + return default; + } + + var resource = base.ToResource(definition); + + resource.SyncLevel = definition.SyncLevel; + + return resource; + } + + public override ApplicationDefinition ToModel(ApplicationResource resource) + { + if (resource == null) + { + return default; + } + + var definition = base.ToModel(resource); + + definition.SyncLevel = resource.SyncLevel; + + return definition; + } + } +}