mirror of
https://github.com/Sonarr/Sonarr
synced 2024-12-21 23:33:00 +00:00
parent
fb9a5efe05
commit
c62fc9d05b
30 changed files with 445 additions and 599 deletions
|
@ -63,6 +63,16 @@ export interface AppSectionItemState<T> {
|
|||
item: T;
|
||||
}
|
||||
|
||||
export interface AppSectionProviderState<T>
|
||||
extends AppSectionDeleteState,
|
||||
AppSectionSaveState {
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
error: Error;
|
||||
items: T[];
|
||||
pendingChanges: Partial<T>;
|
||||
}
|
||||
|
||||
interface AppSectionState<T> {
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
|
|
6
frontend/src/App/State/MetadataAppState.ts
Normal file
6
frontend/src/App/State/MetadataAppState.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { AppSectionProviderState } from 'App/State/AppSectionState';
|
||||
import Metadata from 'typings/Metadata';
|
||||
|
||||
interface MetadataAppState extends AppSectionProviderState<Metadata> {}
|
||||
|
||||
export default MetadataAppState;
|
|
@ -20,6 +20,7 @@ import NamingConfig from 'typings/Settings/NamingConfig';
|
|||
import NamingExample from 'typings/Settings/NamingExample';
|
||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||
import UiSettings from 'typings/Settings/UiSettings';
|
||||
import MetadataAppState from './MetadataAppState';
|
||||
|
||||
export interface DownloadClientAppState
|
||||
extends AppSectionState<DownloadClient>,
|
||||
|
@ -97,6 +98,7 @@ interface SettingsAppState {
|
|||
indexerFlags: IndexerFlagSettingsAppState;
|
||||
indexers: IndexerAppState;
|
||||
languages: LanguageSettingsAppState;
|
||||
metadata: MetadataAppState;
|
||||
naming: NamingAppState;
|
||||
namingExamples: NamingExamplesAppState;
|
||||
notifications: NotificationAppState;
|
||||
|
|
|
@ -138,6 +138,8 @@ ProviderFieldFormGroup.propTypes = {
|
|||
type: PropTypes.string.isRequired,
|
||||
advanced: PropTypes.bool.isRequired,
|
||||
hidden: PropTypes.string,
|
||||
isDisabled: PropTypes.bool,
|
||||
provider: PropTypes.string,
|
||||
pending: PropTypes.bool.isRequired,
|
||||
errors: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
warnings: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import EditMetadataModalContentConnector from './EditMetadataModalContentConnector';
|
||||
|
||||
function EditMetadataModal({ isOpen, onModalClose, ...otherProps }) {
|
||||
return (
|
||||
<Modal
|
||||
size={sizes.MEDIUM}
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<EditMetadataModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
EditMetadataModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EditMetadataModal;
|
|
@ -0,0 +1,36 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import EditMetadataModalContent, {
|
||||
EditMetadataModalContentProps,
|
||||
} from './EditMetadataModalContent';
|
||||
|
||||
interface EditMetadataModalProps extends EditMetadataModalContentProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
function EditMetadataModal({
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
}: EditMetadataModalProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
dispatch(clearPendingChanges({ section: 'metadata' }));
|
||||
onModalClose();
|
||||
}, [dispatch, onModalClose]);
|
||||
|
||||
return (
|
||||
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}>
|
||||
<EditMetadataModalContent
|
||||
{...otherProps}
|
||||
onModalClose={handleModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditMetadataModal;
|
|
@ -1,44 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import EditMetadataModal from './EditMetadataModal';
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
const section = 'settings.metadata';
|
||||
|
||||
return {
|
||||
dispatchClearPendingChanges() {
|
||||
dispatch(clearPendingChanges({ section }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class EditMetadataModalConnector extends Component {
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onModalClose = () => {
|
||||
this.props.dispatchClearPendingChanges({ section: 'metadata' });
|
||||
this.props.onModalClose();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EditMetadataModal
|
||||
{...this.props}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditMetadataModalConnector.propTypes = {
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
dispatchClearPendingChanges: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, createMapDispatchToProps)(EditMetadataModalConnector);
|
|
@ -0,0 +1,5 @@
|
|||
.message {
|
||||
composes: alert from '~Components/Alert.css';
|
||||
|
||||
margin-bottom: 30px;
|
||||
}
|
7
frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.css.d.ts
vendored
Normal file
7
frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.css.d.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'message': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
|
@ -1,105 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
function EditMetadataModalContent(props) {
|
||||
const {
|
||||
advancedSettings,
|
||||
isSaving,
|
||||
saveError,
|
||||
item,
|
||||
onInputChange,
|
||||
onFieldChange,
|
||||
onModalClose,
|
||||
onSavePress,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const {
|
||||
name,
|
||||
enable,
|
||||
fields
|
||||
} = item;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('EditMetadata', { metadataType: name.value })}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form {...otherProps}>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Enable')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="enable"
|
||||
helpText={translate('EnableMetadataHelpText')}
|
||||
{...enable}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
fields.map((field) => {
|
||||
return (
|
||||
<ProviderFieldFormGroup
|
||||
key={field.name}
|
||||
advancedSettings={advancedSettings}
|
||||
provider="metadata"
|
||||
{...field}
|
||||
isDisabled={!enable.value}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Cancel')}
|
||||
</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={onSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
EditMetadataModalContent.propTypes = {
|
||||
advancedSettings: PropTypes.bool.isRequired,
|
||||
isSaving: 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,
|
||||
onDeleteMetadataPress: PropTypes.func
|
||||
};
|
||||
|
||||
export default EditMetadataModalContent;
|
|
@ -0,0 +1,128 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Alert from 'Components/Alert';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import {
|
||||
saveMetadata,
|
||||
setMetadataFieldValue,
|
||||
setMetadataValue,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EditMetadataModalContent.css';
|
||||
|
||||
export interface EditMetadataModalContentProps {
|
||||
id: number;
|
||||
advancedSettings: boolean;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function EditMetadataModalContent({
|
||||
id,
|
||||
advancedSettings,
|
||||
onModalClose,
|
||||
}: EditMetadataModalContentProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { isSaving, saveError, pendingChanges, items } = useSelector(
|
||||
(state: AppState) => state.settings.metadata
|
||||
);
|
||||
|
||||
const { settings, ...otherSettings } = useMemo(() => {
|
||||
const item = items.find((item) => item.id === id)!;
|
||||
|
||||
return selectSettings(item, pendingChanges, saveError);
|
||||
}, [id, items, pendingChanges, saveError]);
|
||||
|
||||
const { name, enable, fields, message } = settings;
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
// @ts-expect-error not typed
|
||||
dispatch(setMetadataValue({ name, value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
// @ts-expect-error not typed
|
||||
dispatch(setMetadataFieldValue({ name, value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
dispatch(saveMetadata({ id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('EditMetadata', { metadataType: name.value })}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form {...otherSettings}>
|
||||
{message ? (
|
||||
<Alert className={styles.message} kind={message.value.type}>
|
||||
{message.value.message}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Enable')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="enable"
|
||||
helpText={translate('EnableMetadataHelpText')}
|
||||
{...enable}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{fields.map((field) => {
|
||||
return (
|
||||
<ProviderFieldFormGroup
|
||||
key={field.name}
|
||||
advancedSettings={advancedSettings}
|
||||
provider="metadata"
|
||||
{...field}
|
||||
isDisabled={!enable.value}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={handleSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditMetadataModalContent;
|
|
@ -1,95 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { saveMetadata, setMetadataFieldValue, setMetadataValue } from 'Store/Actions/settingsActions';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import EditMetadataModalContent from './EditMetadataModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.advancedSettings,
|
||||
(state, { id }) => id,
|
||||
(state) => state.settings.metadata,
|
||||
(advancedSettings, id, metadata) => {
|
||||
const {
|
||||
isSaving,
|
||||
saveError,
|
||||
pendingChanges,
|
||||
items
|
||||
} = metadata;
|
||||
|
||||
const settings = selectSettings(_.find(items, { id }), pendingChanges, saveError);
|
||||
|
||||
return {
|
||||
advancedSettings,
|
||||
id,
|
||||
isSaving,
|
||||
saveError,
|
||||
item: settings.settings,
|
||||
...settings
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setMetadataValue,
|
||||
setMetadataFieldValue,
|
||||
saveMetadata
|
||||
};
|
||||
|
||||
class EditMetadataModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
|
||||
this.props.onModalClose();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.setMetadataValue({ name, value });
|
||||
};
|
||||
|
||||
onFieldChange = ({ name, value }) => {
|
||||
this.props.setMetadataFieldValue({ name, value });
|
||||
};
|
||||
|
||||
onSavePress = () => {
|
||||
this.props.saveMetadata({ id: this.props.id });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EditMetadataModalContent
|
||||
{...this.props}
|
||||
onSavePress={this.onSavePress}
|
||||
onInputChange={this.onInputChange}
|
||||
onFieldChange={this.onFieldChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditMetadataModalContentConnector.propTypes = {
|
||||
id: PropTypes.number,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.object.isRequired,
|
||||
setMetadataValue: PropTypes.func.isRequired,
|
||||
setMetadataFieldValue: PropTypes.func.isRequired,
|
||||
saveMetadata: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EditMetadataModalContentConnector);
|
|
@ -1,150 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditMetadataModalConnector from './EditMetadataModalConnector';
|
||||
import styles from './Metadata.css';
|
||||
|
||||
class Metadata extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isEditMetadataModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onEditMetadataPress = () => {
|
||||
this.setState({ isEditMetadataModalOpen: true });
|
||||
};
|
||||
|
||||
onEditMetadataModalClose = () => {
|
||||
this.setState({ isEditMetadataModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
enable,
|
||||
fields
|
||||
} = this.props;
|
||||
|
||||
const metadataFields = [];
|
||||
const imageFields = [];
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (field.section === 'metadata') {
|
||||
metadataFields.push(field);
|
||||
} else {
|
||||
imageFields.push(field);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={styles.metadata}
|
||||
overlayContent={true}
|
||||
onPress={this.onEditMetadataPress}
|
||||
>
|
||||
<div className={styles.name}>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{
|
||||
enable ?
|
||||
<Label kind={kinds.SUCCESS}>
|
||||
{translate('Enabled')}
|
||||
</Label> :
|
||||
<Label
|
||||
kind={kinds.DISABLED}
|
||||
outline={true}
|
||||
>
|
||||
{translate('Disabled')}
|
||||
</Label>
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
enable && !!metadataFields.length &&
|
||||
<div>
|
||||
<div className={styles.section}>
|
||||
{translate('Metadata')}
|
||||
</div>
|
||||
|
||||
{
|
||||
metadataFields.map((field) => {
|
||||
if (!field.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={field.label}
|
||||
kind={kinds.SUCCESS}
|
||||
>
|
||||
{field.label}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
enable && !!imageFields.length &&
|
||||
<div>
|
||||
<div className={styles.section}>
|
||||
{translate('Images')}
|
||||
</div>
|
||||
|
||||
{
|
||||
imageFields.map((field) => {
|
||||
if (!field.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={field.label}
|
||||
kind={kinds.SUCCESS}
|
||||
>
|
||||
{field.label}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<EditMetadataModalConnector
|
||||
id={id}
|
||||
isOpen={this.state.isEditMetadataModalOpen}
|
||||
onModalClose={this.onEditMetadataModalClose}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Metadata.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
enable: PropTypes.bool.isRequired,
|
||||
fields: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||
};
|
||||
|
||||
export default Metadata;
|
107
frontend/src/Settings/Metadata/Metadata/Metadata.tsx
Normal file
107
frontend/src/Settings/Metadata/Metadata/Metadata.tsx
Normal file
|
@ -0,0 +1,107 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import Field from 'typings/Field';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditMetadataModal from './EditMetadataModal';
|
||||
import styles from './Metadata.css';
|
||||
|
||||
interface MetadataProps {
|
||||
id: number;
|
||||
name: string;
|
||||
enable: boolean;
|
||||
fields: Field[];
|
||||
}
|
||||
|
||||
function Metadata({ id, name, enable, fields }: MetadataProps) {
|
||||
const [isEditMetadataModalOpen, setIsEditMetadataModalOpen] = useState(false);
|
||||
|
||||
const { metadataFields, imageFields } = useMemo(() => {
|
||||
return fields.reduce<{ metadataFields: Field[]; imageFields: Field[] }>(
|
||||
(acc, field) => {
|
||||
if (field.section === 'metadata') {
|
||||
acc.metadataFields.push(field);
|
||||
} else {
|
||||
acc.imageFields.push(field);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ metadataFields: [], imageFields: [] }
|
||||
);
|
||||
}, [fields]);
|
||||
|
||||
const handleOpenPress = useCallback(() => {
|
||||
setIsEditMetadataModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
setIsEditMetadataModalOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={styles.metadata}
|
||||
overlayContent={true}
|
||||
onPress={handleOpenPress}
|
||||
>
|
||||
<div className={styles.name}>{name}</div>
|
||||
|
||||
<div>
|
||||
{enable ? (
|
||||
<Label kind={kinds.SUCCESS}>{translate('Enabled')}</Label>
|
||||
) : (
|
||||
<Label kind={kinds.DISABLED} outline={true}>
|
||||
{translate('Disabled')}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enable && metadataFields.length ? (
|
||||
<div>
|
||||
<div className={styles.section}>{translate('Metadata')}</div>
|
||||
|
||||
{metadataFields.map((field) => {
|
||||
if (!field.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label key={field.label} kind={kinds.SUCCESS}>
|
||||
{field.label}
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{enable && imageFields.length ? (
|
||||
<div>
|
||||
<div className={styles.section}>{translate('Images')}</div>
|
||||
|
||||
{imageFields.map((field) => {
|
||||
if (!field.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label key={field.label} kind={kinds.SUCCESS}>
|
||||
{field.label}
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<EditMetadataModal
|
||||
advancedSettings={false}
|
||||
id={id}
|
||||
isOpen={isEditMetadataModalOpen}
|
||||
onModalClose={handleModalClose}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default Metadata;
|
|
@ -1,44 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Metadata from './Metadata';
|
||||
import styles from './Metadatas.css';
|
||||
|
||||
function Metadatas(props) {
|
||||
const {
|
||||
items,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('Metadata')}>
|
||||
<PageSectionContent
|
||||
errorMessage={translate('MetadataLoadError')}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.metadatas}>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<Metadata
|
||||
key={item.id}
|
||||
{...item}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
Metadatas.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||
};
|
||||
|
||||
export default Metadatas;
|
52
frontend/src/Settings/Metadata/Metadata/Metadatas.tsx
Normal file
52
frontend/src/Settings/Metadata/Metadata/Metadatas.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import MetadataAppState from 'App/State/MetadataAppState';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import { fetchMetadata } from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import MetadataType from 'typings/Metadata';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Metadata from './Metadata';
|
||||
import styles from './Metadatas.css';
|
||||
|
||||
function createMetadatasSelector() {
|
||||
return createSelector(
|
||||
createSortedSectionSelector<MetadataType>(
|
||||
'settings.metadata',
|
||||
sortByProp('name')
|
||||
),
|
||||
(metadata: MetadataAppState) => metadata
|
||||
);
|
||||
}
|
||||
|
||||
function Metadatas() {
|
||||
const dispatch = useDispatch();
|
||||
const { isFetching, error, items, ...otherProps } = useSelector(
|
||||
createMetadatasSelector()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchMetadata());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('Metadata')}>
|
||||
<PageSectionContent
|
||||
isFetching={isFetching}
|
||||
errorMessage={translate('MetadataLoadError')}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.metadatas}>
|
||||
{items.map((item) => {
|
||||
return <Metadata key={item.id} {...item} />;
|
||||
})}
|
||||
</div>
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export default Metadatas;
|
|
@ -3,7 +3,7 @@ import PageContent from 'Components/Page/PageContent';
|
|||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import MetadatasConnector from './Metadata/MetadatasConnector';
|
||||
import Metadatas from './Metadata/Metadatas';
|
||||
|
||||
function MetadataSettings() {
|
||||
return (
|
||||
|
@ -13,7 +13,7 @@ function MetadataSettings() {
|
|||
/>
|
||||
|
||||
<PageContentBody>
|
||||
<MetadatasConnector />
|
||||
<Metadatas />
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
|
|
|
@ -100,6 +100,7 @@ function selectSettings<T extends ModelBaseSetting>(
|
|||
|
||||
const setting: Pending<T> = {
|
||||
value: item[key],
|
||||
pending: false,
|
||||
errors: getFailures(errors, key),
|
||||
warnings: getFailures(warnings, key),
|
||||
};
|
||||
|
|
|
@ -1,20 +1,13 @@
|
|||
import ModelBase from 'App/ModelBase';
|
||||
import Field from './Field';
|
||||
import Provider from './Provider';
|
||||
|
||||
export type Protocol = 'torrent' | 'usenet' | 'unknown';
|
||||
|
||||
interface DownloadClient extends ModelBase {
|
||||
interface DownloadClient extends Provider {
|
||||
enable: boolean;
|
||||
protocol: Protocol;
|
||||
priority: number;
|
||||
removeCompletedDownloads: boolean;
|
||||
removeFailedDownloads: boolean;
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,8 @@ interface Field {
|
|||
name: string;
|
||||
label: string;
|
||||
value: boolean | number | string | number[];
|
||||
section: string;
|
||||
hidden: 'hidden' | 'hiddenIfNotSet' | 'visible';
|
||||
type: string;
|
||||
advanced: boolean;
|
||||
privacy: string;
|
||||
|
|
|
@ -1,17 +1,10 @@
|
|||
import ModelBase from 'App/ModelBase';
|
||||
import Field from './Field';
|
||||
import Provider from './Provider';
|
||||
|
||||
interface ImportList extends ModelBase {
|
||||
interface ImportList extends Provider {
|
||||
enable: boolean;
|
||||
enableAutomaticAdd: boolean;
|
||||
qualityProfileId: number;
|
||||
rootFolderPath: string;
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
|
|
|
@ -1,18 +1,11 @@
|
|||
import ModelBase from 'App/ModelBase';
|
||||
import Field from './Field';
|
||||
import Provider from './Provider';
|
||||
|
||||
interface Indexer extends ModelBase {
|
||||
interface Indexer extends Provider {
|
||||
enableRss: boolean;
|
||||
enableAutomaticSearch: boolean;
|
||||
enableInteractiveSearch: boolean;
|
||||
protocol: string;
|
||||
priority: number;
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
|
|
7
frontend/src/typings/Metadata.ts
Normal file
7
frontend/src/typings/Metadata.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import Provider from './Provider';
|
||||
|
||||
interface Metadata extends Provider {
|
||||
enable: boolean;
|
||||
}
|
||||
|
||||
export default Metadata;
|
|
@ -1,14 +1,7 @@
|
|||
import ModelBase from 'App/ModelBase';
|
||||
import Field from './Field';
|
||||
import Provider from './Provider';
|
||||
|
||||
interface Notification extends ModelBase {
|
||||
interface Notification extends Provider {
|
||||
enable: boolean;
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
|
|
20
frontend/src/typings/Provider.ts
Normal file
20
frontend/src/typings/Provider.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import ModelBase from 'App/ModelBase';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import Field from './Field';
|
||||
|
||||
export interface ProviderMessage {
|
||||
message: string;
|
||||
type: Extract<Kind, 'info' | 'error' | 'warning'>;
|
||||
}
|
||||
|
||||
interface Provider extends ModelBase {
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
message: ProviderMessage;
|
||||
}
|
||||
|
||||
export default Provider;
|
|
@ -33,7 +33,7 @@ export interface Pending<T> {
|
|||
value: T;
|
||||
errors: Failure[];
|
||||
warnings: Failure[];
|
||||
pending?: boolean;
|
||||
pending: boolean;
|
||||
previousValue?: T;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
@ -6,21 +5,21 @@ using System.Text.RegularExpressions;
|
|||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Extras.Metadata.Files;
|
||||
using NzbDrone.Core.MediaCover;
|
||||
using NzbDrone.Core.Localization;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.ThingiProvider;
|
||||
using NzbDrone.Core.Tv;
|
||||
|
||||
namespace NzbDrone.Core.Extras.Metadata.Consumers.Kometa
|
||||
{
|
||||
public class KometaMetadata : MetadataBase<KometaMetadataSettings>
|
||||
{
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly Logger _logger;
|
||||
private readonly IMapCoversToLocal _mediaCoverService;
|
||||
|
||||
public KometaMetadata(IMapCoversToLocal mediaCoverService,
|
||||
Logger logger)
|
||||
public KometaMetadata(ILocalizationService localizationService, Logger logger)
|
||||
{
|
||||
_mediaCoverService = mediaCoverService;
|
||||
_localizationService = localizationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
@ -30,6 +29,8 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Kometa
|
|||
|
||||
public override string Name => "Kometa";
|
||||
|
||||
public override ProviderMessage Message => new (_localizationService.GetLocalizedString("MetadataKometaDeprecated"), ProviderMessageType.Warning);
|
||||
|
||||
public override string GetFilenameAfterMove(Series series, EpisodeFile episodeFile, MetadataFile metadataFile)
|
||||
{
|
||||
if (metadataFile.Type == MetadataType.EpisodeImage)
|
||||
|
@ -104,92 +105,17 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Kometa
|
|||
|
||||
public override List<ImageFileResult> SeriesImages(Series series)
|
||||
{
|
||||
if (!Settings.SeriesImages)
|
||||
{
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
return ProcessSeriesImages(series).ToList();
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
public override List<ImageFileResult> SeasonImages(Series series, Season season)
|
||||
{
|
||||
if (!Settings.SeasonImages)
|
||||
{
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
return ProcessSeasonImages(series, season).ToList();
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
public override List<ImageFileResult> EpisodeImages(Series series, EpisodeFile episodeFile)
|
||||
{
|
||||
if (!Settings.EpisodeImages)
|
||||
{
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var firstEpisode = episodeFile.Episodes.Value.FirstOrDefault();
|
||||
|
||||
if (firstEpisode == null)
|
||||
{
|
||||
_logger.Debug("Episode file has no associated episodes, potentially a duplicate file");
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
var screenshot = firstEpisode.Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot);
|
||||
|
||||
if (screenshot == null)
|
||||
{
|
||||
_logger.Debug("Episode screenshot not available");
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
return new List<ImageFileResult>
|
||||
{
|
||||
new ImageFileResult(GetEpisodeImageFilename(series, episodeFile), screenshot.RemoteUrl)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to process episode image for file: {0}", Path.Combine(series.Path, episodeFile.RelativePath));
|
||||
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<ImageFileResult> ProcessSeriesImages(Series series)
|
||||
{
|
||||
foreach (var image in series.Images)
|
||||
{
|
||||
if (image.CoverType == MediaCoverTypes.Poster)
|
||||
{
|
||||
var source = _mediaCoverService.GetCoverPath(series.Id, image.CoverType);
|
||||
var destination = image.CoverType + Path.GetExtension(source);
|
||||
|
||||
yield return new ImageFileResult(destination, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<ImageFileResult> ProcessSeasonImages(Series series, Season season)
|
||||
{
|
||||
foreach (var image in season.Images)
|
||||
{
|
||||
if (image.CoverType == MediaCoverTypes.Poster)
|
||||
{
|
||||
var filename = string.Format("Season{0:00}.jpg", season.SeasonNumber);
|
||||
|
||||
if (season.SeasonNumber == 0)
|
||||
{
|
||||
filename = "Season00.jpg";
|
||||
}
|
||||
|
||||
yield return new ImageFileResult(filename, image.RemoteUrl);
|
||||
}
|
||||
}
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
private string GetEpisodeImageFilename(Series series, EpisodeFile episodeFile)
|
||||
|
|
|
@ -15,19 +15,11 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Kometa
|
|||
|
||||
public KometaMetadataSettings()
|
||||
{
|
||||
SeriesImages = true;
|
||||
SeasonImages = true;
|
||||
EpisodeImages = true;
|
||||
Deprecated = true;
|
||||
}
|
||||
|
||||
[FieldDefinition(0, Label = "MetadataSettingsSeriesImages", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "Poster.jpg")]
|
||||
public bool SeriesImages { get; set; }
|
||||
|
||||
[FieldDefinition(1, Label = "MetadataSettingsSeasonImages", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "Season##.jpg")]
|
||||
public bool SeasonImages { get; set; }
|
||||
|
||||
[FieldDefinition(2, Label = "MetadataSettingsEpisodeImages", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "S##E##.jpg")]
|
||||
public bool EpisodeImages { get; set; }
|
||||
[FieldDefinition(0, Label = "MetadataKometaDeprecatedSetting", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, Hidden = HiddenType.Hidden)]
|
||||
public bool Deprecated { get; set; }
|
||||
|
||||
public bool IsValid => true;
|
||||
|
||||
|
|
34
src/NzbDrone.Core/HealthCheck/Checks/MetadataCheck.cs
Normal file
34
src/NzbDrone.Core/HealthCheck/Checks/MetadataCheck.cs
Normal file
|
@ -0,0 +1,34 @@
|
|||
using System.Linq;
|
||||
using NzbDrone.Core.Extras.Metadata;
|
||||
using NzbDrone.Core.Extras.Metadata.Consumers.Kometa;
|
||||
using NzbDrone.Core.Localization;
|
||||
using NzbDrone.Core.ThingiProvider.Events;
|
||||
|
||||
namespace NzbDrone.Core.HealthCheck.Checks
|
||||
{
|
||||
[CheckOn(typeof(ProviderUpdatedEvent<IMetadata>))]
|
||||
public class MetadataCheck : HealthCheckBase
|
||||
{
|
||||
private readonly IMetadataFactory _metadataFactory;
|
||||
|
||||
public MetadataCheck(IMetadataFactory metadataFactory, ILocalizationService localizationService)
|
||||
: base(localizationService)
|
||||
{
|
||||
_metadataFactory = metadataFactory;
|
||||
}
|
||||
|
||||
public override HealthCheck Check()
|
||||
{
|
||||
var enabled = _metadataFactory.Enabled();
|
||||
|
||||
if (enabled.Any(m => m.Definition.Implementation == nameof(KometaMetadata)))
|
||||
{
|
||||
return new HealthCheck(GetType(),
|
||||
HealthCheckResult.Warning,
|
||||
$"{_localizationService.GetLocalizedString("MetadataKometaDeprecated")}");
|
||||
}
|
||||
|
||||
return new HealthCheck(GetType());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1163,6 +1163,8 @@
|
|||
"Message": "Message",
|
||||
"Metadata": "Metadata",
|
||||
"MetadataLoadError": "Unable to load Metadata",
|
||||
"MetadataKometaDeprecated": "Kometa files will no longer be created, support will be removed completely in v5",
|
||||
"MetadataKometaDeprecatedSetting": "Deprecated",
|
||||
"MetadataPlexSettingsEpisodeMappings": "Episode Mappings",
|
||||
"MetadataPlexSettingsEpisodeMappingsHelpText": "Include episode mappings for all files in .plexmatch file",
|
||||
"MetadataPlexSettingsSeriesPlexMatchFile": "Series Plex Match File",
|
||||
|
|
Loading…
Reference in a new issue