mirror of
https://github.com/Radarr/Radarr.git
synced 2026-04-16 21:15:33 -04:00
Compare commits
17 Commits
v5.16.0.94
...
v5.16.1.95
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0caa793df4 | ||
|
|
9a107cc8d7 | ||
|
|
a6d727fe2a | ||
|
|
01a53d3624 | ||
|
|
348c29c9d7 | ||
|
|
64739712c6 | ||
|
|
6ac9cca953 | ||
|
|
a2b38c5b7d | ||
|
|
3cc4105d71 | ||
|
|
3449a5d3fe | ||
|
|
5bac157d36 | ||
|
|
114d260f42 | ||
|
|
617b9c5d35 | ||
|
|
ba4ccbb0bd | ||
|
|
b845268b3d | ||
|
|
0fee552074 | ||
|
|
828b994ef4 |
@@ -9,7 +9,7 @@ variables:
|
||||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '5.16.0'
|
||||
majorVersion: '5.16.1'
|
||||
minorVersion: $[counter('minorVersion', 2000)]
|
||||
radarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(radarrVersion)'
|
||||
|
||||
@@ -210,7 +210,6 @@ module.exports = {
|
||||
'no-undef-init': 'off',
|
||||
'no-undefined': 'off',
|
||||
'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }],
|
||||
'no-use-before-define': 'error',
|
||||
|
||||
// Node.js and CommonJS
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import Column from 'Components/Table/Column';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import { ValidationFailure } from 'typings/pending';
|
||||
import { FilterBuilderProp, PropertyFilter } from './AppState';
|
||||
|
||||
export interface Error {
|
||||
responseJSON: {
|
||||
message: string;
|
||||
};
|
||||
status?: number;
|
||||
responseJSON:
|
||||
| {
|
||||
message: string | undefined;
|
||||
}
|
||||
| ValidationFailure[]
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export interface AppSectionDeleteState {
|
||||
@@ -51,6 +56,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;
|
||||
|
||||
@@ -139,6 +139,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,
|
||||
|
||||
@@ -40,7 +40,7 @@ function createImportListExclusionSelector(id?: number) {
|
||||
importListExclusions;
|
||||
|
||||
const mapping = id
|
||||
? items.find((i) => i.id === id)
|
||||
? items.find((i) => i.id === id)!
|
||||
: newImportListExclusion;
|
||||
const settings = selectSettings(mapping, pendingChanges, saveError);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,47 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchMetadata } from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import Metadatas from './Metadatas';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSortedSectionSelector('settings.metadata', sortByProp('name')),
|
||||
(metadata) => metadata
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchMetadata
|
||||
};
|
||||
|
||||
class MetadatasConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchMetadata();
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Metadatas
|
||||
{...this.props}
|
||||
onConfirmDeleteMetadata={this.onConfirmDeleteMetadata}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MetadatasConnector.propTypes = {
|
||||
fetchMetadata: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);
|
||||
@@ -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';
|
||||
import MetadataOptionsConnector from './Options/MetadataOptionsConnector';
|
||||
|
||||
class MetadataSettings extends Component {
|
||||
@@ -62,7 +62,7 @@ class MetadataSettings extends Component {
|
||||
onChildStateChange={this.onChildStateChange}
|
||||
/>
|
||||
|
||||
<MetadatasConnector />
|
||||
<Metadatas />
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
|
||||
@@ -19,14 +19,15 @@ import {
|
||||
setReleaseProfileValue,
|
||||
} from 'Store/Actions/Settings/releaseProfiles';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import { PendingSection } from 'typings/pending';
|
||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EditReleaseProfileModalContent.css';
|
||||
|
||||
const tagInputDelimiters = ['Tab', 'Enter'];
|
||||
|
||||
const newReleaseProfile = {
|
||||
const newReleaseProfile: ReleaseProfile = {
|
||||
id: 0,
|
||||
name: '',
|
||||
enabled: true,
|
||||
required: [],
|
||||
ignored: [],
|
||||
@@ -41,8 +42,12 @@ function createReleaseProfileSelector(id?: number) {
|
||||
const { items, isFetching, error, isSaving, saveError, pendingChanges } =
|
||||
releaseProfiles;
|
||||
|
||||
const mapping = id ? items.find((i) => i.id === id) : newReleaseProfile;
|
||||
const settings = selectSettings(mapping, pendingChanges, saveError);
|
||||
const mapping = id ? items.find((i) => i.id === id)! : newReleaseProfile;
|
||||
const settings = selectSettings<ReleaseProfile>(
|
||||
mapping,
|
||||
pendingChanges,
|
||||
saveError
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -50,7 +55,7 @@ function createReleaseProfileSelector(id?: number) {
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
item: settings.settings as PendingSection<ReleaseProfile>,
|
||||
item: settings.settings,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
function getValidationFailures(saveError) {
|
||||
if (!saveError || saveError.status !== 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.cloneDeep(saveError.responseJSON);
|
||||
}
|
||||
|
||||
function mapFailure(failure) {
|
||||
return {
|
||||
message: failure.errorMessage,
|
||||
link: failure.infoLink,
|
||||
detailedMessage: failure.detailedDescription
|
||||
};
|
||||
}
|
||||
|
||||
function selectSettings(item, pendingChanges, saveError) {
|
||||
const validationFailures = getValidationFailures(saveError);
|
||||
|
||||
// Merge all settings from the item along with pending
|
||||
// changes to ensure any settings that were not included
|
||||
// with the item are included.
|
||||
const allSettings = Object.assign({}, item, pendingChanges);
|
||||
|
||||
const settings = _.reduce(allSettings, (result, value, key) => {
|
||||
if (key === 'fields') {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Return a flattened value
|
||||
if (key === 'implementationName') {
|
||||
result.implementationName = item[key];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const setting = {
|
||||
value: item[key],
|
||||
errors: _.map(_.remove(validationFailures, (failure) => {
|
||||
return failure.propertyName.toLowerCase() === key.toLowerCase() && !failure.isWarning;
|
||||
}), mapFailure),
|
||||
|
||||
warnings: _.map(_.remove(validationFailures, (failure) => {
|
||||
return failure.propertyName.toLowerCase() === key.toLowerCase() && failure.isWarning;
|
||||
}), mapFailure)
|
||||
};
|
||||
|
||||
if (pendingChanges.hasOwnProperty(key)) {
|
||||
setting.previousValue = setting.value;
|
||||
setting.value = pendingChanges[key];
|
||||
setting.pending = true;
|
||||
}
|
||||
|
||||
result[key] = setting;
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
const fields = _.reduce(item.fields, (result, f) => {
|
||||
const field = Object.assign({ pending: false }, f);
|
||||
const hasPendingFieldChange = pendingChanges.fields && pendingChanges.fields.hasOwnProperty(field.name);
|
||||
|
||||
if (hasPendingFieldChange) {
|
||||
field.previousValue = field.value;
|
||||
field.value = pendingChanges.fields[field.name];
|
||||
field.pending = true;
|
||||
}
|
||||
|
||||
field.errors = _.map(_.remove(validationFailures, (failure) => {
|
||||
return failure.propertyName.toLowerCase() === field.name.toLowerCase() && !failure.isWarning;
|
||||
}), mapFailure);
|
||||
|
||||
field.warnings = _.map(_.remove(validationFailures, (failure) => {
|
||||
return failure.propertyName.toLowerCase() === field.name.toLowerCase() && failure.isWarning;
|
||||
}), mapFailure);
|
||||
|
||||
result.push(field);
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
if (fields.length) {
|
||||
settings.fields = fields;
|
||||
}
|
||||
|
||||
const validationErrors = _.filter(validationFailures, (failure) => {
|
||||
return !failure.isWarning;
|
||||
});
|
||||
|
||||
const validationWarnings = _.filter(validationFailures, (failure) => {
|
||||
return failure.isWarning;
|
||||
});
|
||||
|
||||
return {
|
||||
settings,
|
||||
validationErrors,
|
||||
validationWarnings,
|
||||
hasPendingChanges: !_.isEmpty(pendingChanges),
|
||||
hasSettings: !_.isEmpty(settings),
|
||||
pendingChanges
|
||||
};
|
||||
}
|
||||
|
||||
export default selectSettings;
|
||||
168
frontend/src/Store/Selectors/selectSettings.ts
Normal file
168
frontend/src/Store/Selectors/selectSettings.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { cloneDeep, isEmpty } from 'lodash';
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
import Field from 'typings/Field';
|
||||
import {
|
||||
Failure,
|
||||
Pending,
|
||||
PendingField,
|
||||
PendingSection,
|
||||
ValidationError,
|
||||
ValidationFailure,
|
||||
ValidationWarning,
|
||||
} from 'typings/pending';
|
||||
|
||||
interface ValidationFailures {
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
}
|
||||
|
||||
function getValidationFailures(saveError?: Error): ValidationFailures {
|
||||
if (!saveError || saveError.status !== 400) {
|
||||
return {
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
return cloneDeep(saveError.responseJSON as ValidationFailure[]).reduce(
|
||||
(acc: ValidationFailures, failure: ValidationFailure) => {
|
||||
if (failure.isWarning) {
|
||||
acc.warnings.push(failure as ValidationWarning);
|
||||
} else {
|
||||
acc.errors.push(failure as ValidationError);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
errors: [],
|
||||
warnings: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getFailures(failures: ValidationFailure[], key: string) {
|
||||
const result = [];
|
||||
|
||||
for (let i = failures.length - 1; i >= 0; i--) {
|
||||
if (failures[i].propertyName.toLowerCase() === key.toLowerCase()) {
|
||||
result.unshift(mapFailure(failures[i]));
|
||||
|
||||
failures.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function mapFailure(failure: ValidationFailure): Failure {
|
||||
return {
|
||||
errorMessage: failure.errorMessage,
|
||||
infoLink: failure.infoLink,
|
||||
detailedDescription: failure.detailedDescription,
|
||||
|
||||
// TODO: Remove these renamed properties
|
||||
message: failure.errorMessage,
|
||||
link: failure.infoLink,
|
||||
detailedMessage: failure.detailedDescription,
|
||||
};
|
||||
}
|
||||
|
||||
interface ModelBaseSetting {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[id: string]: any;
|
||||
}
|
||||
|
||||
function selectSettings<T extends ModelBaseSetting>(
|
||||
item: T,
|
||||
pendingChanges: Partial<ModelBaseSetting>,
|
||||
saveError?: Error
|
||||
) {
|
||||
const { errors, warnings } = getValidationFailures(saveError);
|
||||
|
||||
// Merge all settings from the item along with pending
|
||||
// changes to ensure any settings that were not included
|
||||
// with the item are included.
|
||||
const allSettings = Object.assign({}, item, pendingChanges);
|
||||
|
||||
const settings = Object.keys(allSettings).reduce(
|
||||
(acc: PendingSection<T>, key) => {
|
||||
if (key === 'fields') {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Return a flattened value
|
||||
if (key === 'implementationName') {
|
||||
acc.implementationName = item[key];
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
const setting: Pending<T> = {
|
||||
value: item[key],
|
||||
pending: false,
|
||||
errors: getFailures(errors, key),
|
||||
warnings: getFailures(warnings, key),
|
||||
};
|
||||
|
||||
if (pendingChanges.hasOwnProperty(key)) {
|
||||
setting.previousValue = setting.value;
|
||||
setting.value = pendingChanges[key];
|
||||
setting.pending = true;
|
||||
}
|
||||
|
||||
// @ts-expect-error - This is a valid key
|
||||
acc[key] = setting;
|
||||
return acc;
|
||||
},
|
||||
{} as PendingSection<T>
|
||||
);
|
||||
|
||||
if ('fields' in item) {
|
||||
const fields =
|
||||
(item.fields as Field[]).reduce((acc: PendingField<T>[], f) => {
|
||||
const field: PendingField<T> = Object.assign(
|
||||
{ pending: false, errors: [], warnings: [] },
|
||||
f
|
||||
);
|
||||
|
||||
if ('fields' in pendingChanges) {
|
||||
const pendingChangesFields = pendingChanges.fields as Record<
|
||||
string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
any
|
||||
>;
|
||||
|
||||
if (pendingChangesFields.hasOwnProperty(field.name)) {
|
||||
field.previousValue = field.value;
|
||||
field.value = pendingChangesFields[field.name];
|
||||
field.pending = true;
|
||||
}
|
||||
}
|
||||
|
||||
field.errors = getFailures(errors, field.name);
|
||||
field.warnings = getFailures(warnings, field.name);
|
||||
|
||||
acc.push(field);
|
||||
return acc;
|
||||
}, []) ?? [];
|
||||
|
||||
if (fields.length) {
|
||||
settings.fields = fields;
|
||||
}
|
||||
}
|
||||
|
||||
const validationErrors = errors;
|
||||
const validationWarnings = warnings;
|
||||
|
||||
return {
|
||||
settings,
|
||||
validationErrors,
|
||||
validationWarnings,
|
||||
hasPendingChanges: !isEmpty(pendingChanges),
|
||||
hasSettings: !isEmpty(settings),
|
||||
pendingChanges,
|
||||
};
|
||||
}
|
||||
|
||||
export default selectSettings;
|
||||
@@ -27,6 +27,12 @@ export default function translate(
|
||||
key: string,
|
||||
tokens: Record<string, string | number | boolean> = {}
|
||||
) {
|
||||
const { isProduction = true } = window.Radarr;
|
||||
|
||||
if (!isProduction && !(key in translations)) {
|
||||
console.warn(`Missing translation for key: ${key}`);
|
||||
}
|
||||
|
||||
const translation = translations[key] || key;
|
||||
|
||||
tokens.appName = 'Radarr';
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import Provider from './Provider';
|
||||
|
||||
export interface Field {
|
||||
order: number;
|
||||
name: string;
|
||||
label: string;
|
||||
value: boolean | number | string;
|
||||
type: string;
|
||||
advanced: boolean;
|
||||
privacy: string;
|
||||
}
|
||||
export type Protocol = 'torrent' | 'usenet' | 'unknown';
|
||||
|
||||
interface DownloadClient extends ModelBase {
|
||||
interface DownloadClient extends Provider {
|
||||
enable: boolean;
|
||||
protocol: string;
|
||||
protocol: Protocol;
|
||||
priority: number;
|
||||
removeCompletedDownloads: boolean;
|
||||
removeFailedDownloads: boolean;
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
|
||||
23
frontend/src/typings/Field.ts
Normal file
23
frontend/src/typings/Field.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface FieldSelectOption<T> {
|
||||
value: T;
|
||||
name: string;
|
||||
order: number;
|
||||
hint?: string;
|
||||
parentValue?: T;
|
||||
isDisabled?: boolean;
|
||||
additionalProperties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface Field {
|
||||
order: number;
|
||||
name: string;
|
||||
label: string;
|
||||
value: boolean | number | string | number[];
|
||||
section: string;
|
||||
hidden: 'hidden' | 'hiddenIfNotSet' | 'visible';
|
||||
type: string;
|
||||
advanced: boolean;
|
||||
privacy: string;
|
||||
}
|
||||
|
||||
export default Field;
|
||||
@@ -1,28 +1,12 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import Provider from './Provider';
|
||||
|
||||
export interface Field {
|
||||
order: number;
|
||||
name: string;
|
||||
label: string;
|
||||
value: boolean | number | string;
|
||||
type: string;
|
||||
advanced: boolean;
|
||||
privacy: string;
|
||||
}
|
||||
|
||||
interface ImportList extends ModelBase {
|
||||
interface ImportList extends Provider {
|
||||
enable: boolean;
|
||||
enabled: boolean;
|
||||
enableAuto: boolean;
|
||||
qualityProfileId: number;
|
||||
minimumAvailability: string;
|
||||
rootFolderPath: string;
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,11 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import Provider from './Provider';
|
||||
|
||||
export interface Field {
|
||||
order: number;
|
||||
name: string;
|
||||
label: string;
|
||||
value: boolean | number | string;
|
||||
type: string;
|
||||
advanced: boolean;
|
||||
privacy: string;
|
||||
}
|
||||
|
||||
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,23 +1,7 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import Provider from './Provider';
|
||||
|
||||
export interface Field {
|
||||
order: number;
|
||||
name: string;
|
||||
label: string;
|
||||
value: boolean | number | string;
|
||||
type: string;
|
||||
advanced: boolean;
|
||||
privacy: string;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1,6 +1,11 @@
|
||||
import Field from './Field';
|
||||
|
||||
export interface ValidationFailure {
|
||||
isWarning: boolean;
|
||||
propertyName: string;
|
||||
errorMessage: string;
|
||||
infoLink?: string;
|
||||
detailedDescription?: string;
|
||||
severity: 'error' | 'warning';
|
||||
}
|
||||
|
||||
@@ -12,12 +17,47 @@ export interface ValidationWarning extends ValidationFailure {
|
||||
isWarning: true;
|
||||
}
|
||||
|
||||
export interface Pending<T> {
|
||||
value: T;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
export interface Failure {
|
||||
errorMessage: ValidationFailure['errorMessage'];
|
||||
infoLink: ValidationFailure['infoLink'];
|
||||
detailedDescription: ValidationFailure['detailedDescription'];
|
||||
|
||||
// TODO: Remove these renamed properties
|
||||
|
||||
message: ValidationFailure['errorMessage'];
|
||||
link: ValidationFailure['infoLink'];
|
||||
detailedMessage: ValidationFailure['detailedDescription'];
|
||||
}
|
||||
|
||||
export type PendingSection<T> = {
|
||||
[K in keyof T]: Pending<T[K]>;
|
||||
export interface Pending<T> {
|
||||
value: T;
|
||||
errors: Failure[];
|
||||
warnings: Failure[];
|
||||
pending: boolean;
|
||||
previousValue?: T;
|
||||
}
|
||||
|
||||
export interface PendingField<T>
|
||||
extends Field,
|
||||
Omit<Pending<T>, 'previousValue' | 'value'> {
|
||||
previousValue?: Field['value'];
|
||||
}
|
||||
|
||||
// export type PendingSection<T> = {
|
||||
// [K in keyof T]: Pending<T[K]>;
|
||||
// };
|
||||
|
||||
type Mapped<T> = {
|
||||
[Prop in keyof T]: {
|
||||
value: T[Prop];
|
||||
errors: Failure[];
|
||||
warnings: Failure[];
|
||||
pending?: boolean;
|
||||
previousValue?: T[Prop];
|
||||
};
|
||||
};
|
||||
|
||||
export type PendingSection<T> = Mapped<T> & {
|
||||
implementationName?: string;
|
||||
fields?: PendingField<T>[];
|
||||
};
|
||||
|
||||
1
frontend/typings/Globals.d.ts
vendored
1
frontend/typings/Globals.d.ts
vendored
@@ -7,5 +7,6 @@ interface Window {
|
||||
theme: string;
|
||||
urlBase: string;
|
||||
version: string;
|
||||
isProduction: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,9 +21,28 @@ namespace NzbDrone.Common.Test.ExtensionTests
|
||||
[TestCase("1.2.3.4")]
|
||||
[TestCase("172.55.0.1")]
|
||||
[TestCase("192.55.0.1")]
|
||||
[TestCase("100.64.0.1")]
|
||||
[TestCase("100.127.255.254")]
|
||||
public void should_return_false_for_public_ip_address(string ipAddress)
|
||||
{
|
||||
IPAddress.Parse(ipAddress).IsLocalAddress().Should().BeFalse();
|
||||
}
|
||||
|
||||
[TestCase("100.64.0.1")]
|
||||
[TestCase("100.127.255.254")]
|
||||
[TestCase("100.100.100.100")]
|
||||
public void should_return_true_for_cgnat_ip_address(string ipAddress)
|
||||
{
|
||||
IPAddress.Parse(ipAddress).IsCgnatIpAddress().Should().BeTrue();
|
||||
}
|
||||
|
||||
[TestCase("1.2.3.4")]
|
||||
[TestCase("192.168.5.1")]
|
||||
[TestCase("100.63.255.255")]
|
||||
[TestCase("100.128.0.0")]
|
||||
public void should_return_false_for_non_cgnat_ip_address(string ipAddress)
|
||||
{
|
||||
IPAddress.Parse(ipAddress).IsCgnatIpAddress().Should().BeFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,16 +190,23 @@ namespace NzbDrone.Common.Disk
|
||||
|
||||
var fi = new FileInfo(path);
|
||||
|
||||
// If the file is a symlink, resolve the target path and get the size of the target file.
|
||||
if (fi.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
try
|
||||
{
|
||||
var targetPath = fi.ResolveLinkTarget(true)?.FullName;
|
||||
|
||||
if (targetPath != null)
|
||||
// If the file is a symlink, resolve the target path and get the size of the target file.
|
||||
if (fi.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
{
|
||||
fi = new FileInfo(targetPath);
|
||||
var targetPath = fi.ResolveLinkTarget(true)?.FullName;
|
||||
|
||||
if (targetPath != null)
|
||||
{
|
||||
fi = new FileInfo(targetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Logger.Trace(ex, "Unable to resolve symlink target for {0}", path);
|
||||
}
|
||||
|
||||
return fi.Length;
|
||||
}
|
||||
|
||||
@@ -148,10 +148,5 @@ namespace NzbDrone.Common.Extensions
|
||||
{
|
||||
return string.Join(separator, source.Select(predicate));
|
||||
}
|
||||
|
||||
public static HashSet<T> ToHashSet<T>(this IEnumerable<T> source, IEqualityComparer<T> comparer = null)
|
||||
{
|
||||
return new HashSet<T>(source, comparer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,18 +39,24 @@ namespace NzbDrone.Common.Extensions
|
||||
private static bool IsLocalIPv4(byte[] ipv4Bytes)
|
||||
{
|
||||
// Link local (no IP assigned by DHCP): 169.254.0.0 to 169.254.255.255 (169.254.0.0/16)
|
||||
bool IsLinkLocal() => ipv4Bytes[0] == 169 && ipv4Bytes[1] == 254;
|
||||
var isLinkLocal = ipv4Bytes[0] == 169 && ipv4Bytes[1] == 254;
|
||||
|
||||
// Class A private range: 10.0.0.0 – 10.255.255.255 (10.0.0.0/8)
|
||||
bool IsClassA() => ipv4Bytes[0] == 10;
|
||||
var isClassA = ipv4Bytes[0] == 10;
|
||||
|
||||
// Class B private range: 172.16.0.0 – 172.31.255.255 (172.16.0.0/12)
|
||||
bool IsClassB() => ipv4Bytes[0] == 172 && ipv4Bytes[1] >= 16 && ipv4Bytes[1] <= 31;
|
||||
var isClassB = ipv4Bytes[0] == 172 && ipv4Bytes[1] >= 16 && ipv4Bytes[1] <= 31;
|
||||
|
||||
// Class C private range: 192.168.0.0 – 192.168.255.255 (192.168.0.0/16)
|
||||
bool IsClassC() => ipv4Bytes[0] == 192 && ipv4Bytes[1] == 168;
|
||||
var isClassC = ipv4Bytes[0] == 192 && ipv4Bytes[1] == 168;
|
||||
|
||||
return IsLinkLocal() || IsClassA() || IsClassC() || IsClassB();
|
||||
return isLinkLocal || isClassA || isClassC || isClassB;
|
||||
}
|
||||
|
||||
public static bool IsCgnatIpAddress(this IPAddress ipAddress)
|
||||
{
|
||||
var bytes = ipAddress.GetAddressBytes();
|
||||
return bytes.Length == 4 && bytes[0] == 100 && bytes[1] >= 64 && bytes[1] <= 127;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ public class AuthOptions
|
||||
public bool? Enabled { get; set; }
|
||||
public string Method { get; set; }
|
||||
public string Required { get; set; }
|
||||
public bool? TrustCgnatIpAddresses { get; set; }
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
|
||||
<PackageReference Include="IPAddressRange" Version="6.0.0" />
|
||||
<PackageReference Include="IPAddressRange" Version="6.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="NLog" Version="5.3.3" />
|
||||
<PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.0" />
|
||||
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.12" />
|
||||
<PackageReference Include="Npgsql" Version="7.0.8" />
|
||||
<PackageReference Include="NLog" Version="5.3.4" />
|
||||
<PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.2" />
|
||||
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.15" />
|
||||
<PackageReference Include="Npgsql" Version="7.0.9" />
|
||||
<PackageReference Include="Sentry" Version="4.0.2" />
|
||||
<PackageReference Include="NLog.Targets.Syslog" Version="7.0.0" />
|
||||
<PackageReference Include="SharpZipLib" Version="1.4.2" />
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Datastore;
|
||||
|
||||
namespace NzbDrone.Core.Test.Datastore;
|
||||
|
||||
[TestFixture]
|
||||
public class DatabaseVersionParserFixture
|
||||
{
|
||||
[TestCase("3.44.2", 3, 44, 2)]
|
||||
public void should_parse_sqlite_database_version(string serverVersion, int majorVersion, int minorVersion, int buildVersion)
|
||||
{
|
||||
var version = DatabaseVersionParser.ParseServerVersion(serverVersion);
|
||||
|
||||
version.Should().NotBeNull();
|
||||
version.Major.Should().Be(majorVersion);
|
||||
version.Minor.Should().Be(minorVersion);
|
||||
version.Build.Should().Be(buildVersion);
|
||||
}
|
||||
|
||||
[TestCase("14.8 (Debian 14.8-1.pgdg110+1)", 14, 8, null)]
|
||||
[TestCase("16.3 (Debian 16.3-1.pgdg110+1)", 16, 3, null)]
|
||||
[TestCase("16.3 - Percona Distribution", 16, 3, null)]
|
||||
[TestCase("17.0 - Percona Server", 17, 0, null)]
|
||||
public void should_parse_postgres_database_version(string serverVersion, int majorVersion, int minorVersion, int? buildVersion)
|
||||
{
|
||||
var version = DatabaseVersionParser.ParseServerVersion(serverVersion);
|
||||
|
||||
version.Should().NotBeNull();
|
||||
version.Major.Should().Be(majorVersion);
|
||||
version.Minor.Should().Be(minorVersion);
|
||||
|
||||
if (buildVersion.HasValue)
|
||||
{
|
||||
version.Build.Should().Be(buildVersion.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -478,6 +478,37 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests
|
||||
downloadClientInfo.RemovesCompletedDownloads.Should().BeTrue();
|
||||
}
|
||||
|
||||
[TestCase("all", 0)]
|
||||
[TestCase("days-archive", 15)]
|
||||
[TestCase("days-delete", 15)]
|
||||
public void should_set_history_removes_completed_downloads_false_for_separate_properties(string option, int number)
|
||||
{
|
||||
_config.Misc.history_retention_option = option;
|
||||
_config.Misc.history_retention_number = number;
|
||||
|
||||
var downloadClientInfo = Subject.GetStatus();
|
||||
|
||||
downloadClientInfo.RemovesCompletedDownloads.Should().BeFalse();
|
||||
}
|
||||
|
||||
[TestCase("number-archive", 10)]
|
||||
[TestCase("number-delete", 10)]
|
||||
[TestCase("number-archive", 0)]
|
||||
[TestCase("number-delete", 0)]
|
||||
[TestCase("days-archive", 3)]
|
||||
[TestCase("days-delete", 3)]
|
||||
[TestCase("all-archive", 0)]
|
||||
[TestCase("all-delete", 0)]
|
||||
public void should_set_history_removes_completed_downloads_true_for_separate_properties(string option, int number)
|
||||
{
|
||||
_config.Misc.history_retention_option = option;
|
||||
_config.Misc.history_retention_number = number;
|
||||
|
||||
var downloadClientInfo = Subject.GetStatus();
|
||||
|
||||
downloadClientInfo.RemovesCompletedDownloads.Should().BeTrue();
|
||||
}
|
||||
|
||||
[TestCase(@"Y:\sabnzbd\root", @"completed\downloads", @"vv", @"Y:\sabnzbd\root\completed\downloads", @"Y:\sabnzbd\root\completed\downloads\vv")]
|
||||
[TestCase(@"Y:\sabnzbd\root", @"completed", @"vv", @"Y:\sabnzbd\root\completed", @"Y:\sabnzbd\root\completed\vv")]
|
||||
[TestCase(@"/sabnzbd/root", @"completed/downloads", @"vv", @"/sabnzbd/root/completed/downloads", @"/sabnzbd/root/completed/downloads/vv")]
|
||||
|
||||
@@ -13,6 +13,14 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests
|
||||
[TestFixture]
|
||||
public class TransmissionFixture : TransmissionFixtureBase<Transmission>
|
||||
{
|
||||
[SetUp]
|
||||
public void Setup_Transmission()
|
||||
{
|
||||
Mocker.GetMock<ITransmissionProxy>()
|
||||
.Setup(v => v.GetClientVersion(It.IsAny<TransmissionSettings>(), It.IsAny<bool>()))
|
||||
.Returns("4.0.6");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void queued_item_should_have_required_properties()
|
||||
{
|
||||
@@ -272,7 +280,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests
|
||||
public void should_only_check_version_number(string version)
|
||||
{
|
||||
Mocker.GetMock<ITransmissionProxy>()
|
||||
.Setup(s => s.GetClientVersion(It.IsAny<TransmissionSettings>()))
|
||||
.Setup(s => s.GetClientVersion(It.IsAny<TransmissionSettings>(), true))
|
||||
.Returns(version);
|
||||
|
||||
Subject.Test().IsValid.Should().BeTrue();
|
||||
|
||||
@@ -29,7 +29,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests
|
||||
Host = "127.0.0.1",
|
||||
Port = 2222,
|
||||
Username = "admin",
|
||||
Password = "pass"
|
||||
Password = "pass",
|
||||
MovieCategory = ""
|
||||
};
|
||||
|
||||
Subject.Definition = new DownloadClientDefinition();
|
||||
@@ -152,7 +153,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests
|
||||
}
|
||||
|
||||
Mocker.GetMock<ITransmissionProxy>()
|
||||
.Setup(s => s.GetTorrents(It.IsAny<TransmissionSettings>()))
|
||||
.Setup(s => s.GetTorrents(null, It.IsAny<TransmissionSettings>()))
|
||||
.Returns(torrents);
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ namespace NzbDrone.Core.Configuration
|
||||
string PostgresMainDb { get; }
|
||||
string PostgresLogDb { get; }
|
||||
string Theme { get; }
|
||||
bool TrustCgnatIpAddresses { get; }
|
||||
}
|
||||
|
||||
public class ConfigFileProvider : IConfigFileProvider
|
||||
@@ -461,5 +462,7 @@ namespace NzbDrone.Core.Configuration
|
||||
{
|
||||
SetValue("ApiKey", GenerateApiKey());
|
||||
}
|
||||
|
||||
public bool TrustCgnatIpAddresses => _authOptions.TrustCgnatIpAddresses ?? GetValueBoolean("TrustCgnatIpAddresses", false, persist: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +444,12 @@ namespace NzbDrone.Core.Configuration
|
||||
|
||||
public string ApplicationUrl => GetValue("ApplicationUrl", string.Empty);
|
||||
|
||||
public bool TrustCgnatIpAddresses
|
||||
{
|
||||
get { return GetValueBoolean("TrustCgnatIpAddresses", false); }
|
||||
set { SetValue("TrustCgnatIpAddresses", value); }
|
||||
}
|
||||
|
||||
private string GetValue(string key)
|
||||
{
|
||||
return GetValue(key, string.Empty);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Data.SQLite;
|
||||
using System.Text.RegularExpressions;
|
||||
using Dapper;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Instrumentation;
|
||||
@@ -52,9 +51,8 @@ namespace NzbDrone.Core.Datastore
|
||||
{
|
||||
using var db = _datamapperFactory();
|
||||
var dbConnection = db as DbConnection;
|
||||
var version = Regex.Replace(dbConnection.ServerVersion, @"\(.*?\)", "");
|
||||
|
||||
return new Version(version);
|
||||
return DatabaseVersionParser.ParseServerVersion(dbConnection.ServerVersion);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
src/NzbDrone.Core/Datastore/DatabaseVersionParser.cs
Normal file
16
src/NzbDrone.Core/Datastore/DatabaseVersionParser.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NzbDrone.Core.Datastore;
|
||||
|
||||
public static class DatabaseVersionParser
|
||||
{
|
||||
private static readonly Regex VersionRegex = new (@"^[^ ]+", RegexOptions.Compiled);
|
||||
|
||||
public static Version ParseServerVersion(string serverVersion)
|
||||
{
|
||||
var match = VersionRegex.Match(serverVersion);
|
||||
|
||||
return match.Success ? new Version(match.Value) : null;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
public class Deluge : TorrentClientBase<DelugeSettings>
|
||||
{
|
||||
private readonly IDelugeProxy _proxy;
|
||||
private bool _hasAttemptedReconnecting;
|
||||
|
||||
public Deluge(IDelugeProxy proxy,
|
||||
ITorrentFileInfoReader torrentFileInfoReader,
|
||||
@@ -128,14 +129,9 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
|
||||
foreach (var torrent in torrents)
|
||||
{
|
||||
// Silently ignore torrents with no hash
|
||||
if (torrent.Hash.IsNullOrWhiteSpace())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ignore torrents without a name, but track to log a single warning for all invalid torrents.
|
||||
if (torrent.Name.IsNullOrWhiteSpace())
|
||||
// Ignore torrents without a hash or name, but track to log a single warning
|
||||
// for all invalid torrents as well as reconnect to the Daemon.
|
||||
if (torrent.Hash.IsNullOrWhiteSpace() || torrent.Name.IsNullOrWhiteSpace())
|
||||
{
|
||||
ignoredCount++;
|
||||
continue;
|
||||
@@ -199,9 +195,20 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
if (ignoredCount > 0)
|
||||
if (ignoredCount > 0 && _hasAttemptedReconnecting)
|
||||
{
|
||||
_logger.Warn("{0} torrent(s) were ignored because they did not have a title. Check Deluge and remove any invalid torrents");
|
||||
if (_hasAttemptedReconnecting)
|
||||
{
|
||||
_logger.Warn("{0} torrent(s) were ignored because they did not have a hash or title. Deluge may have disconnected from it's daemon. If you continue to see this error, check Deluge for invalid torrents.", ignoredCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
_proxy.ReconnectToDaemon(Settings);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_hasAttemptedReconnecting = false;
|
||||
}
|
||||
|
||||
return items;
|
||||
@@ -322,9 +329,9 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
return null;
|
||||
}
|
||||
|
||||
var enabledPlugins = _proxy.GetEnabledPlugins(Settings);
|
||||
var methods = _proxy.GetMethods(Settings);
|
||||
|
||||
if (!enabledPlugins.Contains("Label"))
|
||||
if (!methods.Any(m => m.StartsWith("label.")))
|
||||
{
|
||||
return new NzbDroneValidationFailure("MovieCategory", _localizationService.GetLocalizedString("DownloadClientDelugeValidationLabelPluginInactive"))
|
||||
{
|
||||
|
||||
@@ -18,8 +18,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
Dictionary<string, object> GetConfig(DelugeSettings settings);
|
||||
DelugeTorrent[] GetTorrents(DelugeSettings settings);
|
||||
DelugeTorrent[] GetTorrentsByLabel(string label, DelugeSettings settings);
|
||||
string[] GetAvailablePlugins(DelugeSettings settings);
|
||||
string[] GetEnabledPlugins(DelugeSettings settings);
|
||||
string[] GetMethods(DelugeSettings settings);
|
||||
string[] GetAvailableLabels(DelugeSettings settings);
|
||||
DelugeLabel GetLabelOptions(DelugeSettings settings);
|
||||
void SetTorrentLabel(string hash, string label, DelugeSettings settings);
|
||||
@@ -30,6 +29,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
string AddTorrentFromFile(string filename, byte[] fileContent, DelugeSettings settings);
|
||||
bool RemoveTorrent(string hash, bool removeData, DelugeSettings settings);
|
||||
void MoveTorrentToTopInQueue(string hash, DelugeSettings settings);
|
||||
void ReconnectToDaemon(DelugeSettings settings);
|
||||
}
|
||||
|
||||
public class DelugeProxy : IDelugeProxy
|
||||
@@ -51,25 +51,14 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
|
||||
public string GetVersion(DelugeSettings settings)
|
||||
{
|
||||
try
|
||||
var methods = GetMethods(settings);
|
||||
|
||||
if (methods.Contains("daemon.get_version"))
|
||||
{
|
||||
var response = ProcessRequest<string>(settings, "daemon.info");
|
||||
|
||||
return response;
|
||||
return ProcessRequest<string>(settings, "daemon.get_version");
|
||||
}
|
||||
catch (DownloadClientException ex)
|
||||
{
|
||||
if (ex.Message.Contains("Unknown method"))
|
||||
{
|
||||
// Deluge v2 beta replaced 'daemon.info' with 'daemon.get_version'.
|
||||
// It may return or become official, for now we just retry with the get_version api.
|
||||
var response = ProcessRequest<string>(settings, "daemon.get_version");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
return ProcessRequest<string>(settings, "daemon.info");
|
||||
}
|
||||
|
||||
public Dictionary<string, object> GetConfig(DelugeSettings settings)
|
||||
@@ -101,6 +90,13 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
return GetTorrents(response);
|
||||
}
|
||||
|
||||
public string[] GetMethods(DelugeSettings settings)
|
||||
{
|
||||
var response = ProcessRequest<string[]>(settings, "system.listMethods");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public string AddTorrentFromMagnet(string magnetLink, DelugeSettings settings)
|
||||
{
|
||||
dynamic options = new ExpandoObject();
|
||||
@@ -159,20 +155,6 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
ProcessRequest<object>(settings, "core.queue_top", (object)new string[] { hash });
|
||||
}
|
||||
|
||||
public string[] GetAvailablePlugins(DelugeSettings settings)
|
||||
{
|
||||
var response = ProcessRequest<string[]>(settings, "core.get_available_plugins");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public string[] GetEnabledPlugins(DelugeSettings settings)
|
||||
{
|
||||
var response = ProcessRequest<string[]>(settings, "core.get_enabled_plugins");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public string[] GetAvailableLabels(DelugeSettings settings)
|
||||
{
|
||||
var response = ProcessRequest<string[]>(settings, "label.get_labels");
|
||||
@@ -223,6 +205,12 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
ProcessRequest<object>(settings, "label.set_torrent", hash, label);
|
||||
}
|
||||
|
||||
public void ReconnectToDaemon(DelugeSettings settings)
|
||||
{
|
||||
ProcessRequest<string>(settings, "web.disconnect");
|
||||
ConnectDaemon(BuildRequest(settings));
|
||||
}
|
||||
|
||||
private JsonRpcRequestBuilder BuildRequest(DelugeSettings settings)
|
||||
{
|
||||
var url = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase);
|
||||
|
||||
@@ -278,20 +278,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd
|
||||
status.OutputRootFolders = new List<OsPath> { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, category.FullPath) };
|
||||
}
|
||||
|
||||
if (config.Misc.history_retention.IsNullOrWhiteSpace())
|
||||
{
|
||||
status.RemovesCompletedDownloads = false;
|
||||
}
|
||||
else if (config.Misc.history_retention.EndsWith("d"))
|
||||
{
|
||||
int.TryParse(config.Misc.history_retention.AsSpan(0, config.Misc.history_retention.Length - 1),
|
||||
out var daysRetention);
|
||||
status.RemovesCompletedDownloads = daysRetention < 14;
|
||||
}
|
||||
else
|
||||
{
|
||||
status.RemovesCompletedDownloads = config.Misc.history_retention != "0";
|
||||
}
|
||||
status.RemovesCompletedDownloads = RemovesCompletedDownloads(config);
|
||||
|
||||
return status;
|
||||
}
|
||||
@@ -548,6 +535,44 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd
|
||||
return categories.Contains(category);
|
||||
}
|
||||
|
||||
private bool RemovesCompletedDownloads(SabnzbdConfig config)
|
||||
{
|
||||
var retention = config.Misc.history_retention;
|
||||
var option = config.Misc.history_retention_option;
|
||||
var number = config.Misc.history_retention_number;
|
||||
|
||||
switch (option)
|
||||
{
|
||||
case "all":
|
||||
return false;
|
||||
case "number-archive":
|
||||
case "number-delete":
|
||||
return true;
|
||||
case "days-archive":
|
||||
case "days-delete":
|
||||
return number < 14;
|
||||
case "all-archive":
|
||||
case "all-delete":
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: Remove these checks once support for SABnzbd < 4.3 is removed
|
||||
|
||||
if (retention.IsNullOrWhiteSpace())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (retention.EndsWith("d"))
|
||||
{
|
||||
int.TryParse(config.Misc.history_retention.AsSpan(0, config.Misc.history_retention.Length - 1),
|
||||
out var daysRetention);
|
||||
return daysRetention < 14;
|
||||
}
|
||||
|
||||
return retention != "0";
|
||||
}
|
||||
|
||||
private bool ValidatePath(DownloadClientItem downloadClientItem)
|
||||
{
|
||||
var downloadItemOutputPath = downloadClientItem.OutputPath;
|
||||
|
||||
@@ -32,6 +32,8 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd
|
||||
public bool enable_date_sorting { get; set; }
|
||||
public bool pre_check { get; set; }
|
||||
public string history_retention { get; set; }
|
||||
public string history_retention_option { get; set; }
|
||||
public int history_retention_number { get; set; }
|
||||
}
|
||||
|
||||
public class SabnzbdCategory
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentValidation.Results;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Core.Blocklisting;
|
||||
using NzbDrone.Core.Configuration;
|
||||
@@ -15,6 +17,9 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
public class Transmission : TransmissionBase
|
||||
{
|
||||
public override string Name => "Transmission";
|
||||
public override bool SupportsLabels => HasClientVersion(4, 0);
|
||||
|
||||
public Transmission(ITransmissionProxy proxy,
|
||||
ITorrentFileInfoReader torrentFileInfoReader,
|
||||
IHttpClient httpClient,
|
||||
@@ -28,9 +33,48 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
}
|
||||
|
||||
public override void MarkItemAsImported(DownloadClientItem downloadClientItem)
|
||||
{
|
||||
if (!SupportsLabels)
|
||||
{
|
||||
throw new NotSupportedException($"{Name} does not support marking items as imported");
|
||||
}
|
||||
|
||||
// set post-import category
|
||||
if (Settings.MovieImportedCategory.IsNotNullOrWhiteSpace() &&
|
||||
Settings.MovieImportedCategory != Settings.MovieCategory)
|
||||
{
|
||||
var hash = downloadClientItem.DownloadId.ToLowerInvariant();
|
||||
var torrent = _proxy.GetTorrents(new[] { hash }, Settings).FirstOrDefault();
|
||||
|
||||
if (torrent == null)
|
||||
{
|
||||
_logger.Warn("Could not find torrent with hash \"{0}\" in Transmission.", hash);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var labels = torrent.Labels.ToHashSet(StringComparer.InvariantCultureIgnoreCase);
|
||||
labels.Add(Settings.MovieImportedCategory);
|
||||
|
||||
if (Settings.MovieCategory.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
labels.Remove(Settings.MovieCategory);
|
||||
}
|
||||
|
||||
_proxy.SetTorrentLabels(hash, labels, Settings);
|
||||
}
|
||||
catch (DownloadClientException ex)
|
||||
{
|
||||
_logger.Warn(ex, "Failed to set post-import torrent label \"{0}\" for {1} in Transmission.", Settings.MovieImportedCategory, downloadClientItem.Title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override ValidationFailure ValidateVersion()
|
||||
{
|
||||
var versionString = _proxy.GetClientVersion(Settings);
|
||||
var versionString = _proxy.GetClientVersion(Settings, true);
|
||||
|
||||
_logger.Debug("Transmission version information: {0}", versionString);
|
||||
|
||||
@@ -44,7 +88,5 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public override string Name => "Transmission";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentValidation.Results;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Disk;
|
||||
@@ -18,6 +19,8 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
public abstract class TransmissionBase : TorrentClientBase<TransmissionSettings>
|
||||
{
|
||||
public abstract bool SupportsLabels { get; }
|
||||
|
||||
protected readonly ITransmissionProxy _proxy;
|
||||
|
||||
public TransmissionBase(ITransmissionProxy proxy,
|
||||
@@ -37,7 +40,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
public override IEnumerable<DownloadClientItem> GetItems()
|
||||
{
|
||||
var configFunc = new Lazy<TransmissionConfig>(() => _proxy.GetConfig(Settings));
|
||||
var torrents = _proxy.GetTorrents(Settings);
|
||||
var torrents = _proxy.GetTorrents(null, Settings);
|
||||
|
||||
var items = new List<DownloadClientItem>();
|
||||
|
||||
@@ -45,36 +48,45 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
var outputPath = new OsPath(torrent.DownloadDir);
|
||||
|
||||
if (Settings.MovieDirectory.IsNotNullOrWhiteSpace())
|
||||
if (Settings.MovieCategory.IsNotNullOrWhiteSpace() && SupportsLabels && torrent.Labels is { Count: > 0 })
|
||||
{
|
||||
if (!new OsPath(Settings.MovieDirectory).Contains(outputPath))
|
||||
if (!torrent.Labels.Contains(Settings.MovieCategory, StringComparer.InvariantCultureIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (Settings.MovieCategory.IsNotNullOrWhiteSpace())
|
||||
else
|
||||
{
|
||||
var directories = outputPath.FullPath.Split('\\', '/');
|
||||
if (!directories.Contains(Settings.MovieCategory))
|
||||
if (Settings.MovieDirectory.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
continue;
|
||||
if (!new OsPath(Settings.MovieDirectory).Contains(outputPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (Settings.MovieCategory.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
var directories = outputPath.FullPath.Split('\\', '/');
|
||||
if (!directories.Contains(Settings.MovieCategory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, outputPath);
|
||||
|
||||
var item = new DownloadClientItem();
|
||||
item.DownloadId = torrent.HashString.ToUpper();
|
||||
item.Category = Settings.MovieCategory;
|
||||
item.Title = torrent.Name;
|
||||
|
||||
item.DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this, false);
|
||||
|
||||
item.OutputPath = GetOutputPath(outputPath, torrent);
|
||||
item.TotalSize = torrent.TotalSize;
|
||||
item.RemainingSize = torrent.LeftUntilDone;
|
||||
item.SeedRatio = torrent.DownloadedEver <= 0 ? 0 :
|
||||
(double)torrent.UploadedEver / torrent.DownloadedEver;
|
||||
var item = new DownloadClientItem
|
||||
{
|
||||
DownloadId = torrent.HashString.ToUpper(),
|
||||
Category = Settings.MovieCategory,
|
||||
Title = torrent.Name,
|
||||
DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this, Settings.MovieImportedCategory.IsNotNullOrWhiteSpace() && SupportsLabels),
|
||||
OutputPath = GetOutputPath(outputPath, torrent),
|
||||
TotalSize = torrent.TotalSize,
|
||||
RemainingSize = torrent.LeftUntilDone,
|
||||
SeedRatio = torrent.DownloadedEver <= 0 ? 0 : (double)torrent.UploadedEver / torrent.DownloadedEver
|
||||
};
|
||||
|
||||
if (torrent.Eta >= 0)
|
||||
{
|
||||
@@ -300,7 +312,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
try
|
||||
{
|
||||
_proxy.GetTorrents(Settings);
|
||||
_proxy.GetTorrents(null, Settings);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -310,5 +322,15 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected bool HasClientVersion(int major, int minor)
|
||||
{
|
||||
var rawVersion = _proxy.GetClientVersion(Settings);
|
||||
|
||||
var versionResult = Regex.Match(rawVersion, @"(?<!\(|(\d|\.)+)(\d|\.)+(?!\)|(\d|\.)+)").Value;
|
||||
var clientVersion = Version.Parse(versionResult);
|
||||
|
||||
return clientVersion >= new Version(major, minor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NLog;
|
||||
@@ -12,15 +15,16 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
public interface ITransmissionProxy
|
||||
{
|
||||
List<TransmissionTorrent> GetTorrents(TransmissionSettings settings);
|
||||
IReadOnlyCollection<TransmissionTorrent> GetTorrents(IReadOnlyCollection<string> hashStrings, TransmissionSettings settings);
|
||||
void AddTorrentFromUrl(string torrentUrl, string downloadDirectory, TransmissionSettings settings);
|
||||
void AddTorrentFromData(byte[] torrentData, string downloadDirectory, TransmissionSettings settings);
|
||||
void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, TransmissionSettings settings);
|
||||
TransmissionConfig GetConfig(TransmissionSettings settings);
|
||||
string GetProtocolVersion(TransmissionSettings settings);
|
||||
string GetClientVersion(TransmissionSettings settings);
|
||||
string GetClientVersion(TransmissionSettings settings, bool force = false);
|
||||
void RemoveTorrent(string hash, bool removeData, TransmissionSettings settings);
|
||||
void MoveTorrentToTopInQueue(string hashString, TransmissionSettings settings);
|
||||
void SetTorrentLabels(string hash, IEnumerable<string> labels, TransmissionSettings settings);
|
||||
}
|
||||
|
||||
public class TransmissionProxy : ITransmissionProxy
|
||||
@@ -28,50 +32,66 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
private readonly IHttpClient _httpClient;
|
||||
private readonly Logger _logger;
|
||||
|
||||
private ICached<string> _authSessionIDCache;
|
||||
private readonly ICached<string> _authSessionIdCache;
|
||||
private readonly ICached<string> _versionCache;
|
||||
|
||||
public TransmissionProxy(ICacheManager cacheManager, IHttpClient httpClient, Logger logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
|
||||
_authSessionIDCache = cacheManager.GetCache<string>(GetType(), "authSessionID");
|
||||
_authSessionIdCache = cacheManager.GetCache<string>(GetType(), "authSessionID");
|
||||
_versionCache = cacheManager.GetCache<string>(GetType(), "versions");
|
||||
}
|
||||
|
||||
public List<TransmissionTorrent> GetTorrents(TransmissionSettings settings)
|
||||
public IReadOnlyCollection<TransmissionTorrent> GetTorrents(IReadOnlyCollection<string> hashStrings, TransmissionSettings settings)
|
||||
{
|
||||
var result = GetTorrentStatus(settings);
|
||||
var result = GetTorrentStatus(hashStrings, settings);
|
||||
|
||||
var torrents = ((JArray)result.Arguments["torrents"]).ToObject<List<TransmissionTorrent>>();
|
||||
var torrents = ((JArray)result.Arguments["torrents"]).ToObject<ReadOnlyCollection<TransmissionTorrent>>();
|
||||
|
||||
return torrents;
|
||||
}
|
||||
|
||||
public void AddTorrentFromUrl(string torrentUrl, string downloadDirectory, TransmissionSettings settings)
|
||||
{
|
||||
var arguments = new Dictionary<string, object>();
|
||||
arguments.Add("filename", torrentUrl);
|
||||
arguments.Add("paused", settings.AddPaused);
|
||||
var arguments = new Dictionary<string, object>
|
||||
{
|
||||
{ "filename", torrentUrl },
|
||||
{ "paused", settings.AddPaused }
|
||||
};
|
||||
|
||||
if (!downloadDirectory.IsNullOrWhiteSpace())
|
||||
{
|
||||
arguments.Add("download-dir", downloadDirectory);
|
||||
}
|
||||
|
||||
if (settings.MovieCategory.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
arguments.Add("labels", new List<string> { settings.MovieCategory });
|
||||
}
|
||||
|
||||
ProcessRequest("torrent-add", arguments, settings);
|
||||
}
|
||||
|
||||
public void AddTorrentFromData(byte[] torrentData, string downloadDirectory, TransmissionSettings settings)
|
||||
{
|
||||
var arguments = new Dictionary<string, object>();
|
||||
arguments.Add("metainfo", Convert.ToBase64String(torrentData));
|
||||
arguments.Add("paused", settings.AddPaused);
|
||||
var arguments = new Dictionary<string, object>
|
||||
{
|
||||
{ "metainfo", Convert.ToBase64String(torrentData) },
|
||||
{ "paused", settings.AddPaused }
|
||||
};
|
||||
|
||||
if (!downloadDirectory.IsNullOrWhiteSpace())
|
||||
{
|
||||
arguments.Add("download-dir", downloadDirectory);
|
||||
}
|
||||
|
||||
if (settings.MovieCategory.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
arguments.Add("labels", new List<string> { settings.MovieCategory });
|
||||
}
|
||||
|
||||
ProcessRequest("torrent-add", arguments, settings);
|
||||
}
|
||||
|
||||
@@ -82,8 +102,10 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
return;
|
||||
}
|
||||
|
||||
var arguments = new Dictionary<string, object>();
|
||||
arguments.Add("ids", new[] { hash });
|
||||
var arguments = new Dictionary<string, object>
|
||||
{
|
||||
{ "ids", new List<string> { hash } }
|
||||
};
|
||||
|
||||
if (seedConfiguration.Ratio != null)
|
||||
{
|
||||
@@ -97,6 +119,12 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
arguments.Add("seedIdleMode", 1);
|
||||
}
|
||||
|
||||
// Avoid extraneous request if no limits are to be set
|
||||
if (arguments.All(arg => arg.Key == "ids"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ProcessRequest("torrent-set", arguments, settings);
|
||||
}
|
||||
|
||||
@@ -107,11 +135,16 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
return config.RpcVersion;
|
||||
}
|
||||
|
||||
public string GetClientVersion(TransmissionSettings settings)
|
||||
public string GetClientVersion(TransmissionSettings settings, bool force = false)
|
||||
{
|
||||
var config = GetConfig(settings);
|
||||
var cacheKey = $"version:{$"{GetBaseUrl(settings)}:{settings.Password}".SHA256Hash()}";
|
||||
|
||||
return config.Version;
|
||||
if (force)
|
||||
{
|
||||
_versionCache.Remove(cacheKey);
|
||||
}
|
||||
|
||||
return _versionCache.Get(cacheKey, () => GetConfig(settings).Version, TimeSpan.FromHours(6));
|
||||
}
|
||||
|
||||
public TransmissionConfig GetConfig(TransmissionSettings settings)
|
||||
@@ -124,21 +157,36 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
|
||||
public void RemoveTorrent(string hashString, bool removeData, TransmissionSettings settings)
|
||||
{
|
||||
var arguments = new Dictionary<string, object>();
|
||||
arguments.Add("ids", new string[] { hashString });
|
||||
arguments.Add("delete-local-data", removeData);
|
||||
var arguments = new Dictionary<string, object>
|
||||
{
|
||||
{ "ids", new List<string> { hashString } },
|
||||
{ "delete-local-data", removeData }
|
||||
};
|
||||
|
||||
ProcessRequest("torrent-remove", arguments, settings);
|
||||
}
|
||||
|
||||
public void MoveTorrentToTopInQueue(string hashString, TransmissionSettings settings)
|
||||
{
|
||||
var arguments = new Dictionary<string, object>();
|
||||
arguments.Add("ids", new string[] { hashString });
|
||||
var arguments = new Dictionary<string, object>
|
||||
{
|
||||
{ "ids", new List<string> { hashString } }
|
||||
};
|
||||
|
||||
ProcessRequest("queue-move-top", arguments, settings);
|
||||
}
|
||||
|
||||
public void SetTorrentLabels(string hash, IEnumerable<string> labels, TransmissionSettings settings)
|
||||
{
|
||||
var arguments = new Dictionary<string, object>
|
||||
{
|
||||
{ "ids", new List<string> { hash } },
|
||||
{ "labels", labels.ToImmutableHashSet() }
|
||||
};
|
||||
|
||||
ProcessRequest("torrent-set", arguments, settings);
|
||||
}
|
||||
|
||||
private TransmissionResponse GetSessionVariables(TransmissionSettings settings)
|
||||
{
|
||||
// Retrieve transmission information such as the default download directory, bandwidth throttling and seed ratio.
|
||||
@@ -151,14 +199,9 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
return ProcessRequest("session-stats", null, settings);
|
||||
}
|
||||
|
||||
private TransmissionResponse GetTorrentStatus(TransmissionSettings settings)
|
||||
{
|
||||
return GetTorrentStatus(null, settings);
|
||||
}
|
||||
|
||||
private TransmissionResponse GetTorrentStatus(IEnumerable<string> hashStrings, TransmissionSettings settings)
|
||||
{
|
||||
var fields = new string[]
|
||||
var fields = new List<string>
|
||||
{
|
||||
"id",
|
||||
"hashString", // Unique torrent ID. Use this instead of the client id?
|
||||
@@ -179,11 +222,14 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
"seedIdleLimit",
|
||||
"seedIdleMode",
|
||||
"fileCount",
|
||||
"file-count"
|
||||
"file-count",
|
||||
"labels"
|
||||
};
|
||||
|
||||
var arguments = new Dictionary<string, object>();
|
||||
arguments.Add("fields", fields);
|
||||
var arguments = new Dictionary<string, object>
|
||||
{
|
||||
{ "fields", fields }
|
||||
};
|
||||
|
||||
if (hashStrings != null)
|
||||
{
|
||||
@@ -195,9 +241,14 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
return result;
|
||||
}
|
||||
|
||||
private string GetBaseUrl(TransmissionSettings settings)
|
||||
{
|
||||
return HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase);
|
||||
}
|
||||
|
||||
private HttpRequestBuilder BuildRequest(TransmissionSettings settings)
|
||||
{
|
||||
var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase)
|
||||
var requestBuilder = new HttpRequestBuilder(GetBaseUrl(settings))
|
||||
.Resource("rpc")
|
||||
.Accept(HttpAccept.Json);
|
||||
|
||||
@@ -212,11 +263,11 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.Password);
|
||||
|
||||
var sessionId = _authSessionIDCache.Find(authKey);
|
||||
var sessionId = _authSessionIdCache.Find(authKey);
|
||||
|
||||
if (sessionId == null || reauthenticate)
|
||||
{
|
||||
_authSessionIDCache.Remove(authKey);
|
||||
_authSessionIdCache.Remove(authKey);
|
||||
|
||||
var authLoginRequest = BuildRequest(settings).Build();
|
||||
authLoginRequest.SuppressHttpError = true;
|
||||
@@ -244,7 +295,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
|
||||
_logger.Debug("Transmission authentication succeeded.");
|
||||
|
||||
_authSessionIDCache.Set(authKey, sessionId);
|
||||
_authSessionIdCache.Set(authKey, sessionId);
|
||||
}
|
||||
|
||||
requestBuilder.SetHeader("X-Transmission-Session-Id", sessionId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentValidation;
|
||||
using NzbDrone.Common.Extensions;
|
||||
@@ -27,11 +28,25 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
private static readonly TransmissionSettingsValidator Validator = new ();
|
||||
|
||||
// This constructor is used when creating a new instance, such as the user adding a new Transmission client.
|
||||
public TransmissionSettings()
|
||||
{
|
||||
Host = "localhost";
|
||||
Port = 9091;
|
||||
UrlBase = "/transmission/";
|
||||
MovieCategory = "radarr";
|
||||
}
|
||||
|
||||
// TODO: Remove this in v6
|
||||
// This constructor is used when deserializing from JSON, it will set the
|
||||
// category to the deserialized value, defaulting to null.
|
||||
[JsonConstructor]
|
||||
public TransmissionSettings(string movieCategory = null)
|
||||
{
|
||||
Host = "localhost";
|
||||
Port = 9091;
|
||||
UrlBase = "/transmission/";
|
||||
MovieCategory = movieCategory;
|
||||
}
|
||||
|
||||
[FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)]
|
||||
@@ -59,16 +74,19 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
[FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsCategorySubFolderHelpText")]
|
||||
public string MovieCategory { get; set; }
|
||||
|
||||
[FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientTransmissionSettingsDirectoryHelpText")]
|
||||
[FieldDefinition(7, Label = "PostImportCategory", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsPostImportCategoryHelpText")]
|
||||
public string MovieImportedCategory { get; set; }
|
||||
|
||||
[FieldDefinition(8, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientTransmissionSettingsDirectoryHelpText")]
|
||||
public string MovieDirectory { get; set; }
|
||||
|
||||
[FieldDefinition(8, Label = "DownloadClientSettingsRecentPriority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "DownloadClientSettingsRecentPriorityMovieHelpText")]
|
||||
[FieldDefinition(9, Label = "DownloadClientSettingsRecentPriority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "DownloadClientSettingsRecentPriorityMovieHelpText")]
|
||||
public int RecentMoviePriority { get; set; }
|
||||
|
||||
[FieldDefinition(9, Label = "DownloadClientSettingsOlderPriority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "DownloadClientSettingsOlderPriorityMovieHelpText")]
|
||||
[FieldDefinition(10, Label = "DownloadClientSettingsOlderPriority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "DownloadClientSettingsOlderPriorityMovieHelpText")]
|
||||
public int OlderMoviePriority { get; set; }
|
||||
|
||||
[FieldDefinition(10, Label = "DownloadClientSettingsAddPaused", Type = FieldType.Checkbox)]
|
||||
[FieldDefinition(11, Label = "DownloadClientSettingsAddPaused", Type = FieldType.Checkbox)]
|
||||
public bool AddPaused { get; set; }
|
||||
|
||||
public override NzbDroneValidationResult Validate()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
@@ -11,6 +13,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
public long TotalSize { get; set; }
|
||||
public long LeftUntilDone { get; set; }
|
||||
public bool IsFinished { get; set; }
|
||||
public IReadOnlyCollection<string> Labels { get; set; } = Array.Empty<string>();
|
||||
public long Eta { get; set; }
|
||||
public TransmissionTorrentStatus Status { get; set; }
|
||||
public long SecondsDownloading { get; set; }
|
||||
|
||||
@@ -15,6 +15,9 @@ namespace NzbDrone.Core.Download.Clients.Vuze
|
||||
{
|
||||
private const int MINIMUM_SUPPORTED_PROTOCOL_VERSION = 14;
|
||||
|
||||
public override string Name => "Vuze";
|
||||
public override bool SupportsLabels => false;
|
||||
|
||||
public Vuze(ITransmissionProxy proxy,
|
||||
ITorrentFileInfoReader torrentFileInfoReader,
|
||||
IHttpClient httpClient,
|
||||
@@ -67,7 +70,5 @@ namespace NzbDrone.Core.Download.Clients.Vuze
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public override string Name => "Vuze";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,11 +311,11 @@ namespace NzbDrone.Core.Download.Pending
|
||||
ect = ect.AddMinutes(_configService.RssSyncInterval);
|
||||
}
|
||||
|
||||
var timeleft = ect.Subtract(DateTime.UtcNow);
|
||||
var timeLeft = ect.Subtract(DateTime.UtcNow);
|
||||
|
||||
if (timeleft.TotalSeconds < 0)
|
||||
if (timeLeft.TotalSeconds < 0)
|
||||
{
|
||||
timeleft = TimeSpan.Zero;
|
||||
timeLeft = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
string downloadClientName = null;
|
||||
@@ -336,9 +336,9 @@ namespace NzbDrone.Core.Download.Pending
|
||||
Languages = pendingRelease.RemoteMovie.Languages,
|
||||
Title = pendingRelease.Title,
|
||||
Size = pendingRelease.RemoteMovie.Release.Size,
|
||||
Sizeleft = pendingRelease.RemoteMovie.Release.Size,
|
||||
SizeLeft = pendingRelease.RemoteMovie.Release.Size,
|
||||
RemoteMovie = pendingRelease.RemoteMovie,
|
||||
Timeleft = timeleft,
|
||||
TimeLeft = timeLeft,
|
||||
EstimatedCompletionTime = ect,
|
||||
Added = pendingRelease.Added,
|
||||
Status = Enum.TryParse(pendingRelease.Reason.ToString(), out QueueStatus outValue) ? outValue : QueueStatus.Unknown,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
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.Movies;
|
||||
using NzbDrone.Core.ThingiProvider;
|
||||
|
||||
namespace NzbDrone.Core.Extras.Metadata.Consumers.Kometa
|
||||
{
|
||||
@@ -15,13 +14,15 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Kometa
|
||||
{
|
||||
private static readonly Regex MovieImagesRegex = new (@"^(?:poster|background)\.(?:png|jpe?g)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private readonly IMapCoversToLocal _mediaCoverService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
|
||||
public override string Name => "Kometa";
|
||||
|
||||
public KometaMetadata(IMapCoversToLocal mediaCoverService)
|
||||
public override ProviderMessage Message => new (_localizationService.GetLocalizedString("MetadataKometaDeprecated"), ProviderMessageType.Warning);
|
||||
|
||||
public KometaMetadata(ILocalizationService localizationService)
|
||||
{
|
||||
_mediaCoverService = mediaCoverService;
|
||||
_localizationService = localizationService;
|
||||
}
|
||||
|
||||
public override MetadataFile FindMetadataFile(Movie movie, string path)
|
||||
@@ -56,31 +57,7 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Kometa
|
||||
|
||||
public override List<ImageFileResult> MovieImages(Movie movie)
|
||||
{
|
||||
if (!Settings.MovieImages)
|
||||
{
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
return ProcessMovieImages(movie).ToList();
|
||||
}
|
||||
|
||||
private IEnumerable<ImageFileResult> ProcessMovieImages(Movie movie)
|
||||
{
|
||||
foreach (var image in movie.MovieMetadata.Value.Images.Where(i => i.CoverType is MediaCoverTypes.Poster or MediaCoverTypes.Fanart))
|
||||
{
|
||||
var source = _mediaCoverService.GetCoverPath(movie.Id, image.CoverType);
|
||||
|
||||
var filename = image.CoverType switch
|
||||
{
|
||||
MediaCoverTypes.Poster => "poster",
|
||||
MediaCoverTypes.Fanart => "background",
|
||||
_ => throw new ArgumentOutOfRangeException($"{image.CoverType} is not supported")
|
||||
};
|
||||
|
||||
var destination = filename + Path.GetExtension(source);
|
||||
|
||||
yield return new ImageFileResult(destination, source);
|
||||
}
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Kometa
|
||||
|
||||
public KometaMetadataSettings()
|
||||
{
|
||||
MovieImages = true;
|
||||
Deprecated = true;
|
||||
}
|
||||
|
||||
[FieldDefinition(0, Label = "MetadataSettingsMovieImages", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "poster.jpg, background.jpg")]
|
||||
public bool MovieImages { get; set; }
|
||||
[FieldDefinition(0, Label = "MetadataKometaDeprecatedSetting", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, Hidden = HiddenType.Hidden)]
|
||||
public bool Deprecated { get; set; }
|
||||
|
||||
public NzbDroneValidationResult Validate()
|
||||
{
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -919,6 +919,8 @@
|
||||
"Menu": "Menu",
|
||||
"Message": "Message",
|
||||
"Metadata": "Metadata",
|
||||
"MetadataKometaDeprecated": "Kometa files will no longer be created, support will be removed completely in v6",
|
||||
"MetadataKometaDeprecatedSetting": "Deprecated",
|
||||
"MetadataLoadError": "Unable to load Metadata",
|
||||
"MetadataSettings": "Metadata Settings",
|
||||
"MetadataSettingsMovieImages": "Movie Images",
|
||||
|
||||
@@ -518,7 +518,7 @@
|
||||
"AutoRedownloadFailedHelpText": "自动搜索并尝试下载不同的发布资源",
|
||||
"AutomaticSearch": "自动搜索",
|
||||
"ICalShowAsAllDayEventsHelpText": "事件将以全天事件的形式显示在日历中",
|
||||
"AppDataLocationHealthCheckMessage": "无法更新,以防止在更新时删除 AppData",
|
||||
"AppDataLocationHealthCheckMessage": "为防止在更新时删除 AppData,更新将无法进行",
|
||||
"Announced": "已公布",
|
||||
"Analytics": "分析",
|
||||
"AllMoviesInPathHaveBeenImported": "在 {path} 中的所有电影已被导入",
|
||||
@@ -584,7 +584,7 @@
|
||||
"InstallLatest": "安装最新版",
|
||||
"IndexerStatusCheckSingleClientMessage": "下列索引器因错误不可用:{indexerNames}",
|
||||
"IndexerStatusCheckAllClientMessage": "所有索引器都因错误不可用",
|
||||
"IndexerSearchCheckNoInteractiveMessage": "没有索引器开启手动搜索,{appName} 将不会提供任何手动搜索结果",
|
||||
"IndexerSearchCheckNoInteractiveMessage": "没有启用交互式搜索的索引器,{appName}将不提供任何交互式搜索结果",
|
||||
"IndexerRssHealthCheckNoIndexers": "没有索引器开启 RSS 同步,{appName} 将不会自动抓取新版本",
|
||||
"IndexerLongTermStatusCheckSingleClientMessage": "由于故障超过6小时,下列索引器已不可用:{indexerNames}",
|
||||
"IndexerLongTermStatusCheckAllClientMessage": "由于故障超过6小时,所有索引器均不可用",
|
||||
@@ -914,7 +914,7 @@
|
||||
"MIA": "MIA",
|
||||
"MegabytesPerMinute": "每分钟MB",
|
||||
"Mechanism": "机制",
|
||||
"MappedNetworkDrivesWindowsService": "映射的网络驱动器在作为Windows服务运行时不可用。请参阅常见问题解答了解更多信息",
|
||||
"MappedNetworkDrivesWindowsService": "作为 Windows 服务运行时,映射的网络驱动器不可用,请参阅 [FAQ]({url}) 获取更多信息。",
|
||||
"MaintenanceRelease": "维护版本:修复错误及其他改进,参见Github提交 查看更多详情",
|
||||
"LogOnly": "只有日志",
|
||||
"LogLevelTraceHelpTextWarning": "追踪日志只应该暂时启用",
|
||||
@@ -1712,7 +1712,7 @@
|
||||
"Logout": "注销",
|
||||
"Recommendation": "推荐",
|
||||
"NotificationsSynologyValidationInvalidOs": "必须是 Synology(群辉)设备",
|
||||
"NoMovieReleaseDatesAvailable": "TMDb 上未找到此电影的发布日期。",
|
||||
"NoMovieReleaseDatesAvailable": "[TMDb]({url}) 上未找到此电影的发布日期。",
|
||||
"NotificationsSlackSettingsChannelHelpText": "覆盖传入 Webhook 的默认渠道(#other-channel)",
|
||||
"NotificationsTelegramSettingsIncludeAppName": "标题中包含 {appName}",
|
||||
"UnableToImportAutomatically": "无法自动导入",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"About": "关于",
|
||||
"Add": "添加",
|
||||
"About": "关于",
|
||||
"Always": "总是",
|
||||
"Analytics": "分析",
|
||||
"Username": "用户名"
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.Movies;
|
||||
|
||||
@@ -20,6 +21,7 @@ namespace NzbDrone.Core.Notifications.Webhook
|
||||
public List<string> Genres { get; set; }
|
||||
public List<WebhookImage> Images { get; set; }
|
||||
public List<string> Tags { get; set; }
|
||||
public Language OriginalLanguage { get; set; }
|
||||
|
||||
public WebhookMovie()
|
||||
{
|
||||
@@ -38,6 +40,7 @@ namespace NzbDrone.Core.Notifications.Webhook
|
||||
Genres = movie.MovieMetadata.Value.Genres;
|
||||
Images = movie.MovieMetadata.Value.Images.Select(i => new WebhookImage(i)).ToList();
|
||||
Tags = tags;
|
||||
OriginalLanguage = movie.MovieMetadata.Value.OriginalLanguage;
|
||||
}
|
||||
|
||||
public WebhookMovie(Movie movie, MovieFile movieFile, List<string> tags)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
|
||||
namespace NzbDrone.Core.Notifications.Webhook
|
||||
@@ -21,6 +23,7 @@ namespace NzbDrone.Core.Notifications.Webhook
|
||||
IndexerFlags = movieFile.IndexerFlags.ToString();
|
||||
Size = movieFile.Size;
|
||||
DateAdded = movieFile.DateAdded;
|
||||
Languages = movieFile.Languages;
|
||||
|
||||
if (movieFile.MediaInfo != null)
|
||||
{
|
||||
@@ -38,6 +41,7 @@ namespace NzbDrone.Core.Notifications.Webhook
|
||||
public string IndexerFlags { get; set; }
|
||||
public long Size { get; set; }
|
||||
public DateTime DateAdded { get; set; }
|
||||
public List<Language> Languages { get; set; }
|
||||
public WebhookMovieFileMediaInfo MediaInfo { get; set; }
|
||||
public string SourcePath { get; set; }
|
||||
public string RecycleBinPath { get; set; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Qualities;
|
||||
|
||||
@@ -22,6 +23,7 @@ namespace NzbDrone.Core.Notifications.Webhook
|
||||
Size = remoteMovie.Release.Size;
|
||||
CustomFormats = remoteMovie.CustomFormats?.Select(x => x.Name).ToList();
|
||||
CustomFormatScore = remoteMovie.CustomFormatScore;
|
||||
Languages = remoteMovie.Languages;
|
||||
IndexerFlags = Enum.GetValues(typeof(IndexerFlags)).Cast<IndexerFlags>().Where(r => (remoteMovie.Release.IndexerFlags & r) == r).Select(r => r.ToString()).ToList();
|
||||
}
|
||||
|
||||
@@ -33,6 +35,7 @@ namespace NzbDrone.Core.Notifications.Webhook
|
||||
public long Size { get; set; }
|
||||
public int CustomFormatScore { get; set; }
|
||||
public List<string> CustomFormats { get; set; }
|
||||
public List<Language> Languages { get; set; }
|
||||
public List<string> IndexerFlags { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ namespace NzbDrone.Core.Queue
|
||||
public QualityModel Quality { get; set; }
|
||||
public decimal Size { get; set; }
|
||||
public string Title { get; set; }
|
||||
public decimal Sizeleft { get; set; }
|
||||
public TimeSpan? Timeleft { get; set; }
|
||||
public decimal SizeLeft { get; set; }
|
||||
public TimeSpan? TimeLeft { get; set; }
|
||||
public DateTime? EstimatedCompletionTime { get; set; }
|
||||
public DateTime? Added { get; set; }
|
||||
public QueueStatus Status { get; set; }
|
||||
|
||||
@@ -62,8 +62,8 @@ namespace NzbDrone.Core.Queue
|
||||
Quality = trackedDownload.RemoteMovie?.ParsedMovieInfo.Quality ?? new QualityModel(Quality.Unknown),
|
||||
Title = trackedDownload.DownloadItem.Title,
|
||||
Size = trackedDownload.DownloadItem.TotalSize,
|
||||
Sizeleft = trackedDownload.DownloadItem.RemainingSize,
|
||||
Timeleft = trackedDownload.DownloadItem.RemainingTime,
|
||||
SizeLeft = trackedDownload.DownloadItem.RemainingSize,
|
||||
TimeLeft = trackedDownload.DownloadItem.RemainingTime,
|
||||
Status = Enum.TryParse(trackedDownload.DownloadItem.Status.ToString(), out QueueStatus outValue) ? outValue : QueueStatus.Unknown,
|
||||
TrackedDownloadStatus = trackedDownload.Status,
|
||||
TrackedDownloadState = trackedDownload.State,
|
||||
@@ -82,9 +82,9 @@ namespace NzbDrone.Core.Queue
|
||||
|
||||
queue.Id = HashConverter.GetHashInt31($"trackedDownload-{trackedDownload.DownloadClient}-{trackedDownload.DownloadItem.DownloadId}");
|
||||
|
||||
if (queue.Timeleft.HasValue)
|
||||
if (queue.TimeLeft.HasValue)
|
||||
{
|
||||
queue.EstimatedCompletionTime = DateTime.UtcNow.Add(queue.Timeleft.Value);
|
||||
queue.EstimatedCompletionTime = DateTime.UtcNow.Add(queue.TimeLeft.Value);
|
||||
}
|
||||
|
||||
return queue;
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<PackageReference Include="Diacritical.Net" Version="1.0.4" />
|
||||
<PackageReference Include="Equ" Version="2.3.0" />
|
||||
<PackageReference Include="MailKit" Version="3.6.0" />
|
||||
<PackageReference Include="Npgsql" Version="7.0.8" />
|
||||
<PackageReference Include="Polly" Version="8.4.2" />
|
||||
<PackageReference Include="Npgsql" Version="7.0.9" />
|
||||
<PackageReference Include="Polly" Version="8.5.0" />
|
||||
<PackageReference Include="Servarr.FFMpegCore" Version="4.7.0-26" />
|
||||
<PackageReference Include="Servarr.FFprobe" Version="5.1.4.112" />
|
||||
<PackageReference Include="System.Memory" Version="4.5.5" />
|
||||
@@ -20,9 +20,9 @@
|
||||
<PackageReference Include="Servarr.FluentMigrator.Runner.SQLite" Version="3.3.2.9" />
|
||||
<PackageReference Include="Servarr.FluentMigrator.Runner.Postgres" Version="3.3.2.9" />
|
||||
<PackageReference Include="FluentValidation" Version="9.5.4" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="NLog" Version="5.3.3" />
|
||||
<PackageReference Include="NLog" Version="5.3.4" />
|
||||
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.115.5-18" />
|
||||
<PackageReference Include="MonoTorrent" Version="2.0.7" />
|
||||
<PackageReference Include="System.Text.Json" Version="6.0.10" />
|
||||
|
||||
73
src/NzbDrone.Integration.Test/ApiTests/QueueFixture.cs
Normal file
73
src/NzbDrone.Integration.Test/ApiTests/QueueFixture.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Messaging.Commands;
|
||||
using NzbDrone.Integration.Test.Client;
|
||||
using Radarr.Api.V3.Queue;
|
||||
using Radarr.Http;
|
||||
|
||||
namespace NzbDrone.Integration.Test.ApiTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class QueueFixture : IntegrationTest
|
||||
{
|
||||
private PagingResource<QueueResource> GetFirstPage()
|
||||
{
|
||||
var request = Queue.BuildRequest();
|
||||
request.AddParameter("includeUnknownMovieItems", true);
|
||||
|
||||
return Queue.Get<PagingResource<QueueResource>>(request);
|
||||
}
|
||||
|
||||
private void RefreshQueue()
|
||||
{
|
||||
var command = Commands.Post(new SimpleCommandResource { Name = "RefreshMonitoredDownloads" });
|
||||
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
var updatedCommand = Commands.Get(command.Id);
|
||||
|
||||
if (updatedCommand.Status == CommandStatus.Completed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Thread.Sleep(1000);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Order(0)]
|
||||
public void ensure_queue_is_empty_when_download_client_is_configured()
|
||||
{
|
||||
EnsureNoDownloadClient();
|
||||
EnsureDownloadClient();
|
||||
|
||||
var queue = GetFirstPage();
|
||||
|
||||
queue.TotalRecords.Should().Be(0);
|
||||
queue.Records.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Order(1)]
|
||||
public void ensure_queue_is_not_empty()
|
||||
{
|
||||
EnsureNoDownloadClient();
|
||||
|
||||
var client = EnsureDownloadClient();
|
||||
var directory = client.Fields.First(v => v.Name == "watchFolder").Value as string;
|
||||
|
||||
File.WriteAllText(Path.Combine(directory, "Movie.Title.2024.mkv"), "Test Download");
|
||||
RefreshQueue();
|
||||
|
||||
var queue = GetFirstPage();
|
||||
|
||||
queue.TotalRecords.Should().Be(1);
|
||||
queue.Records.Should().NotBeEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/NzbDrone.Integration.Test/Client/QueueClient.cs
Normal file
13
src/NzbDrone.Integration.Test/Client/QueueClient.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Radarr.Api.V3.Queue;
|
||||
using RestSharp;
|
||||
|
||||
namespace NzbDrone.Integration.Test.Client
|
||||
{
|
||||
public class QueueClient : ClientBase<QueueResource>
|
||||
{
|
||||
public QueueClient(IRestClient restClient, string apiKey)
|
||||
: base(restClient, apiKey)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ namespace NzbDrone.Integration.Test
|
||||
public ClientBase<TagResource> Tags;
|
||||
public ClientBase<MovieResource> WantedMissing;
|
||||
public ClientBase<MovieResource> WantedCutoffUnmet;
|
||||
public QueueClient Queue;
|
||||
|
||||
private List<SignalRMessage> _signalRReceived;
|
||||
|
||||
@@ -115,6 +116,7 @@ namespace NzbDrone.Integration.Test
|
||||
Tags = new ClientBase<TagResource>(RestClient, ApiKey);
|
||||
WantedMissing = new ClientBase<MovieResource>(RestClient, ApiKey, "wanted/missing");
|
||||
WantedCutoffUnmet = new ClientBase<MovieResource>(RestClient, ApiKey, "wanted/cutoff");
|
||||
Queue = new QueueClient(RestClient, ApiKey);
|
||||
}
|
||||
|
||||
[OneTimeTearDown]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
||||
<PackageReference Include="FluentValidation" Version="9.5.4" />
|
||||
<PackageReference Include="Moq" Version="4.16.1" />
|
||||
<PackageReference Include="NLog" Version="5.3.3" />
|
||||
<PackageReference Include="NLog" Version="5.3.4" />
|
||||
<PackageReference Include="NUnit" Version="3.14.0" />
|
||||
<PackageReference Include="RestSharp" Version="106.15.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
|
||||
<PackageReference Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" />
|
||||
<PackageReference Include="NLog" Version="5.3.3" />
|
||||
<PackageReference Include="NLog" Version="5.3.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NzbDrone.Common\Radarr.Common.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NLog" Version="5.3.3" />
|
||||
<PackageReference Include="NLog" Version="5.3.4" />
|
||||
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NzbDrone.Common\Radarr.Common.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -45,6 +45,7 @@ namespace Radarr.Api.V3.Config
|
||||
public string BackupFolder { get; set; }
|
||||
public int BackupInterval { get; set; }
|
||||
public int BackupRetention { get; set; }
|
||||
public bool TrustCgnatIpAddresses { get; set; }
|
||||
}
|
||||
|
||||
public static class HostConfigResourceMapper
|
||||
|
||||
@@ -29,7 +29,9 @@ namespace Radarr.Api.V3.Movies
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public object Import([FromBody] List<MovieResource> resource)
|
||||
[Consumes("application/json")]
|
||||
[Produces("application/json")]
|
||||
public IEnumerable<MovieResource> Import([FromBody] List<MovieResource> resource)
|
||||
{
|
||||
var newMovies = resource.ToModel();
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ namespace Radarr.Api.V3.Movies
|
||||
}
|
||||
|
||||
[HttpGet("tmdb")]
|
||||
public object SearchByTmdbId(int tmdbId)
|
||||
[Produces("application/json")]
|
||||
public MovieResource SearchByTmdbId(int tmdbId)
|
||||
{
|
||||
var availDelay = _configService.AvailabilityDelay;
|
||||
var result = new Movie { MovieMetadata = _movieInfo.GetMovieInfo(tmdbId).Item1 };
|
||||
@@ -63,7 +64,8 @@ namespace Radarr.Api.V3.Movies
|
||||
}
|
||||
|
||||
[HttpGet("imdb")]
|
||||
public object SearchByImdbId(string imdbId)
|
||||
[Produces("application/json")]
|
||||
public MovieResource SearchByImdbId(string imdbId)
|
||||
{
|
||||
var result = new Movie { MovieMetadata = _movieInfo.GetMovieByImdbId(imdbId) };
|
||||
|
||||
@@ -73,7 +75,8 @@ namespace Radarr.Api.V3.Movies
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public object Search([FromQuery] string term)
|
||||
[Produces("application/json")]
|
||||
public IEnumerable<MovieResource> Search([FromQuery] string term)
|
||||
{
|
||||
var searchResults = _searchProxy.SearchForNewMovie(term);
|
||||
|
||||
|
||||
@@ -214,8 +214,8 @@ namespace Radarr.Api.V3.Queue
|
||||
if (pagingSpec.SortKey == "timeleft")
|
||||
{
|
||||
ordered = ascending
|
||||
? fullQueue.OrderBy(q => q.Timeleft, new TimeleftComparer())
|
||||
: fullQueue.OrderByDescending(q => q.Timeleft, new TimeleftComparer());
|
||||
? fullQueue.OrderBy(q => q.TimeLeft, new TimeleftComparer())
|
||||
: fullQueue.OrderByDescending(q => q.TimeLeft, new TimeleftComparer());
|
||||
}
|
||||
else if (pagingSpec.SortKey == "estimatedCompletionTime")
|
||||
{
|
||||
@@ -266,7 +266,7 @@ namespace Radarr.Api.V3.Queue
|
||||
ordered = ascending ? fullQueue.OrderBy(orderByFunc) : fullQueue.OrderByDescending(orderByFunc);
|
||||
}
|
||||
|
||||
ordered = ordered.ThenByDescending(q => q.Size == 0 ? 0 : 100 - (q.Sizeleft / q.Size * 100));
|
||||
ordered = ordered.ThenByDescending(q => q.Size == 0 ? 0 : 100 - (q.SizeLeft / q.Size * 100));
|
||||
|
||||
pagingSpec.Records = ordered.Skip((pagingSpec.Page - 1) * pagingSpec.PageSize).Take(pagingSpec.PageSize).ToList();
|
||||
pagingSpec.TotalRecords = fullQueue.Count;
|
||||
@@ -300,9 +300,9 @@ namespace Radarr.Api.V3.Queue
|
||||
return q => q.Size;
|
||||
case "progress":
|
||||
// Avoid exploding if a download's size is 0
|
||||
return q => 100 - (q.Sizeleft / Math.Max(q.Size * 100, 1));
|
||||
return q => 100 - (q.SizeLeft / Math.Max(q.Size * 100, 1));
|
||||
default:
|
||||
return q => q.Timeleft;
|
||||
return q => q.TimeLeft;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,11 @@ namespace Radarr.Api.V3.Queue
|
||||
public int CustomFormatScore { get; set; }
|
||||
public decimal Size { get; set; }
|
||||
public string Title { get; set; }
|
||||
public decimal Sizeleft { get; set; }
|
||||
public TimeSpan? Timeleft { get; set; }
|
||||
|
||||
// Collides with existing properties due to case-insensitive deserialization
|
||||
// public decimal SizeLeft { get; set; }
|
||||
// public TimeSpan? TimeLeft { get; set; }
|
||||
|
||||
public DateTime? EstimatedCompletionTime { get; set; }
|
||||
public DateTime? Added { get; set; }
|
||||
public QueueStatus Status { get; set; }
|
||||
@@ -37,6 +40,12 @@ namespace Radarr.Api.V3.Queue
|
||||
public bool DownloadClientHasPostImportCategory { get; set; }
|
||||
public string Indexer { get; set; }
|
||||
public string OutputPath { get; set; }
|
||||
|
||||
[Obsolete("Will be replaced by SizeLeft")]
|
||||
public decimal Sizeleft { get; set; }
|
||||
|
||||
[Obsolete("Will be replaced by TimeLeft")]
|
||||
public TimeSpan? Timeleft { get; set; }
|
||||
}
|
||||
|
||||
public static class QueueResourceMapper
|
||||
@@ -62,8 +71,11 @@ namespace Radarr.Api.V3.Queue
|
||||
CustomFormatScore = customFormatScore,
|
||||
Size = model.Size,
|
||||
Title = model.Title,
|
||||
Sizeleft = model.Sizeleft,
|
||||
Timeleft = model.Timeleft,
|
||||
|
||||
// Collides with existing properties due to case-insensitive deserialization
|
||||
// SizeLeft = model.SizeLeft,
|
||||
// TimeLeft = model.TimeLeft,
|
||||
|
||||
EstimatedCompletionTime = model.EstimatedCompletionTime,
|
||||
Added = model.Added,
|
||||
Status = model.Status,
|
||||
@@ -76,7 +88,12 @@ namespace Radarr.Api.V3.Queue
|
||||
DownloadClient = model.DownloadClient,
|
||||
DownloadClientHasPostImportCategory = model.DownloadClientHasPostImportCategory,
|
||||
Indexer = model.Indexer,
|
||||
OutputPath = model.OutputPath
|
||||
OutputPath = model.OutputPath,
|
||||
|
||||
#pragma warning disable CS0618
|
||||
Sizeleft = model.SizeLeft,
|
||||
Timeleft = model.TimeLeft,
|
||||
#pragma warning restore CS0618
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation" Version="9.5.4" />
|
||||
<PackageReference Include="Ical.Net" Version="4.2.0" />
|
||||
<PackageReference Include="NLog" Version="5.3.3" />
|
||||
<PackageReference Include="Ical.Net" Version="4.3.1" />
|
||||
<PackageReference Include="NLog" Version="5.3.4" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5365,28 +5365,22 @@
|
||||
"$ref": "#/components/schemas/MovieResource"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MovieResource"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MovieResource"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MovieResource"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5408,7 +5402,14 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MovieResource"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5429,7 +5430,14 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MovieResource"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5450,7 +5458,17 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MovieResource"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11669,15 +11687,6 @@
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"sizeleft": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"timeleft": {
|
||||
"type": "string",
|
||||
"format": "date-span",
|
||||
"nullable": true
|
||||
},
|
||||
"estimatedCompletionTime": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
@@ -11729,6 +11738,17 @@
|
||||
"outputPath": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"sizeleft": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"deprecated": true
|
||||
},
|
||||
"timeleft": {
|
||||
"type": "string",
|
||||
"format": "date-span",
|
||||
"nullable": true,
|
||||
"deprecated": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -27,10 +27,13 @@ namespace NzbDrone.Http.Authentication
|
||||
if (_authenticationRequired == AuthenticationRequiredType.DisabledForLocalAddresses)
|
||||
{
|
||||
if (context.Resource is HttpContext httpContext &&
|
||||
IPAddress.TryParse(httpContext.GetRemoteIP(), out var ipAddress) &&
|
||||
ipAddress.IsLocalAddress())
|
||||
IPAddress.TryParse(httpContext.GetRemoteIP(), out var ipAddress))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
if (ipAddress.IsLocalAddress() ||
|
||||
(_configService.TrustCgnatIpAddresses && ipAddress.IsCgnatIpAddress()))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation" Version="9.5.4" />
|
||||
<PackageReference Include="ImpromptuInterface" Version="7.0.1" />
|
||||
<PackageReference Include="NLog" Version="5.3.3" />
|
||||
<PackageReference Include="NLog" Version="5.3.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NzbDrone.Core\Radarr.Core.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user