Add support for Applications

pull/3/head
nitsua 4 years ago committed by Qstick
parent 191d06deca
commit 47fbab02c5

@ -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 (
<Switch>
{/*
Movies
Indexers
*/}
<Route
@ -87,6 +88,11 @@ function AppRoutes(props) {
component={Settings}
/>
<Route
path="/settings/applications"
component={ApplicationSettings}
/>
<Route
path="/settings/indexers"
component={IndexerSettingsConnector}

@ -0,0 +1,22 @@
import React from 'react';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import ApplicationsConnector from './Applications/ApplicationsConnector';
function ApplicationSettings() {
return (
<PageContent title={translate('Applications')}>
<SettingsToolbarConnector
showSave={false}
/>
<PageContentBody>
<ApplicationsConnector />
</PageContentBody>
</PageContent>
);
}
export default ApplicationSettings;

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

@ -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 (
<div
className={styles.application}
>
<Link
className={styles.underlay}
onPress={this.onApplicationSelect}
/>
<div className={styles.overlay}>
<div className={styles.name}>
{implementationName}
</div>
<div className={styles.actions}>
{
hasPresets &&
<span>
<Button
size={sizes.SMALL}
onPress={this.onApplicationSelect}
>
Custom
</Button>
<Menu className={styles.presetsMenu}>
<Button
className={styles.presetsMenuButton}
size={sizes.SMALL}
>
Presets
</Button>
<MenuContent>
{
presets.map((preset) => {
return (
<AddApplicationPresetMenuItem
key={preset.name}
name={preset.name}
implementation={implementation}
onPress={onApplicationSelect}
/>
);
})
}
</MenuContent>
</Menu>
</span>
}
<Button
to={infoLink}
size={sizes.SMALL}
>
{translate('MoreInfo')}
</Button>
</div>
</div>
</div>
);
}
}
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;

@ -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 (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddApplicationModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddApplicationModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddApplicationModal;

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

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Add Application
</ModalHeader>
<ModalBody>
{
isSchemaFetching &&
<LoadingIndicator />
}
{
!isSchemaFetching && !!schemaError &&
<div>
{translate('UnableToAddANewApplicationPleaseTryAgain')}
</div>
}
{
isSchemaPopulated && !schemaError &&
<div>
<div className={styles.applications}>
{
schema.map((application) => {
return (
<AddApplicationItem
key={application.implementation}
implementation={application.implementation}
{...application}
onApplicationSelect={onApplicationSelect}
/>
);
})
}
</div>
</div>
}
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
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;

@ -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 (
<AddApplicationModalContent
{...this.props}
onApplicationSelect={this.onApplicationSelect}
/>
);
}
}
AddApplicationModalContentConnector.propTypes = {
fetchApplicationSchema: PropTypes.func.isRequired,
selectApplicationSchema: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddApplicationModalContentConnector);

@ -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 (
<MenuItem
{...otherProps}
onPress={this.onPress}
>
{name}
</MenuItem>
);
}
}
AddApplicationPresetMenuItem.propTypes = {
name: PropTypes.string.isRequired,
implementation: PropTypes.string.isRequired,
onPress: PropTypes.func.isRequired
};
export default AddApplicationPresetMenuItem;

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

@ -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 (
<Card
className={styles.application}
overlayContent={true}
onPress={this.onEditApplicationPress}
>
<div className={styles.name}>
{name}
</div>
{
syncLevel === 'addOnly' &&
<Label kind={kinds.WARNING}>
Add Only
</Label>
}
{
syncLevel === 'fullSync' &&
<Label kind={kinds.SUCCESS}>
Full Sync
</Label>
}
{
syncLevel === 'disabled' &&
<Label
kind={kinds.DISABLED}
outline={true}
>
Disabled
</Label>
}
<EditApplicationModalConnector
id={id}
isOpen={this.state.isEditApplicationModalOpen}
onModalClose={this.onEditApplicationModalClose}
onDeleteApplicationPress={this.onDeleteApplicationPress}
/>
<ConfirmModal
isOpen={this.state.isDeleteApplicationModalOpen}
kind={kinds.DANGER}
title={translate('DeleteApplication')}
message={translate('DeleteApplicationMessageText', [name])}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteApplication}
onCancel={this.onDeleteApplicationModalClose}
/>
</Card>
);
}
}
Application.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
syncLevel: PropTypes.string.isRequired,
onConfirmDeleteApplication: PropTypes.func
};
export default Application;

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

@ -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 (
<FieldSet legend={translate('Applications')}>
<PageSectionContent
errorMessage="Unable to load application list"
{...otherProps}
>
<div className={styles.applications}>
{
items.map((item) => {
return (
<Application
key={item.id}
{...item}
onConfirmDeleteApplication={onConfirmDeleteApplication}
/>
);
})
}
<Card
className={styles.addApplication}
onPress={this.onAddApplicationPress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
</div>
</Card>
</div>
<AddApplicationModal
isOpen={isAddApplicationModalOpen}
onModalClose={this.onAddApplicationModalClose}
/>
<EditApplicationModalConnector
isOpen={isEditApplicationModalOpen}
onModalClose={this.onEditApplicationModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
}
Applications.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteApplication: PropTypes.func.isRequired
};
export default Applications;

@ -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 (
<Applications
{...this.props}
onConfirmDeleteApplication={this.onConfirmDeleteApplication}
/>
);
}
}
ApplicationsConnector.propTypes = {
fetchApplications: PropTypes.func.isRequired,
deleteApplication: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ApplicationsConnector);

