1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-04-16 21:15:33 -04:00

Compare commits

...

17 Commits

Author SHA1 Message Date
soup
0caa793df4 New: Add config file setting for CGNAT authentication bypass
(cherry picked from commit 4c41a4f368046f73f82306bbd73bec992392938b)
2024-12-04 13:30:56 +02:00
Stevie Robinson
9a107cc8d7 New: Add Languages to Webhook Notifications
(cherry picked from commit e039dc45e267cf717e00c0a49ba637012f37e3d7)

Closes #10733
2024-12-02 16:59:45 +02:00
Mark McDowall
a6d727fe2a New: Kometa metadata file creation disabled
(cherry picked from commit c62fc9d05bb9e1fe51b454d78e80bd9250e31f89)

Closes #10738
2024-12-02 16:56:12 +02:00
Servarr
01a53d3624 Automated API Docs update 2024-12-02 16:03:25 +02:00
Weblate
348c29c9d7 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ardenet <1213193613@qq.com>
Co-authored-by: Languages add-on <noreply-addon-languages@weblate.org>
Co-authored-by: Robin Dadswell <robin@robindadswell.tech>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: mryx007 <mryx@mail.de>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_Hans/
Translation: Servarr/Radarr
2024-12-02 15:34:43 +02:00
Bogdan
64739712c6 Add return type for movie lookup and import endpoints
Closes #10737
2024-12-02 15:29:50 +02:00
Bogdan
6ac9cca953 Avoid default category on existing Transmission configurations
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
(cherry picked from commit bd656ae7f66fc9224ef2a57857152ee5d54d54f8)
2024-12-02 15:12:07 +02:00
Bogdan
a2b38c5b7d New: Labels support for Transmission 4.0
(cherry picked from commit 675e3cd38a14ea33c27f2d66a4be2bf802e17d88)
2024-12-02 15:12:07 +02:00
Bogdan
3cc4105d71 Bump NLog, Npgsql, Ical.Net, IPAddressRange, ImageSharp and Polly 2024-12-02 14:30:38 +02:00
Mark McDowall
3449a5d3fe Fixed: Don't fail import if symlink target can't be resolved
(cherry picked from commit 8cb58a63d8ec1b290bc57ad2cf1e90809ceebce9)
2024-12-02 02:32:21 +02:00
Gylesie
5bac157d36 Remove unnecessary heap allocations in local IP check
(cherry picked from commit ed536a85ad5f2062bf6f01f80efddb19fa935f63)
2024-12-02 02:31:28 +02:00
Mark McDowall
114d260f42 Deprecate Sizeleft and Timeleft queue item properties
Rename SizeLeft and TimeLeft queue item properties

(cherry picked from commit b51a49097941e5f306cae5785c63985b319784fd)

Fixed: Error loading queue

(cherry picked from commit f9606518eef78117f1e06a8bcc34af57ab0d2454)
2024-12-01 19:25:45 +02:00
Bogdan
617b9c5d35 Console warnings for missing translations on development builds
(cherry picked from commit 67a1ecb0fea4e6c7dfdb68fbe3ef30d4c22398d8)

Closes #10669
2024-12-01 18:54:53 +02:00
Mark McDowall
ba4ccbb0bd Deluge communication improvements
(cherry picked from commit 183b8b574a4dd948b5fada94d0e645b87710f223)
2024-12-01 14:01:41 +02:00
Mark McDowall
b845268b3d New: Support for new SABnzbd history retention values
Closes #10699

(cherry picked from commit e361f18837d98c089f7dc9c0190221ca8e2cf225)
2024-12-01 14:01:09 +02:00
Bogdan
0fee552074 Bump version to 5.16.1 2024-12-01 14:00:15 +02:00
Mark McDowall
828b994ef4 Support Postgres with non-standard version string
(cherry picked from commit 40f4ef27b22113c1dae0d0cbdee8205132bed68a)
2024-12-01 11:47:19 +00:00
87 changed files with 1941 additions and 1229 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import { AppSectionProviderState } from 'App/State/AppSectionState';
import Metadata from 'typings/Metadata';
interface MetadataAppState extends AppSectionProviderState<Metadata> {}
export default MetadataAppState;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
.message {
composes: alert from '~Components/Alert.css';
margin-bottom: 30px;
}

View 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;

View File

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

View File

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

View File

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

View File

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

View 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;

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View 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;

View File

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

View File

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

View 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;

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import Provider from './Provider';
interface Metadata extends Provider {
enable: boolean;
}
export default Metadata;

View File

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

View 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;

View File

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

View File

@@ -7,5 +7,6 @@ interface Window {
theme: string;
urlBase: string;
version: string;
isProduction: boolean;
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")]

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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"))
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

@@ -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": "无法自动导入",

View File

@@ -1,6 +1,6 @@
{
"About": "关于",
"Add": "添加",
"About": "关于",
"Always": "总是",
"Analytics": "分析",
"Username": "用户名"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();
}
}
}

View 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)
{
}
}
}

View File

@@ -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]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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