@ -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 (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditApplicationModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditApplicationModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditApplicationModal;

@ -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 (
<EditApplicationModal
{...otherProps}
onModalClose={this.onModalClose}
/>
);
}
}
EditApplicationModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
dispatchClearPendingChanges: PropTypes.func.isRequired,
dispatchCancelTestApplication: PropTypes.func.isRequired,
dispatchCancelSaveApplication: PropTypes.func.isRequired
};
export default connect(null, createMapDispatchToProps)(EditApplicationModalConnector);

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

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{`${id ? 'Edit' : 'Add'} Application`}
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>
{translate('UnableToAddANewApplicationPleaseTryAgain')}
</div>
}
{
!isFetching && !error &&
<Form {...otherProps}>
{
!!message &&
<Alert
className={styles.message}
kind={message.value.type}
>
{message.value.message}
</Alert>
}
<FormGroup>
<FormLabel>{translate('Name')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{'Sync Level'}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
values={syncLevelOptions}
name="syncLevel"
helpText={'Sync Level'}
{...syncLevel}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('TagsHelpText')}
{...tags}
onChange={onInputChange}
/>
</FormGroup>
{
fields.map((field) => {
return (
<ProviderFieldFormGroup
key={field.name}
advancedSettings={advancedSettings}
provider="application"
providerData={item}
section="settings.applications"
{...field}
onChange={onFieldChange}
/>
);
})
}
</Form>
}
</ModalBody>
<ModalFooter>
{
id &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteApplicationPress}
>
{translate('Delete')}
</Button>
}
<SpinnerErrorButton
isSpinning={isTesting}
error={saveError}
onPress={onTestPress}
>
{translate('Test')}
</SpinnerErrorButton>
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
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;

@ -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 (
<EditApplicationModalContent
{...this.props}
onSavePress={this.onSavePress}
onTestPress={this.onTestPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
);
}
}
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);

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

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

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

@ -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<TSettings> : 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<ProviderDefinition> 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<string, string> query)
{
return null;
}
}
}

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

@ -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<IApplications, ApplicationDefinition>
{
}
public class ApplicationFactory : ProviderFactory<IApplications, ApplicationDefinition>, IApplicationsFactory
{
public ApplicationFactory(IApplicationsRepository providerRepository, IEnumerable<IApplications> providers, IContainer container, IEventAggregator eventAggregator, Logger logger)
: base(providerRepository, providers, container, eventAggregator, logger)
{
}
}
}

@ -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<ApplicationDefinition>
{
void UpdateSettings(ApplicationDefinition model);
}
public class ApplicationRepository : ProviderRepository<ApplicationDefinition>, IApplicationsRepository
{
public ApplicationRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
public void UpdateSettings(ApplicationDefinition model)
{
SetFields(model, m => m.Settings);
}
}
}

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

@ -0,0 +1,9 @@
namespace NzbDrone.Core.Applications
{
public enum ApplicationSyncLevel
{
Disabled,
AddOnly,
FullSync
}
}

@ -0,0 +1,8 @@
using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.Applications
{
public interface IApplications : IProvider
{
}
}

@ -0,0 +1,27 @@
using System.Collections.Generic;
using FluentValidation.Results;
using NzbDrone.Common.Extensions;
namespace NzbDrone.Core.Applications.Radarr
{
public class Radarr : ApplicationBase<RadarrSettings>
{
public override string Name => "Radarr";
private readonly IRadarrV3Proxy _radarrV3Proxy;
public Radarr(IRadarrV3Proxy radarrV3Proxy)
{
_radarrV3Proxy = radarrV3Proxy;
}
public override ValidationResult Test()
{
var failures = new List<ValidationFailure>();
failures.AddIfNotNull(_radarrV3Proxy.Test(Settings));
return new ValidationResult(failures);
}
}
}

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

@ -0,0 +1,7 @@
namespace NzbDrone.Core.Applications.Radarr
{
public class RadarrStatus
{
public string Version { get; set; }
}
}

@ -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<RadarrStatus>("/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<TResource>(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<TResource>(response.Content);
return results;
}
}
}

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

@ -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<ApplicationDefinition>("Applications").RegisterModel()
.Ignore(x => x.ImplementationName);
Mapper.Entity<History.History>("History").RegisterModel();
Mapper.Entity<Log>("Logs").RegisterModel();

@ -0,0 +1,14 @@
using NzbDrone.Core.Applications;
namespace Prowlarr.Api.V1.Application
{
public class ApplicationModule : ProviderModuleBase<ApplicationResource, IApplications, ApplicationDefinition>
{
public static readonly ApplicationResourceMapper ResourceMapper = new ApplicationResourceMapper();
public ApplicationModule(ApplicationFactory applicationsFactory)
: base(applicationsFactory, "applications", ResourceMapper)
{
}
}
}

@ -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<ApplicationResource, ApplicationDefinition>
{
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;
}
}
}
Loading…
Cancel
Save