1
0
mirror of https://github.com/Radarr/Radarr.git synced 2024-10-05 15:47:20 +02:00

Fixed: Backend Updates from Sonarr

Co-Authored-By: Mark McDowall <markus101@users.noreply.github.com>
Co-Authored-By: taloth <taloth@users.noreply.github.com>
This commit is contained in:
Qstick 2019-06-30 21:50:01 -04:00
parent d178dce0d3
commit 91ab518dfb
131 changed files with 2422 additions and 988 deletions

View File

@ -9,15 +9,15 @@ function getIconName(eventType) {
switch (eventType) {
case 'grabbed':
return icons.DOWNLOADING;
case 'seriesFolderImported':
case 'movieFolderImported':
return icons.DRIVE;
case 'downloadFolderImported':
return icons.DOWNLOADED;
case 'downloadFailed':
return icons.DOWNLOADING;
case 'episodeFileDeleted':
case 'movieFileDeleted':
return icons.DELETE;
case 'episodeFileRenamed':
case 'movieFileRenamed':
return icons.ORGANIZE;
default:
return icons.UNKNOWN;
@ -36,17 +36,17 @@ function getIconKind(eventType) {
function getTooltip(eventType, data) {
switch (eventType) {
case 'grabbed':
return `Episode grabbed from ${data.indexer} and sent to ${data.downloadClient}`;
case 'seriesFolderImported':
return 'Episode imported from series folder';
return `Movie grabbed from ${data.indexer} and sent to ${data.downloadClient}`;
case 'movieFolderImported':
return 'Movie imported from movie folder';
case 'downloadFolderImported':
return 'Episode downloaded successfully and picked up from download client';
return 'Movie downloaded successfully and picked up from download client';
case 'downloadFailed':
return 'Episode download failed';
case 'episodeFileDeleted':
return 'Episode file deleted';
case 'episodeFileRenamed':
return 'Episode file renamed';
return 'Movie download failed';
case 'movieFileDeleted':
return 'Movie file deleted';
case 'movieFileRenamed':
return 'Movie file renamed';
default:
return 'Unknown event';
}

View File

@ -2,13 +2,13 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchDevices, clearDevices } from 'Store/Actions/deviceActions';
import { fetchOptions, clearOptions } from 'Store/Actions/providerOptionActions';
import DeviceInput from './DeviceInput';
function createMapStateToProps() {
return createSelector(
(state, { value }) => value,
(state) => state.devices,
(state) => state.providerOptions,
(value, devices) => {
return {
@ -37,8 +37,8 @@ function createMapStateToProps() {
}
const mapDispatchToProps = {
dispatchFetchDevices: fetchDevices,
dispatchClearDevices: clearDevices
dispatchFetchOptions: fetchOptions,
dispatchClearOptions: clearOptions
};
class DeviceInputConnector extends Component {
@ -51,7 +51,7 @@ class DeviceInputConnector extends Component {
}
componentWillUnmount = () => {
// this.props.dispatchClearDevices();
this.props.dispatchClearOptions();
}
//
@ -61,10 +61,14 @@ class DeviceInputConnector extends Component {
const {
provider,
providerData,
dispatchFetchDevices
dispatchFetchOptions
} = this.props;
dispatchFetchDevices({ provider, providerData });
dispatchFetchOptions({
action: 'getDevices',
provider,
providerData
});
}
//
@ -92,8 +96,8 @@ DeviceInputConnector.propTypes = {
providerData: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
dispatchFetchDevices: PropTypes.func.isRequired,
dispatchClearDevices: PropTypes.func.isRequired
dispatchFetchOptions: PropTypes.func.isRequired,
dispatchClearOptions: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector);

View File

@ -6,7 +6,7 @@ import classNames from 'classnames';
import getUniqueElememtId from 'Utilities/getUniqueElementId';
import isMobileUtil from 'Utilities/isMobile';
import * as keyCodes from 'Utilities/Constants/keyCodes';
import { icons, scrollDirections } from 'Helpers/Props';
import { icons, sizes, scrollDirections } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Portal from 'Components/Portal';
import Link from 'Components/Link/Link';
@ -14,8 +14,8 @@ import Measure from 'Components/Measure';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import Scroller from 'Components/Scroller/Scroller';
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import HintedSelectInputOption from './HintedSelectInputOption';
import styles from './EnhancedSelectInput.css';
function isArrowKey(keyCode) {
@ -150,9 +150,11 @@ class EnhancedSelectInput extends Component {
}
onBlur = () => {
this.setState({
selectedIndex: getSelectedIndex(this.props)
});
// Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
const origIndex = getSelectedIndex(this.props);
if (origIndex !== this.state.selectedIndex) {
this.setState({ selectedIndex: origIndex });
}
}
onKeyDown = (event) => {
@ -385,6 +387,7 @@ class EnhancedSelectInput extends Component {
isMobile &&
<Modal
className={styles.optionsModal}
size={sizes.EXTRA_SMALL}
isOpen={isOpen}
onModalClose={this.onOptionsModalClose}
>
@ -439,8 +442,8 @@ EnhancedSelectInput.defaultProps = {
disabledClassName: styles.isDisabled,
isDisabled: false,
selectedValueOptions: {},
selectedValueComponent: EnhancedSelectInputSelectedValue,
optionComponent: EnhancedSelectInputOption
selectedValueComponent: HintedSelectInputSelectedValue,
optionComponent: HintedSelectInputOption
};
export default EnhancedSelectInput;

View File

@ -7,13 +7,17 @@
cursor: default;
&:hover {
background-color: #f9f9f9;
background-color: #f8f8f8;
}
}
.isSelected {
background-color: #e2e2e2;
&:hover {
background-color: #e2e2e2;
}
&.isMobile {
background-color: inherit;

View File

@ -1,5 +1,6 @@
.inputGroupContainer {
flex: 1 1 auto;
min-width: 0;
}
.inputGroup {
@ -11,6 +12,7 @@
.inputContainer {
position: relative;
flex: 1 1 auto;
min-width: 0;
}
.inputUnit {

View File

@ -14,7 +14,7 @@ import PathInputConnector from './PathInputConnector';
import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector';
import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
import MovieMonitoredSelectInput from './MovieMonitoredSelectInput';
import SelectInput from './SelectInput';
import EnhancedSelectInput from './EnhancedSelectInput';
import TagInputConnector from './TagInputConnector';
import TextTagInputConnector from './TextTagInputConnector';
import TextInput from './TextInput';
@ -60,7 +60,7 @@ function getComponent(type) {
return RootFolderSelectInputConnector;
case inputTypes.SELECT:
return SelectInput;
return EnhancedSelectInput;
case inputTypes.TAG:
return TagInputConnector;

View File

@ -0,0 +1,23 @@
.optionText {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1 0 0;
min-width: 0;
&.isMobile {
display: block;
.hintText {
margin-left: 0;
}
}
}
.hintText {
@add-mixin truncate;
margin-left: 15px;
color: $darkGray;
font-size: $smallFontSize;
}

View File

@ -0,0 +1,44 @@
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
import styles from './HintedSelectInputOption.css';
function HintedSelectInputOption(props) {
const {
value,
hint,
isMobile,
...otherProps
} = props;
return (
<EnhancedSelectInputOption
isMobile={isMobile}
{...otherProps}
>
<div className={classNames(
styles.optionText,
isMobile && styles.isMobile
)}
>
<div>{value}</div>
{
hint != null &&
<div className={styles.hintText}>
{hint}
</div>
}
</div>
</EnhancedSelectInputOption>
);
}
HintedSelectInputOption.propTypes = {
value: PropTypes.string.isRequired,
hint: PropTypes.node,
isMobile: PropTypes.bool.isRequired
};
export default HintedSelectInputOption;

View File

@ -0,0 +1,24 @@
.selectedValue {
composes: selectedValue from '~./EnhancedSelectInputSelectedValue.css';
display: flex;
align-items: center;
justify-content: space-between;
overflow: hidden;
}
.valueText {
@add-mixin truncate;
flex: 0 0 auto;
}
.hintText {
@add-mixin truncate;
flex: 1 10 0;
margin-left: 15px;
color: $gray;
text-align: right;
font-size: $smallFontSize;
}

View File

@ -0,0 +1,43 @@
import PropTypes from 'prop-types';
import React from 'react';
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
import styles from './HintedSelectInputSelectedValue.css';
function HintedSelectInputSelectedValue(props) {
const {
value,
hint,
includeHint,
...otherProps
} = props;
return (
<EnhancedSelectInputSelectedValue
className={styles.selectedValue}
{...otherProps}
>
<div className={styles.valueText}>
{value}
</div>
{
hint != null && includeHint &&
<div className={styles.hintText}>
{hint}
</div>
}
</EnhancedSelectInputSelectedValue>
);
}
HintedSelectInputSelectedValue.propTypes = {
value: PropTypes.string,
hint: PropTypes.string,
includeHint: PropTypes.bool.isRequired
};
HintedSelectInputSelectedValue.defaultProps = {
includeHint: true
};
export default HintedSelectInputSelectedValue;

View File

@ -20,7 +20,7 @@ function getType(type) {
return inputTypes.NUMBER;
case 'path':
return inputTypes.PATH;
case 'filepath':
case 'filePath':
return inputTypes.PATH;
case 'select':
return inputTypes.SELECT;
@ -60,6 +60,7 @@ function ProviderFieldFormGroup(props) {
value,
type,
advanced,
hidden,
pending,
errors,
warnings,
@ -68,6 +69,13 @@ function ProviderFieldFormGroup(props) {
...otherProps
} = props;
if (
hidden === 'hidden' ||
(hidden === 'hiddenIfNotSet' && !value)
) {
return null;
}
return (
<FormGroup
advancedSettings={advancedSettings}
@ -86,7 +94,7 @@ function ProviderFieldFormGroup(props) {
errors={errors}
warnings={warnings}
pending={pending}
includeFiles={type === 'filepath' ? true : undefined}
includeFiles={type === 'filePath' ? true : undefined}
onChange={onChange}
{...otherProps}
/>
@ -108,6 +116,7 @@ ProviderFieldFormGroup.propTypes = {
value: PropTypes.any,
type: PropTypes.string.isRequired,
advanced: PropTypes.bool.isRequired,
hidden: PropTypes.string,
pending: PropTypes.bool.isRequired,
errors: PropTypes.arrayOf(PropTypes.object).isRequired,
warnings: PropTypes.arrayOf(PropTypes.object).isRequired,

View File

@ -1,7 +1,6 @@
.input {
composes: input from '~./AutoSuggestInput.css';
position: relative;
padding: 0;
min-height: 35px;
height: auto;

View File

@ -1,5 +1,4 @@
.inputContainer {
position: absolute;
top: -1px;
right: -1px;
bottom: -1px;

View File

@ -128,6 +128,8 @@ class TextInput extends Component {
hasWarning,
hasButton,
step,
min,
max,
onBlur
} = this.props;
@ -148,6 +150,8 @@ class TextInput extends Component {
name={name}
value={value}
step={step}
min={min}
max={max}
onChange={this.onChange}
onFocus={this.onFocus}
onBlur={onBlur}
@ -171,6 +175,8 @@ TextInput.propTypes = {
hasWarning: PropTypes.bool,
hasButton: PropTypes.bool,
step: PropTypes.number,
min: PropTypes.number,
max: PropTypes.number,
onChange: PropTypes.func.isRequired,
onFocus: PropTypes.func,
onBlur: PropTypes.func,

View File

@ -47,7 +47,7 @@ class Link extends Component {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_self';
} else if (to.startsWith(window.Radarr.urlBase)) {
} else if (to.startsWith(`${window.Radarr.urlBase}/`)) {
el = RouterLink;
linkProps.to = to;
linkProps.target = target;

View File

@ -154,8 +154,33 @@ class MovieSearchInput extends Component {
}
onSuggestionsFetchRequested = ({ value }) => {
const fuse = new Fuse(this.props.movies, fuseOptions);
const suggestions = fuse.search(value);
const { movies } = this.props;
let suggestions = [];
if (value.length === 1) {
suggestions = movies.reduce((acc, s) => {
if (s.firstCharacter === value.toLowerCase()) {
acc.push({
item: s,
indices: [
[0, 0]
],
matches: [
{
value: s.title,
key: 'title'
}
],
arrayIndex: 0
});
}
return acc;
}, []);
} else {
const fuse = new Fuse(movies, fuseOptions);
suggestions = fuse.search(value);
}
this.setState({ suggestions });
}

View File

@ -2,6 +2,7 @@ import { connect } from 'react-redux';
import { push } from 'connected-react-router';
import { createSelector } from 'reselect';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import MovieSearchInput from './MovieSearchInput';
@ -26,9 +27,16 @@ function createCleanMovieSelector() {
sortTitle,
images,
alternateTitles,
tags: tags.map((id) => {
return allTags.find((tag) => tag.id === id);
})
firstCharacter: title.charAt(0).toLowerCase(),
tags: tags.reduce((acc, id) => {
const matchingTag = allTags.find((tag) => tag.id === id);
if (matchingTag) {
acc.push(matchingTag);
}
return acc;
}, [])
};
});
}
@ -36,7 +44,7 @@ function createCleanMovieSelector() {
}
function createMapStateToProps() {
return createSelector(
return createDeepEqualSelector(
createCleanMovieSelector(),
(movies) => {
return {

View File

@ -84,7 +84,7 @@ class SignalRConnector extends Component {
constructor(props, context) {
super(props, context);
this.signalRconnectionOptions = { transport: ['webSockets', 'longPolling'] };
this.signalRconnectionOptions = { transport: ['webSockets', 'serverSentEvents', 'longPolling'] };
this.signalRconnection = null;
this.retryInterval = 1;
this.retryTimeoutId = null;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -52,15 +52,15 @@ class DeleteMovieModalContent extends Component {
} = this.props;
const {
episodeFileCount,
movieFileCount,
sizeOnDisk
} = statistics;
const deleteFiles = this.state.deleteFiles;
let deleteFilesLabel = `Delete ${episodeFileCount} Movie Files`;
let deleteFilesLabel = `Delete ${movieFileCount} Movie Files`;
let deleteFilesHelpText = 'Delete the movie files and movie folder';
if (episodeFileCount === 0) {
if (movieFileCount === 0) {
deleteFilesLabel = 'Delete Movie Folder';
deleteFilesHelpText = 'Delete the movie folder and it\'s contents';
}
@ -102,8 +102,8 @@ class DeleteMovieModalContent extends Component {
<div>The movie folder <strong>{path}</strong> and all it's content will be deleted.</div>
{
!!episodeFileCount &&
<div>{episodeFileCount} movie files totaling {formatBytes(sizeOnDisk)}</div>
!!movieFileCount &&
<div>{movieFileCount} movie files totaling {formatBytes(sizeOnDisk)}</div>
}
</div>
}
@ -137,7 +137,7 @@ DeleteMovieModalContent.propTypes = {
DeleteMovieModalContent.defaultProps = {
statistics: {
episodeFileCount: 0
movieFileCount: 0
}
};

View File

@ -584,7 +584,7 @@ MovieDetails.propTypes = {
};
MovieDetails.defaultProps = {
tag: [],
tags: [],
isSaving: false,
sizeOnDisk: 0
};

View File

@ -254,10 +254,7 @@ MovieIndexPoster.propTypes = {
MovieIndexPoster.defaultProps = {
statistics: {
seasonCount: 0,
episodeCount: 0,
episodeFileCount: 0,
totalEpisodeCount: 0
movieFileCount: 0
}
};

View File

@ -0,0 +1,25 @@
import PropTypes from 'prop-types';
import React from 'react';
import MovieImage from './MovieImage';
const bannerPlaceholder = 'data:image/png;base64,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';
function MovieBanner(props) {
return (
<MovieImage
{...props}
coverType="banner"
placeholder={bannerPlaceholder}
/>
);
}
MovieBanner.propTypes = {
size: PropTypes.number.isRequired
};
MovieBanner.defaultProps = {
size: 70
};
export default MovieBanner;

View File

@ -0,0 +1,199 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import LazyLoad from 'react-lazyload';
function findImage(images, coverType) {
return images.find((image) => image.coverType === coverType);
}
function getUrl(image, coverType, size) {
if (image) {
// Remove protocol
let url = image.url.replace(/^https?:/, '');
url = url.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`);
return url;
}
}
class MovieImage extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const pixelRatio = Math.ceil(window.devicePixelRatio);
const {
images,
coverType,
size
} = props;
const image = findImage(images, coverType);
this.state = {
pixelRatio,
image,
url: getUrl(image, coverType, pixelRatio * size),
isLoaded: false,
hasError: false
};
}
componentDidMount() {
if (!this.state.url && this.props.onError) {
this.props.onError();
}
}
componentDidUpdate() {
const {
images,
coverType,
placeholder,
size,
onError
} = this.props;
const {
image,
pixelRatio
} = this.state;
const nextImage = findImage(images, coverType);
if (nextImage && (!image || nextImage.url !== image.url)) {
this.setState({
image: nextImage,
url: getUrl(nextImage, coverType, pixelRatio * size),
hasError: false
// Don't reset isLoaded, as we want to immediately try to
// show the new image, whether an image was shown previously
// or the placeholder was shown.
});
} else if (!nextImage && image) {
this.setState({
image: nextImage,
url: placeholder,
hasError: false
});
if (onError) {
onError();
}
}
}
//
// Listeners
onError = () => {
this.setState({
hasError: true
});
if (this.props.onError) {
this.props.onError();
}
}
onLoad = () => {
this.setState({
isLoaded: true,
hasError: false
});
if (this.props.onLoad) {
this.props.onLoad();
}
}
//
// Render
render() {
const {
className,
style,
placeholder,
size,
lazy,
overflow
} = this.props;
const {
url,
hasError,
isLoaded
} = this.state;
if (hasError || !url) {
return (
<img
className={className}
style={style}
src={placeholder}
/>
);
}
if (lazy) {
return (
<LazyLoad
height={size}
offset={100}
overflow={overflow}
placeholder={
<img
className={className}
style={style}
src={placeholder}
/>
}
>
<img
className={className}
style={style}
src={url}
onError={this.onError}
onLoad={this.onLoad}
/>
</LazyLoad>
);
}
return (
<img
className={className}
style={style}
src={isLoaded ? url : placeholder}
onError={this.onError}
onLoad={this.onLoad}
/>
);
}
}
MovieImage.propTypes = {
className: PropTypes.string,
style: PropTypes.object,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
coverType: PropTypes.string.isRequired,
placeholder: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
lazy: PropTypes.bool.isRequired,
overflow: PropTypes.bool.isRequired,
onError: PropTypes.func,
onLoad: PropTypes.func
};
MovieImage.defaultProps = {
size: 250,
lazy: true,
overflow: false
};
export default MovieImage;

View File

@ -1,195 +1,25 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import LazyLoad from 'react-lazyload';
import React from 'react';
import MovieImage from './MovieImage';
const posterPlaceholder = 'data:image/png;base64,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';
function findPoster(images) {
return _.find(images, { coverType: 'poster' });
}
function getPosterUrl(poster, size) {
if (poster) {
// Remove protocol
let url = poster.url.replace(/^https?:/, '');
url = url.replace('poster.jpg', `poster-${size}.jpg`);
return url;
}
}
class MoviePoster extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const pixelRatio = Math.ceil(window.devicePixelRatio);
const {
images,
size
} = props;
const poster = findPoster(images);
this.state = {
pixelRatio,
poster,
posterUrl: getPosterUrl(poster, pixelRatio * size),
isLoaded: false,
hasError: false
};
}
componentDidMount() {
if (!this.state.posterUrl && this.props.onError) {
this.props.onError();
}
}
componentDidUpdate(prevProps, prevState) {
const {
images,
size,
onError
} = this.props;
const {
poster,
pixelRatio
} = this.state;
const nextPoster = findPoster(images);
if (nextPoster && (!poster || nextPoster.url !== poster.url)) {
this.setState({
poster: nextPoster,
posterUrl: getPosterUrl(nextPoster, pixelRatio * size),
hasError: false
// Don't reset isLoaded, as we want to immediately try to
// show the new image, whether an image was shown previously
// or the placeholder was shown.
});
} else if (!nextPoster && poster) {
this.setState({
poster: nextPoster,
posterUrl: posterPlaceholder,
hasError: false
});
if (onError) {
onError();
}
}
}
//
// Listeners
onError = () => {
this.setState({
hasError: true
});
if (this.props.onError) {
this.props.onError();
}
}
onLoad = () => {
this.setState({
isLoaded: true,
hasError: false
});
if (this.props.onLoad) {
this.props.onLoad();
}
}
//
// Render
render() {
const {
className,
style,
size,
lazy,
overflow
} = this.props;
const {
posterUrl,
hasError,
isLoaded
} = this.state;
if (hasError || !posterUrl) {
return (
<img
className={className}
style={style}
src={posterPlaceholder}
/>
);
}
if (lazy) {
return (
<LazyLoad
height={size}
offset={100}
overflow={overflow}
placeholder={
<img
className={className}
style={style}
src={posterPlaceholder}
/>
}
>
<img
className={className}
style={style}
src={posterUrl}
onError={this.onError}
/>
</LazyLoad>
);
}
return (
<img
className={className}
style={style}
src={isLoaded ? posterUrl : posterPlaceholder}
onError={this.onError}
onLoad={this.onLoad}
/>
);
}
function MoviePoster(props) {
return (
<MovieImage
{...props}
coverType="poster"
placeholder={posterPlaceholder}
/>
);
}
MoviePoster.propTypes = {
className: PropTypes.string,
style: PropTypes.object,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
size: PropTypes.number.isRequired,
lazy: PropTypes.bool.isRequired,
overflow: PropTypes.bool.isRequired,
onError: PropTypes.func,
onLoad: PropTypes.func
size: PropTypes.number.isRequired
};
MoviePoster.defaultProps = {
size: 250,
lazy: true,
overflow: false
size: 250
};
export default MoviePoster;

View File

@ -54,7 +54,8 @@ class DownloadClient extends Component {
const {
id,
name,
enable
enable,
priority
} = this.props;
return (
@ -80,6 +81,16 @@ class DownloadClient extends Component {
Disabled
</Label>
}
{
priority > 1 &&
<Label
kind={kinds.DISABLED}
outline={true}
>
Priority: {priority}
</Label>
}
</div>
<EditDownloadClientModalConnector
@ -107,6 +118,7 @@ DownloadClient.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
enable: PropTypes.bool.isRequired,
priority: PropTypes.number.isRequired,
onConfirmDeleteDownloadClient: PropTypes.func.isRequired
};

View File

@ -44,6 +44,7 @@ class EditDownloadClientModalContent extends Component {
implementationName,
name,
enable,
priority,
fields,
message
} = item;
@ -67,9 +68,7 @@ class EditDownloadClientModalContent extends Component {
{
!isFetching && !error &&
<Form
{...otherProps}
>
<Form {...otherProps}>
{
!!message &&
<Alert
@ -117,6 +116,23 @@ class EditDownloadClientModalContent extends Component {
})
}
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>Client Priority</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="priority"
helpText="Prioritize multiple Download Clients. Round-Robin is used for clients with the same priority."
min={1}
max={50}
{...priority}
onChange={onInputChange}
/>
</FormGroup>
</Form>
}
</ModalBody>

View File

@ -23,6 +23,7 @@ function EditRemotePathMappingModalContent(props) {
isSaving,
saveError,
item,
downloadClientHosts,
onInputChange,
onSavePress,
onModalClose,
@ -55,17 +56,16 @@ function EditRemotePathMappingModalContent(props) {
{
!isFetching && !error &&
<Form
{...otherProps}
>
<Form {...otherProps}>
<FormGroup>
<FormLabel>Host</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
type={inputTypes.SELECT}
name="host"
helpText="The same host you specified for the remote Download Client"
{...host}
values={downloadClientHosts}
onChange={onInputChange}
/>
</FormGroup>
@ -140,6 +140,7 @@ EditRemotePathMappingModalContent.propTypes = {
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.shape(remotePathMappingShape).isRequired,
downloadClientHosts: PropTypes.arrayOf(PropTypes.string).isRequired,
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,

View File

@ -13,11 +13,39 @@ const newRemotePathMapping = {
localPath: ''
};
const selectDownloadClientHosts = createSelector(
(state) => state.settings.downloadClients.items,
(downloadClients) => {
const hosts = downloadClients.reduce((acc, downloadClient) => {
const name = downloadClient.name;
const host = downloadClient.fields.find((field) => {
return field.name === 'host';
});
if (host) {
const group = acc[host.value] = acc[host.value] || [];
group.push(name);
}
return acc;
}, {});
return Object.keys(hosts).map((host) => {
return {
key: host,
value: host,
hint: `${hosts[host].join(', ')}`
};
});
}
);
function createRemotePathMappingSelector() {
return createSelector(
(state, { id }) => id,
(state) => state.settings.remotePathMappings,
(id, remotePathMappings) => {
selectDownloadClientHosts,
(id, remotePathMappings, downloadClientHosts) => {
const {
isFetching,
error,
@ -37,7 +65,8 @@ function createRemotePathMappingSelector() {
isSaving,
saveError,
item: settings.settings,
...settings
...settings,
downloadClientHosts
};
}
);
@ -55,8 +84,8 @@ function createMapStateToProps() {
}
const mapDispatchToProps = {
setRemotePathMappingValue,
saveRemotePathMapping
dispatchSetRemotePathMappingValue: setRemotePathMappingValue,
dispatchSaveRemotePathMapping: saveRemotePathMapping
};
class EditRemotePathMappingModalContentConnector extends Component {
@ -67,7 +96,7 @@ class EditRemotePathMappingModalContentConnector extends Component {
componentDidMount() {
if (!this.props.id) {
Object.keys(newRemotePathMapping).forEach((name) => {
this.props.setRemotePathMappingValue({
this.props.dispatchSetRemotePathMappingValue({
name,
value: newRemotePathMapping[name]
});
@ -85,11 +114,11 @@ class EditRemotePathMappingModalContentConnector extends Component {
// Listeners
onInputChange = ({ name, value }) => {
this.props.setRemotePathMappingValue({ name, value });
this.props.dispatchSetRemotePathMappingValue({ name, value });
}
onSavePress = () => {
this.props.saveRemotePathMapping({ id: this.props.id });
this.props.dispatchSaveRemotePathMapping({ id: this.props.id });
}
//
@ -111,8 +140,8 @@ EditRemotePathMappingModalContentConnector.propTypes = {
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setRemotePathMappingValue: PropTypes.func.isRequired,
saveRemotePathMapping: PropTypes.func.isRequired,
dispatchSetRemotePathMappingValue: PropTypes.func.isRequired,
dispatchSaveRemotePathMapping: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View File

@ -49,7 +49,8 @@ function BackupSettings(props) {
<FormInputGroup
type={inputTypes.NUMBER}
name="backupInterval"
helpText="Interval in days"
unit="days"
helpText="Interval between automatic backups"
onChange={onInputChange}
{...backupInterval}
/>
@ -64,7 +65,8 @@ function BackupSettings(props) {
<FormInputGroup
type={inputTypes.NUMBER}
name="backupRetention"
helpText="Retention in days. Automatic backups older the retention will be cleaned up automatically"
unit="days"
helpText="Automatic backups older the retention will be cleaned up automatically"
onChange={onInputChange}
{...backupRetention}
/>

View File

@ -16,6 +16,19 @@ import ProxySettings from './ProxySettings';
import SecuritySettings from './SecuritySettings';
import UpdateSettings from './UpdateSettings';
const requiresRestartKeys = [
'bindAddress',
'port',
'urlBase',
'enableSsl',
'sslPort',
'sslCertHash',
'authenticationMethod',
'username',
'password',
'apiKey'
];
class GeneralSettings extends Component {
//
@ -42,20 +55,7 @@ class GeneralSettings extends Component {
const prevSettings = prevProps.settings;
const keys = [
'bindAddress',
'port',
'urlBase',
'enableSsl',
'sslPort',
'sslCertHash',
'authenticationMethod',
'username',
'password',
'apiKey'
];
const pendingRestart = _.some(keys, (key) => {
const pendingRestart = _.some(requiresRestartKeys, (key) => {
const setting = settings[key];
const prevSetting = prevSettings[key];
@ -98,6 +98,7 @@ class GeneralSettings extends Component {
isResettingApiKey,
isMono,
isWindows,
isWindowsService,
mode,
onInputChange,
onConfirmResetApiKey,
@ -177,7 +178,9 @@ class GeneralSettings extends Component {
isOpen={this.state.isRestartRequiredModalOpen}
kind={kinds.DANGER}
title="Restart Radarr"
message="Radarr requires a restart to apply changes, do you want to restart now?"
message={
`Radarr requires a restart to apply changes, do you want to restart now? ${isWindowsService ? 'Depending which user is running the Radarr service you may need to restart Radarr as admin once before the service will start automatically.' : ''}`
}
cancelLabel="I'll restart later"
confirmLabel="Restart Now"
onConfirm={this.onConfirmRestart}
@ -201,6 +204,7 @@ GeneralSettings.propTypes = {
hasSettings: PropTypes.bool.isRequired,
isMono: PropTypes.bool.isRequired,
isWindows: PropTypes.bool.isRequired,
isWindowsService: PropTypes.bool.isRequired,
mode: PropTypes.string.isRequired,
onInputChange: PropTypes.func.isRequired,
onConfirmResetApiKey: PropTypes.func.isRequired,

View File

@ -26,6 +26,7 @@ function createMapStateToProps() {
isResettingApiKey,
isMono: systemStatus.isMono,
isWindows: systemStatus.isWindows,
isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service',
mode: systemStatus.mode,
...sectionSettings
};
@ -58,7 +59,7 @@ class GeneralSettingsConnector extends Component {
}
componentWillUnmount() {
this.props.clearPendingChanges({ section: SECTION });
this.props.clearPendingChanges({ section: `settings.${SECTION}` });
}
//

View File

@ -87,56 +87,59 @@ function HostSettings(props) {
</FormGroup>
{
enableSsl.value &&
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>SSL Port</FormLabel>
enableSsl.value ?
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>SSL Port</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="sslPort"
min={1}
max={65535}
helpTextWarning="Requires restart to take effect"
onChange={onInputChange}
{...sslPort}
/>
</FormGroup>
<FormInputGroup
type={inputTypes.NUMBER}
name="sslPort"
min={1}
max={65535}
helpTextWarning="Requires restart to take effect"
onChange={onInputChange}
{...sslPort}
/>
</FormGroup> :
null
}
{
isWindows && enableSsl.value &&
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>SSL Cert Hash</FormLabel>
isWindows && enableSsl.value ?
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>SSL Cert Hash</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="sslCertHash"
helpTextWarning="Requires restart to take effect"
onChange={onInputChange}
{...sslCertHash}
/>
</FormGroup>
<FormInputGroup
type={inputTypes.TEXT}
name="sslCertHash"
helpTextWarning="Requires restart to take effect"
onChange={onInputChange}
{...sslCertHash}
/>
</FormGroup> :
null
}
{
mode !== 'service' &&
<FormGroup size={sizes.MEDIUM}>
<FormLabel>Open browser on start</FormLabel>
isWindows && mode !== 'service' ?
<FormGroup size={sizes.MEDIUM}>
<FormLabel>Open browser on start</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="launchBrowser"
helpText=" Open a web browser and navigate to Radarr homepage on app start."
onChange={onInputChange}
{...launchBrowser}
/>
</FormGroup>
<FormInputGroup
type={inputTypes.CHECK}
name="launchBrowser"
helpText=" Open a web browser and navigate to Radarr homepage on app start."
onChange={onInputChange}
{...launchBrowser}
/>
</FormGroup> :
null
}
</FieldSet>

View File

@ -3,10 +3,12 @@ import React, { Component } from 'react';
import ReactSlider from 'react-slider';
import formatBytes from 'Utilities/Number/formatBytes';
import roundNumber from 'Utilities/Number/roundNumber';
import { kinds } from 'Helpers/Props';
import { kinds, tooltipPositions } from 'Helpers/Props';
import Label from 'Components/Label';
import NumberInput from 'Components/Form/NumberInput';
import TextInput from 'Components/Form/TextInput';
import Popover from 'Components/Tooltip/Popover';
import QualityDefinitionLimits from './QualityDefinitionLimits';
import styles from './QualityDefinition.css';
const MIN = 0;
@ -139,12 +141,10 @@ class QualityDefinition extends Component {
} = this.state;
const minBytes = minSize * 1024 * 1024;
const minThirty = formatBytes(minBytes * 90, 2);
const minSixty = formatBytes(minBytes * 140, 2);
const minSixty = `${formatBytes(minBytes * 60)}/h`;
const maxBytes = maxSize && maxSize * 1024 * 1024;
const maxThirty = maxBytes ? formatBytes(maxBytes * 90, 2) : 'Unlimited';
const maxSixty = maxBytes ? formatBytes(maxBytes * 140, 2) : 'Unlimited';
const maxSixty = maxBytes ? `${formatBytes(maxBytes * 60)}/h` : 'Unlimited';
return (
<div className={styles.qualityDefinition}>
@ -178,13 +178,35 @@ class QualityDefinition extends Component {
<div className={styles.sizes}>
<div>
<Label kind={kinds.WARNING}>{minThirty}</Label>
<Label kind={kinds.INFO}>{minSixty}</Label>
<Popover
anchor={
<Label kind={kinds.INFO}>{minSixty}</Label>
}
title="Minimum Limits"
body={
<QualityDefinitionLimits
bytes={minBytes}
message="No minimum for any runtime"
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
<div>
<Label kind={kinds.WARNING}>{maxThirty}</Label>
<Label kind={kinds.INFO}>{maxSixty}</Label>
<Popover
anchor={
<Label kind={kinds.WARNING}>{maxSixty}</Label>
}
title="Maximum Limits"
body={
<QualityDefinitionLimits
bytes={maxBytes}
message="No limit for any runtime"
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
</div>
</div>

View File

@ -12,15 +12,15 @@ function QualityDefinitionLimits(props) {
return <div>{message}</div>;
}
const thirty = formatBytes(bytes * 30);
const fourtyFive = formatBytes(bytes * 45);
const sixty = formatBytes(bytes * 60);
const ninety = formatBytes(bytes * 90);
const hundredTwenty = formatBytes(bytes * 120);
return (
<div>
<div>30 Minutes: {thirty}</div>
<div>45 Minutes: {fourtyFive}</div>
<div>60 Minutes: {sixty}</div>
<div>90 Minutes: {ninety}</div>
<div>120 Minutes: {hundredTwenty}</div>
</div>
);
}

View File

@ -4,7 +4,6 @@ import * as blacklist from './blacklistActions';
import * as calendar from './calendarActions';
import * as captcha from './captchaActions';
import * as customFilters from './customFilterActions';
import * as devices from './deviceActions';
import * as commands from './commandActions';
import * as movieFiles from './movieFileActions';
import * as history from './historyActions';
@ -13,6 +12,7 @@ import * as interactiveImportActions from './interactiveImportActions';
import * as oAuth from './oAuthActions';
import * as organizePreview from './organizePreviewActions';
import * as paths from './pathActions';
import * as providerOptions from './providerOptionActions';
import * as queue from './queueActions';
import * as releases from './releaseActions';
import * as rootFolders from './rootFolderActions';
@ -32,7 +32,6 @@ export default [
captcha,
commands,
customFilters,
devices,
movieFiles,
history,
importMovie,
@ -40,6 +39,7 @@ export default [
oAuth,
organizePreview,
paths,
providerOptions,
queue,
releases,
rootFolders,

View File

@ -8,7 +8,7 @@ import { set } from './baseActions';
//
// Variables
export const section = 'devices';
export const section = 'providerOptions';
//
// State
@ -23,32 +23,27 @@ export const defaultState = {
//
// Actions Types
export const FETCH_DEVICES = 'devices/fetchDevices';
export const CLEAR_DEVICES = 'devices/clearDevices';
export const FETCH_OPTIONS = 'devices/fetchOptions';
export const CLEAR_OPTIONS = 'devices/clearOptions';
//
// Action Creators
export const fetchDevices = createThunk(FETCH_DEVICES);
export const clearDevices = createAction(CLEAR_DEVICES);
export const fetchOptions = createThunk(FETCH_OPTIONS);
export const clearOptions = createAction(CLEAR_OPTIONS);
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_DEVICES]: function(getState, payload, dispatch) {
const actionPayload = {
action: 'getDevices',
...payload
};
[FETCH_OPTIONS]: function(getState, payload, dispatch) {
dispatch(set({
section,
isFetching: true
}));
const promise = requestAction(actionPayload);
const promise = requestAction(payload);
promise.done((data) => {
dispatch(set({
@ -56,7 +51,7 @@ export const actionHandlers = handleThunks({
isFetching: false,
isPopulated: true,
error: null,
items: data.devices || []
items: data.options || []
}));
});
@ -76,7 +71,7 @@ export const actionHandlers = handleThunks({
export const reducers = createHandleActions({
[CLEAR_DEVICES]: function(state) {
[CLEAR_OPTIONS]: function(state) {
return updateSectionState(state, section, defaultState);
}

View File

@ -5,7 +5,7 @@ function createMovieQualityProfileSelector() {
return createSelector(
(state) => state.settings.qualityProfiles.items,
createMovieSelector(),
(qualityProfiles, movie) => {
(qualityProfiles, movie = {}) => {
return qualityProfiles.find((profile) => {
return profile.id === movie.qualityProfileId;
});

View File

@ -1,7 +1,6 @@
/* stylelint-disable */
@import '~normalize.css/normalize.css';
@import 'scaffolding.css';
@import '/Content/Fonts/fonts.css';
@import "~normalize.css/normalize.css";
@import "scaffolding.css";
/* stylelint-enable */

View File

@ -96,7 +96,7 @@ class BackupRow extends Component {
<TableRowCell>
<Link
to={path}
to={`${window.Radarr.urlBase}${path}`}
noRouter={true}
>
{name}

View File

@ -1,8 +1,4 @@
.updateAvailable {
display: flex;
}
.upToDate {
.messageContainer {
display: flex;
margin-bottom: 20px;
}
@ -12,7 +8,7 @@
font-size: 30px;
}
.upToDateMessage {
.message {
padding-left: 5px;
font-size: 18px;
line-height: 30px;
@ -49,7 +45,7 @@
font-size: 16px;
}
.branch {
.label {
composes: label from '~Components/Label.css';
margin-left: 10px;

View File

@ -1,6 +1,6 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import React, { Component, Fragment } from 'react';
import { icons, kinds } from 'Helpers/Props';
import formatDate from 'Utilities/Date/formatDate';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@ -19,25 +19,35 @@ class Updates extends Component {
render() {
const {
currentVersion,
isFetching,
isPopulated,
error,
updatesError,
generalSettingsError,
items,
isInstallingUpdate,
updateMechanism,
shortDateFormat,
onInstallLatestPress
} = this.props;
const hasUpdates = isPopulated && !error && items.length > 0;
const noUpdates = isPopulated && !error && !items.length;
const hasError = !!(updatesError || generalSettingsError);
const hasUpdates = isPopulated && !hasError && items.length > 0;
const noUpdates = isPopulated && !hasError && !items.length;
const hasUpdateToInstall = hasUpdates && _.some(items, { installable: true, latest: true });
const noUpdateToInstall = hasUpdates && !hasUpdateToInstall;
const externalUpdaterMessages = {
external: 'Unable to update Radarr directly, Radarr is configured to use an external update mechanism',
apt: 'Unable to update Radarr directly, use apt to install the update',
docker: 'Unable to update Radarr directly, update the docker container to receive the update'
};
return (
<PageContent title="Updates">
<PageContentBodyConnector>
{
!isPopulated && !error &&
!isPopulated && !hasError &&
<LoadingIndicator />
}
@ -48,15 +58,30 @@ class Updates extends Component {
{
hasUpdateToInstall &&
<div className={styles.updateAvailable}>
<SpinnerButton
className={styles.updateAvailable}
kind={kinds.PRIMARY}
isSpinning={isInstallingUpdate}
onPress={onInstallLatestPress}
>
Install Latest
</SpinnerButton>
<div className={styles.messageContainer}>
{
updateMechanism === 'builtIn' || updateMechanism === 'script' ?
<SpinnerButton
className={styles.updateAvailable}
kind={kinds.PRIMARY}
isSpinning={isInstallingUpdate}
onPress={onInstallLatestPress}
>
Install Latest
</SpinnerButton> :
<Fragment>
<Icon
name={icons.WARNING}
kind={kinds.WARNING}
size={30}
/>
<div className={styles.message}>
{externalUpdaterMessages[updateMechanism] || externalUpdaterMessages.external}
</div>
</Fragment>
}
{
isFetching &&
@ -70,13 +95,14 @@ class Updates extends Component {
{
noUpdateToInstall &&
<div className={styles.upToDate}>
<div className={styles.messageContainer}>
<Icon
className={styles.upToDateIcon}
name={icons.CHECK_CIRCLE}
size={30}
/>
<div className={styles.upToDateMessage}>
<div className={styles.message}>
The latest version of Radarr is already installed
</div>
@ -108,13 +134,25 @@ class Updates extends Component {
<div className={styles.date}>{formatDate(update.releaseDate, shortDateFormat)}</div>
{
update.branch !== 'master' &&
update.branch === 'master' ?
null:
<Label
className={styles.branch}
className={styles.label}
>
{update.branch}
</Label>
}
{
update.version === currentVersion ?
<Label
className={styles.label}
kind={kinds.SUCCESS}
>
Currently Installed
</Label> :
null
}
</div>
{
@ -144,11 +182,18 @@ class Updates extends Component {
}
{
!!error &&
!!updatesError &&
<div>
Failed to fetch updates
</div>
}
{
!!generalSettingsError &&
<div>
Failed to update settings
</div>
}
</PageContentBodyConnector>
</PageContent>
);
@ -157,11 +202,14 @@ class Updates extends Component {
}
Updates.propTypes = {
currentVersion: PropTypes.string.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
updatesError: PropTypes.object,
generalSettingsError: PropTypes.object,
items: PropTypes.array.isRequired,
isInstallingUpdate: PropTypes.bool.isRequired,
updateMechanism: PropTypes.string,
shortDateFormat: PropTypes.string.isRequired,
onInstallLatestPress: PropTypes.func.isRequired
};

View File

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchGeneralSettings } from 'Store/Actions/settingsActions';
import { fetchUpdates } from 'Store/Actions/systemActions';
import { executeCommand } from 'Store/Actions/commandActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
@ -11,23 +12,35 @@ import Updates from './Updates';
function createMapStateToProps() {
return createSelector(
(state) => state.app.version,
(state) => state.system.updates,
(state) => state.settings.general,
createUISettingsSelector(),
createCommandExecutingSelector(commandNames.APPLICATION_UPDATE),
(updates, uiSettings, isInstallingUpdate) => {
(
currentVersion,
updates,
generalSettings,
uiSettings,
isInstallingUpdate
) => {
const {
isFetching,
isPopulated,
error,
error: updatesError,
items
} = updates;
const isFetching = updates.isFetching || generalSettings.isFetching;
const isPopulated = updates.isPopulated && generalSettings.isPopulated;
return {
currentVersion,
isFetching,
isPopulated,
error,
updatesError,
generalSettingsError: generalSettings.error,
items,
isInstallingUpdate,
updateMechanism: generalSettings.item.updateMechanism,
shortDateFormat: uiSettings.shortDateFormat
};
}
@ -35,8 +48,9 @@ function createMapStateToProps() {
}
const mapDispatchToProps = {
fetchUpdates,
executeCommand
dispatchFetchUpdates: fetchUpdates,
dispatchFetchGeneralSettings: fetchGeneralSettings,
dispatchExecuteCommand: executeCommand
};
class UpdatesConnector extends Component {
@ -45,14 +59,15 @@ class UpdatesConnector extends Component {
// Lifecycle
componentDidMount() {
this.props.fetchUpdates();
this.props.dispatchFetchUpdates();
this.props.dispatchFetchGeneralSettings();
}
//
// Listeners
onInstallLatestPress = () => {
this.props.executeCommand({ name: commandNames.APPLICATION_UPDATE });
this.props.dispatchExecuteCommand({ name: commandNames.APPLICATION_UPDATE });
}
//
@ -69,8 +84,9 @@ class UpdatesConnector extends Component {
}
UpdatesConnector.propTypes = {
fetchUpdates: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
dispatchFetchUpdates: PropTypes.func.isRequired,
dispatchFetchGeneralSettings: PropTypes.func.isRequired,
dispatchExecuteCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(UpdatesConnector);

View File

@ -1,30 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<!-- Chrome, Opera, and Firefox OS -->
<meta name="theme-color" content="#3a3f51"/>
<meta name="theme-color" content="#3a3f51" />
<!-- Windows Phone -->
<meta name="msapplication-navbutton-color" content="#3a3f51"/>
<meta name="msapplication-navbutton-color" content="#3a3f51" />
<meta name="description" content="Radarr">
<meta name="description" content="Radarr (Preview)" />
<link rel="apple-touch-icon" sizes="180x180" href="/Content/Images/Icons/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/Content/Images/Icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/Content/Images/Icons/favicon-16x16.png">
<link rel="manifest" href="/Content/Images/Icons/manifest.json">
<link rel="mask-icon" href="/Content/Images/Icons/safari-pinned-tab.svg" color="#00ccff">
<link rel="shortcut icon" type="image/ico" href="/favicon.ico" data-no-hash />
<meta name="msapplication-config" content="/Content/Images/Icons/browserconfig.xml">
<link
rel="apple-touch-icon"
sizes="180x180"
href="/Content/Images/Icons/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/Content/Images/Icons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/Content/Images/Icons/favicon-16x16.png"
/>
<link rel="manifest" href="/Content/Images/Icons/manifest.json" />
<link
rel="mask-icon"
href="/Content/Images/Icons/safari-pinned-tab.svg"
color="#00ccff"
/>
<link
rel="shortcut icon"
type="image/ico"
href="/favicon.ico"
data-no-hash
/>
<meta
name="msapplication-config"
content="/Content/Images/Icons/browserconfig.xml"
/>
<link rel="stylesheet" type="text/css" href="/Content/styles.css">
<link rel="stylesheet" type="text/css" href="/Content/styles.css" />
<link rel="stylesheet" type="text/css" href="/Content/Fonts/fonts.css" />
<title>Radarr</title>
<title>Radarr (Preview)</title>
<!--
The super basic styling for .root will live here,
@ -57,5 +83,4 @@
<script src="/vendor.js"></script>
<script src="/preload.js"></script>
<script src="/index.js"></script>
</html>

View File

@ -1,232 +1,291 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<!-- Chrome, Opera, and Firefox OS -->
<meta name="theme-color" content="#3a3f51" />
<!-- Windows Phone -->
<meta name="msapplication-navbutton-color" content="#3a3f51" />
<!-- Chrome, Opera, and Firefox OS -->
<meta name="theme-color" content="#3a3f51"/>
<!-- Windows Phone -->
<meta name="msapplication-navbutton-color" content="#3a3f51"/>
<meta name="description" content="Radarr (Preview)" />
<meta name="description" content="Radarr">
<link
rel="apple-touch-icon"
sizes="180x180"
href="/Content/Images/Icons/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/Content/Images/Icons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/Content/Images/Icons/favicon-16x16.png"
/>
<link rel="manifest" href="/Content/Images/Icons/manifest.json" />
<link
rel="mask-icon"
href="/Content/Images/Icons/safari-pinned-tab.svg"
color="#00ccff"
/>
<link
rel="shortcut icon"
type="image/ico"
href="/favicon.ico"
data-no-hash
/>
<meta
name="msapplication-config"
content="/Content/Images/Icons/browserconfig.xml"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/Content/Images/Icons/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/Content/Images/Icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/Content/Images/Icons/favicon-16x16.png">
<link rel="manifest" href="/Content/Images/Icons/manifest.json">
<link rel="mask-icon" href="/Content/Images/Icons/safari-pinned-tab.svg" color="#00ccff">
<link rel="shortcut icon" type="image/ico" href="/favicon.ico" data-no-hash />
<meta name="msapplication-config" content="/Content/Images/Icons/browserconfig.xml">
<link rel="stylesheet" type="text/css" href="/Content/styles.css" />
<link rel="stylesheet" type="text/css" href="/Content/Fonts/fonts.css" />
<link rel="stylesheet" type="text/css" href="/Content/styles.css">
<title>Login - Radarr</title>
<title>Login - Radarr</title>
<style>
body {
background-color: #f5f7fa;
color: #656565;
font-family: "Roboto", "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.center {
display: flex;
justify-content: center;
}
.content {
flex: 0 0 325px;
}
.logo {
width: 32px;
height: 32px;
}
.panel {
margin-top: 50px;
border-radius: 4px;
}
.panel-header {
display: flex;
justify-content: center;
padding: 10px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
background-color: #3a3f51;
}
.panel-body {
padding: 20px;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
background-color: #fff;
}
.sign-in {
padding: 10px 0;
text-align: center;
}
.form-group {
margin: 20px 0;
}
.form-input {
box-sizing: border-box;
padding: 6px 16px;
width: 100%;
height: 35px;
border: 1px solid #dde6e9;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.form-input:focus {
outline: 0;
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
}
.button {
overflow: hidden;
margin-top: 20px;
padding: 10px 0;
width: 100%;
border: 1px solid;
border-color: #5899eb;
border-radius: 4px;
background-color: #5d9cec;
color: #fff;
vertical-align: middle;
text-align: center;
white-space: nowrap;
line-height: normal;
}
.button:hover {
border-color: #3483e7;
background-color: #4b91ea;
color: #fff;
text-decoration: none;
}
.copy {
margin-top: 15px;
text-align: center;
font-size: 14px;
}
.remember-me-container {
display: flex;
justify-content: space-between;
line-height: 25px;
}
.remember-me {
font-size: 14px;
}
.forgot-password {
margin-left: auto;
color: #909fa7;
text-decoration: none;
font-size: 13px;
}
.forgot-password:focus,
.forgot-password:hover {
color: #748690;
text-decoration: underline;
}
.forgot-password:visited {
color: #748690;
}
.login-failed {
margin-top: 20px;
color: #f05050;
font-size: 14px;
}
.hidden {
display: none;
}
@media only screen and (min-device-width: 375px) and (max-device-width: 812px) {
.form-input {
font-size: 16px;
<style>
body {
background-color: #f5f7fa;
color: #656565;
font-family: "Roboto", "open sans", "Helvetica Neue", Helvetica, Arial,
sans-serif;
}
}
</style>
</head>
<body>
<div class="center">
<div class="content">
<div class="panel">
<div class="panel-header">
<img src="/Content/Images/logo.svg" alt="Image" class="logo">
</div>
.center {
display: flex;
justify-content: center;
}
<div class="panel-body">
<div class="sign-in">
SIGN IN TO CONTINUE
.content {
flex: 0 0 325px;
}
.logo {
width: 32px;
height: 32px;
}
.panel {
margin-top: 50px;
border-radius: 4px;
}
.panel-header {
display: flex;
justify-content: center;
padding: 10px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
background-color: #3a3f51;
}
.panel-body {
padding: 20px;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
background-color: #fff;
}
.sign-in {
padding: 10px 0;
text-align: center;
}
.form-group {
margin: 20px 0;
}
.form-input {
box-sizing: border-box;
padding: 6px 16px;
width: 100%;
height: 35px;
border: 1px solid #dde6e9;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.form-input:focus {
outline: 0;
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075),
0 0 8px rgba(102, 175, 233, 0.6);
}
.button {
overflow: hidden;
margin-top: 20px;
padding: 10px 0;
width: 100%;
border: 1px solid;
border-color: #5899eb;
border-radius: 4px;
background-color: #5d9cec;
color: #fff;
vertical-align: middle;
text-align: center;
white-space: nowrap;
line-height: normal;
}
.button:hover {
border-color: #3483e7;
background-color: #4b91ea;
color: #fff;
text-decoration: none;
}
.copy {
margin-top: 15px;
text-align: center;
font-size: 14px;
}
.remember-me-container {
display: flex;
justify-content: space-between;
line-height: 25px;
}
.remember-me {
font-size: 14px;
}
.forgot-password {
margin-left: auto;
color: #909fa7;
text-decoration: none;
font-size: 13px;
}
.forgot-password:focus,
.forgot-password:hover {
color: #748690;
text-decoration: underline;
}
.forgot-password:visited {
color: #748690;
}
.login-failed {
margin-top: 20px;
color: #f05050;
font-size: 14px;
}
.hidden {
display: none;
}
@media only screen and (min-device-width: 375px) and (max-device-width: 812px) {
.form-input {
font-size: 16px;
}
}
</style>
</head>
<body>
<div class="center">
<div class="content">
<div class="panel">
<div class="panel-header">
<img src="/Content/Images/logo.svg" alt="Image" class="logo" />
</div>
<div class="panel-body">
<div class="sign-in">
SIGN IN TO CONTINUE
</div>
<form
role="form"
data-parsley-validate=""
novalidate=""
class="mb-lg"
method="POST"
>
<div class="form-group">
<input
type="email"
name="username"
class="form-input"
placeholder="Username"
autocomplete="off"
pattern=".{1,}"
required
title="User name is required"
autoFocus="true"
/>
</div>
<div class="form-group">
<input
type="password"
name="password"
class="form-input"
placeholder="Password"
required
/>
</div>
<div class="remember-me-container">
<span class="remember-me">
<input
type="checkbox"
name="rememberMe"
id="rememberMe"
checked="checked"
/>
<label for="rememberMe">Remember Me</label>
</span>
<a
href="https://github.com/Radarr/Radarr/wiki/Forgot-my-Password"
class="forgot-password"
>Forgot your password?</a
>
</div>
<button type="submit" class="button">Login</button>
<div id="login-failed" class="login-failed hidden">
Incorrect Username or Password
</div>
</form>
</div>
</div>
<form role="form" data-parsley-validate="" novalidate="" class="mb-lg" method="POST">
<div class="form-group">
<input type="email" name="username" class="form-input" placeholder="Username" autocomplete="off" pattern=".{1,}" required title="User name is required" autoFocus="true">
</div>
<div class="form-group">
<input type="password" name="password" class="form-input" placeholder="Password" required>
</div>
<div class="remember-me-container">
<span class="remember-me">
<input type="checkbox" name="rememberMe" id="rememberMe" checked="checked">
<label for="rememberMe">Remember Me</label>
</span>
<a href="https://github.com/Radarr/Radarr/wiki/Forgot-my-Password" class="forgot-password">Forgot your password?</a>
</div>
<button type="submit" class="button">Login</button>
<div id="login-failed" class="login-failed hidden">
Incorrect Username or Password
</div>
</form>
<div id="copy" class="copy hidden">
<span>&copy;</span>
<span id="year"></span>
<span>-</span>
<span>Radarr</span>
</div>
</div>
</div>
</body>
<div id="copy" class="copy hidden">
<span>&copy;</span>
<span id="year"></span>
<span>-</span>
<span>Radarr</span>
</div>
</div>
</div>
</body>
<script type="text/javascript">
var yearSpan = document.getElementById("year");
yearSpan.innerHTML = "2010-" + new Date().getFullYear();
<script type="text/javascript">
var yearSpan = document.getElementById('year');
yearSpan.innerHTML = '2010-' + new Date().getFullYear();
var copyDiv = document.getElementById("copy");
copyDiv.classList.remove("hidden");
var copyDiv = document.getElementById('copy');
copyDiv.classList.remove('hidden');
if (window.location.search.indexOf("loginFailed=true") > -1) {
var loginFailedDiv = document.getElementById("login-failed");
if (window.location.search.indexOf('loginFailed=true') > -1) {
var loginFailedDiv = document.getElementById('login-failed');
loginFailedDiv.classList.remove('hidden');
}
</script>
loginFailedDiv.classList.remove("hidden");
}
</script>
</html>

View File

@ -15,6 +15,7 @@ protected override void MapToResource(DownloadClientResource resource, DownloadC
resource.Enable = definition.Enable;
resource.Protocol = definition.Protocol;
resource.Priority = definition.Priority;
}
protected override void MapToModel(DownloadClientDefinition definition, DownloadClientResource resource)
@ -23,6 +24,7 @@ protected override void MapToModel(DownloadClientDefinition definition, Download
definition.Enable = resource.Enable;
definition.Protocol = resource.Protocol;
definition.Priority = resource.Priority;
}
protected override void Validate(DownloadClientDefinition definition, bool includeWarnings)

View File

@ -6,5 +6,6 @@ public class DownloadClientResource : ProviderResource
{
public bool Enable { get; set; }
public DownloadProtocol Protocol { get; set; }
public int Priority { get; set; }
}
}

View File

@ -86,7 +86,7 @@ public void paths_should_not_be_equal(string first, string second)
{
first.AsOsAgnostic().PathEquals(second.AsOsAgnostic()).Should().BeFalse();
}
[Test]
public void should_return_false_when_not_a_child()
{
@ -113,6 +113,7 @@ public void should_return_true_when_folder_is_parent_of_a_file()
[TestCase(@"C:\Test\", @"C:\Test\mydir")]
[TestCase(@"C:\Test\", @"C:\Test\mydir\")]
[TestCase(@"C:\Test", @"C:\Test\30.Rock.S01E01.Pilot.avi")]
[TestCase(@"C:\", @"C:\Test\30.Rock.S01E01.Pilot.avi")]
public void path_should_be_parent(string parentPath, string childPath)
{
parentPath.AsOsAgnostic().IsParentPath(childPath.AsOsAgnostic()).Should().BeTrue();
@ -137,18 +138,34 @@ public void path_should_be_parent_on_windows_only(string parentPath, string chil
}
[TestCase(@"C:\Test\mydir", @"C:\Test")]
[TestCase(@"C:\Test\", @"C:")]
[TestCase(@"C:\Test\", @"C:\")]
[TestCase(@"C:\", null)]
public void path_should_return_parent(string path, string parentPath)
[TestCase(@"\\server\share", null)]
[TestCase(@"\\server\share\test", @"\\server\share")]
public void path_should_return_parent_windows(string path, string parentPath)
{
WindowsOnly();
path.GetParentPath().Should().Be(parentPath);
}
[TestCase(@"/", null)]
[TestCase(@"/test", "/")]
public void path_should_return_parent_mono(string path, string parentPath)
{
MonoOnly();
path.GetParentPath().Should().Be(parentPath);
}
[Test]
public void path_should_return_parent_for_oversized_path()
{
var path = @"/media/2e168617-f2ae-43fb-b88c-3663af1c8eea/downloads/sabnzbd/nzbdrone/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories";
var parentPath = @"/media/2e168617-f2ae-43fb-b88c-3663af1c8eea/downloads/sabnzbd/nzbdrone/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing";
MonoOnly();
// This test will fail on Windows if long path support is not enabled: https://www.howtogeek.com/266621/how-to-make-windows-10-accept-file-paths-over-260-characters/
// It will also fail if the app isn't configured to use long path (such as resharper): https://blogs.msdn.microsoft.com/jeremykuhne/2016/07/30/net-4-6-2-and-long-paths-on-windows-10/
var path = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\nzbdrone\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories".AsOsAgnostic();
var parentPath = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\nzbdrone\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing".AsOsAgnostic();
path.GetParentPath().Should().Be(parentPath);
}

View File

@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using NzbDrone.Common.EnvironmentInfo;
namespace NzbDrone.Common.Disk
{
public static class SystemFolders
{
public static List<string> GetSystemFolders()
{
if (OsInfo.IsWindows)
{
return new List<string> { Environment.GetFolderPath(Environment.SpecialFolder.Windows) };
}
if (OsInfo.IsOsx)
{
return new List<string> { "/System" };
}
return new List<string>
{
"/bin",
"/boot",
"/lib",
"/sbin",
"/proc"
};
}
}
}

View File

@ -1,7 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace NzbDrone.Common.Exceptions
{
@ -10,19 +7,16 @@ public class RadarrStartupException : NzbDroneException
public RadarrStartupException(string message, params object[] args)
: base("Radarr failed to start: " + string.Format(message, args))
{
}
public RadarrStartupException(string message)
: base("Radarr failed to start: " + message)
{
}
public RadarrStartupException()
: base("Radarr failed to start")
{
}
public RadarrStartupException(Exception innerException, string message, params object[] args)
@ -38,7 +32,6 @@ public RadarrStartupException(Exception innerException, string message)
public RadarrStartupException(Exception innerException)
: base("Radarr failed to start: " + innerException.Message)
{
}
}
}

View File

@ -98,6 +98,7 @@
<Compile Include="Disk\RelativeFileSystemModel.cs" />
<Compile Include="Disk\FileSystemModel.cs" />
<Compile Include="Disk\FileSystemResult.cs" />
<Compile Include="Disk\SystemFolders.cs" />
<Compile Include="EnvironmentInfo\IOperatingSystemVersionInfo.cs" />
<Compile Include="EnvironmentInfo\IOsVersionAdapter.cs" />
<Compile Include="EnvironmentInfo\IPlatformInfo.cs" />

View File

@ -27,7 +27,7 @@ public interface IProcessProvider
bool Exists(string processName);
ProcessPriorityClass GetCurrentProcessPriority();
Process Start(string path, string args = null, StringDictionary environmentVariables = null, Action<string> onOutputDataReceived = null, Action<string> onErrorDataReceived = null);
Process SpawnNewProcess(string path, string args = null, StringDictionary environmentVariables = null);
Process SpawnNewProcess(string path, string args = null, StringDictionary environmentVariables = null, bool noWindow = false);
ProcessOutput StartAndCapture(string path, string args = null, StringDictionary environmentVariables = null);
}
@ -108,11 +108,7 @@ public void OpenDefaultBrowser(string url)
public Process Start(string path, string args = null, StringDictionary environmentVariables = null, Action<string> onOutputDataReceived = null, Action<string> onErrorDataReceived = null)
{
if (PlatformInfo.IsMono && path.EndsWith(".exe", StringComparison.InvariantCultureIgnoreCase))
{
args = GetMonoArgs(path, args);
path = "mono";
}
(path, args) = GetPathAndArgs(path, args);
var logger = LogManager.GetLogger(new FileInfo(path).Name);
@ -190,17 +186,16 @@ public Process Start(string path, string args = null, StringDictionary environme
return process;
}
public Process SpawnNewProcess(string path, string args = null, StringDictionary environmentVariables = null)
public Process SpawnNewProcess(string path, string args = null, StringDictionary environmentVariables = null, bool noWindow = false)
{
if (PlatformInfo.IsMono && path.EndsWith(".exe", StringComparison.InvariantCultureIgnoreCase))
{
args = GetMonoArgs(path, args);
path = "mono";
}
(path, args) = GetPathAndArgs(path, args);
_logger.Debug("Starting {0} {1}", path, args);
var startInfo = new ProcessStartInfo(path, args);
startInfo.CreateNoWindow = noWindow;
startInfo.UseShellExecute = !noWindow;
var process = new Process
{
StartInfo = startInfo
@ -333,7 +328,6 @@ private List<Process> GetProcessesByName(string name)
var monoProcesses = Process.GetProcessesByName("mono")
.Union(Process.GetProcessesByName("mono-sgen"))
.Union(Process.GetProcessesByName("mono-sgen32"))
.Where(process =>
process.Modules.Cast<ProcessModule>()
.Any(module =>
@ -359,9 +353,19 @@ private List<Process> GetProcessesByName(string name)
return processes;
}
private string GetMonoArgs(string path, string args)
private (string Path, string Args) GetPathAndArgs(string path, string args)
{
return string.Format("--debug {0} {1}", path, args);
if (PlatformInfo.IsMono && path.EndsWith(".exe", StringComparison.InvariantCultureIgnoreCase))
{
return ("mono", $"--debug {path} {args}");
}
if (OsInfo.IsWindows && path.EndsWith(".bat", StringComparison.InvariantCultureIgnoreCase))
{
return ("cmd.exe", $"/c {path} {args}");
}
return (path, args);
}
}
}

View File

@ -6,6 +6,8 @@
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Movies;
using System.Collections.Generic;
using NzbDrone.Core.Languages;
namespace NzbDrone.Core.Test.Datastore
{
@ -64,10 +66,12 @@ public void one_to_one_should_not_query_db_if_foreign_key_is_zero()
public void embedded_document_as_json()
{
var quality = new QualityModel { Quality = Quality.Bluray720p, Revision = new Revision(version: 2 )};
var languages = new List<Language> { Language.English };
var history = Builder<History.History>.CreateNew()
.With(c => c.Id = 0)
.With(c => c.Quality = quality)
.With(c => c.Languages = languages)
.Build();
Db.Insert(history);
@ -79,14 +83,18 @@ public void embedded_document_as_json()
[Test]
public void embedded_list_of_document_with_json()
{
var languages = new List<Language> { Language.English };
var history = Builder<History.History>.CreateListOfSize(2)
.All().With(c => c.Id = 0)
.With(c => c.Languages = languages)
.Build().ToList();
history[0].Quality = new QualityModel { Quality = Quality.HDTV1080p, Revision = new Revision(version: 2)};
history[1].Quality = new QualityModel { Quality = Quality.Bluray720p, Revision = new Revision(version: 2)};
Db.InsertMany(history);
var returnedHistory = Db.All<History.History>();

View File

@ -0,0 +1,153 @@
using System.Linq;
using FluentAssertions;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore.Migration;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Datastore.Migration
{
[TestFixture]
public class add_download_client_priorityFixture : MigrationTest<add_download_client_priority>
{
[Test]
public void should_set_prio_to_one()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("DownloadClients").Row(new
{
Enable = 1,
Name = "Deluge",
Implementation = "Deluge",
Settings = new DelugeSettings85
{
Host = "127.0.0.1",
TvCategory = "abc",
UrlBase = "/my/"
}.ToJson(),
ConfigContract = "DelugeSettings"
});
});
var items = db.Query<DownloadClientDefinition132>("SELECT * FROM DownloadClients");
items.Should().HaveCount(1);
items.First().Priority.Should().Be(1);
}
[Test]
public void should_renumber_prio_for_enabled_clients()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("DownloadClients").Row(new
{
Enable = 1,
Name = "Deluge",
Implementation = "Deluge",
Settings = new DelugeSettings85
{
Host = "127.0.0.1",
TvCategory = "abc",
UrlBase = "/my/"
}.ToJson(),
ConfigContract = "DelugeSettings"
}).Row(new
{
Enable = 1,
Name = "Deluge2",
Implementation = "Deluge",
Settings = new DelugeSettings85
{
Host = "127.0.0.1",
TvCategory = "abc",
UrlBase = "/my/"
}.ToJson(),
ConfigContract = "DelugeSettings"
}).Row(new
{
Enable = 1,
Name = "sab",
Implementation = "Sabnzbd",
Settings = new SabnzbdSettings81
{
Host = "127.0.0.1",
TvCategory = "abc"
}.ToJson(),
ConfigContract = "SabnzbdSettings"
});
});
var items = db.Query<DownloadClientDefinition132>("SELECT * FROM DownloadClients");
items.Should().HaveCount(3);
items[0].Priority.Should().Be(1);
items[1].Priority.Should().Be(2);
items[2].Priority.Should().Be(1);
}
[Test]
public void should_not_renumber_prio_for_disabled_clients()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("DownloadClients").Row(new
{
Enable = 0,
Name = "Deluge",
Implementation = "Deluge",
Settings = new DelugeSettings85
{
Host = "127.0.0.1",
TvCategory = "abc",
UrlBase = "/my/"
}.ToJson(),
ConfigContract = "DelugeSettings"
}).Row(new
{
Enable = 0,
Name = "Deluge2",
Implementation = "Deluge",
Settings = new DelugeSettings85
{
Host = "127.0.0.1",
TvCategory = "abc",
UrlBase = "/my/"
}.ToJson(),
ConfigContract = "DelugeSettings"
}).Row(new
{
Enable = 0,
Name = "sab",
Implementation = "Sabnzbd",
Settings = new SabnzbdSettings81
{
Host = "127.0.0.1",
TvCategory = "abc"
}.ToJson(),
ConfigContract = "SabnzbdSettings"
});
});
var items = db.Query<DownloadClientDefinition132>("SELECT * FROM DownloadClients");
items.Should().HaveCount(3);
items[0].Priority.Should().Be(1);
items[1].Priority.Should().Be(1);
items[1].Priority.Should().Be(1);
}
}
public class DownloadClientDefinition132
{
public int Id { get; set; }
public bool Enable { get; set; }
public int Priority { get; set; }
public string Name { get; set; }
public string Implementation { get; set; }
public JObject Settings { get; set; }
public string ConfigContract { get; set; }
}
}

View File

@ -19,7 +19,11 @@ public void Setup()
{
_remoteMovie = new RemoteMovie
{
Release = new ReleaseInfo() { DownloadProtocol = DownloadProtocol.Torrent }
Release = new ReleaseInfo
{
Title = "Movie.title.1998",
DownloadProtocol = DownloadProtocol.Torrent
}
};
}
@ -69,5 +73,15 @@ public void should_compare_case_insensitive()
Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeFalse();
}
[TestCase("How the Earth Was Made S02 Disc 1 1080i Blu-ray DTS-HD MA 2.0 AVC-TrollHD")]
[TestCase("The Universe S03 Disc 1 1080p Blu-ray LPCM 2.0 AVC-TrollHD")]
[TestCase("HELL ON WHEELS S02 1080P FULL BLURAY AVC DTS-HD MA 5 1")]
[TestCase("Game.of.Thrones.S06.2016.DISC.3.BluRay.1080p.AVC.Atmos.TrueHD7.1-MTeam")]
[TestCase("Game of Thrones S05 Disc 1 BluRay 1080p AVC Atmos TrueHD 7 1-MTeam")]
public void should_return_false_if_matches_disc_format(string title)
{
_remoteMovie.Release.Title = title;
Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeFalse();
}
}
}

View File

@ -0,0 +1,229 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using FizzWare.NBuilder;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.Download;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Download
{
[TestFixture]
public class DownloadClientProviderFixture : CoreTest<DownloadClientProvider>
{
private List<IDownloadClient> _downloadClients;
private List<DownloadClientStatus> _blockedProviders;
private int _nextId;
[SetUp]
public void SetUp()
{
_downloadClients = new List<IDownloadClient>();
_blockedProviders = new List<DownloadClientStatus>();
_nextId = 1;
Mocker.GetMock<IDownloadClientFactory>()
.Setup(v => v.GetAvailableProviders())
.Returns(_downloadClients);
Mocker.GetMock<IDownloadClientStatusService>()
.Setup(v => v.GetBlockedProviders())
.Returns(_blockedProviders);
}
private Mock<IDownloadClient> WithUsenetClient(int priority = 0)
{
var mock = new Mock<IDownloadClient>(MockBehavior.Default);
mock.SetupGet(s => s.Definition)
.Returns(Builder<DownloadClientDefinition>
.CreateNew()
.With(v => v.Id = _nextId++)
.With(v => v.Priority = priority)
.Build());
_downloadClients.Add(mock.Object);
mock.SetupGet(v => v.Protocol).Returns(DownloadProtocol.Usenet);
return mock;
}
private Mock<IDownloadClient> WithTorrentClient(int priority = 0)
{
var mock = new Mock<IDownloadClient>(MockBehavior.Default);
mock.SetupGet(s => s.Definition)
.Returns(Builder<DownloadClientDefinition>
.CreateNew()
.With(v => v.Id = _nextId++)
.With(v => v.Priority = priority)
.Build());
_downloadClients.Add(mock.Object);
mock.SetupGet(v => v.Protocol).Returns(DownloadProtocol.Torrent);
return mock;
}
private void GivenBlockedClient(int id)
{
_blockedProviders.Add(new DownloadClientStatus
{
ProviderId = id,
DisabledTill = DateTime.UtcNow.AddHours(3)
});
}
[Test]
public void should_roundrobin_over_usenet_client()
{
WithUsenetClient();
WithUsenetClient();
WithUsenetClient();
WithTorrentClient();
var client1 = Subject.GetDownloadClient(DownloadProtocol.Usenet);
var client2 = Subject.GetDownloadClient(DownloadProtocol.Usenet);
var client3 = Subject.GetDownloadClient(DownloadProtocol.Usenet);
var client4 = Subject.GetDownloadClient(DownloadProtocol.Usenet);
var client5 = Subject.GetDownloadClient(DownloadProtocol.Usenet);
client1.Definition.Id.Should().Be(1);
client2.Definition.Id.Should().Be(2);
client3.Definition.Id.Should().Be(3);
client4.Definition.Id.Should().Be(1);
client5.Definition.Id.Should().Be(2);
}
[Test]
public void should_roundrobin_over_torrent_client()
{
WithUsenetClient();
WithTorrentClient();
WithTorrentClient();
WithTorrentClient();
var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client5 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
client1.Definition.Id.Should().Be(2);
client2.Definition.Id.Should().Be(3);
client3.Definition.Id.Should().Be(4);
client4.Definition.Id.Should().Be(2);
client5.Definition.Id.Should().Be(3);
}
[Test]
public void should_roundrobin_over_protocol_separately()
{
WithUsenetClient();
WithTorrentClient();
WithTorrentClient();
var client1 = Subject.GetDownloadClient(DownloadProtocol.Usenet);
var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
client1.Definition.Id.Should().Be(1);
client2.Definition.Id.Should().Be(2);
client3.Definition.Id.Should().Be(3);
client4.Definition.Id.Should().Be(2);
}
[Test]
public void should_skip_blocked_torrent_client()
{
WithUsenetClient();
WithTorrentClient();
WithTorrentClient();
WithTorrentClient();
GivenBlockedClient(3);
var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client5 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
client1.Definition.Id.Should().Be(2);
client2.Definition.Id.Should().Be(4);
client3.Definition.Id.Should().Be(2);
client4.Definition.Id.Should().Be(4);
}
[Test]
public void should_not_skip_blocked_torrent_client_if_all_blocked()
{
WithUsenetClient();
WithTorrentClient();
WithTorrentClient();
WithTorrentClient();
GivenBlockedClient(2);
GivenBlockedClient(3);
GivenBlockedClient(4);
var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client5 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
client1.Definition.Id.Should().Be(2);
client2.Definition.Id.Should().Be(3);
client3.Definition.Id.Should().Be(4);
client4.Definition.Id.Should().Be(2);
}
[Test]
public void should_skip_secondary_prio_torrent_client()
{
WithUsenetClient();
WithTorrentClient(2);
WithTorrentClient();
WithTorrentClient();
var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client5 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
client1.Definition.Id.Should().Be(3);
client2.Definition.Id.Should().Be(4);
client3.Definition.Id.Should().Be(3);
client4.Definition.Id.Should().Be(4);
}
[Test]
public void should_not_skip_secondary_prio_torrent_client_if_primary_blocked()
{
WithUsenetClient();
WithTorrentClient(2);
WithTorrentClient(2);
WithTorrentClient();
GivenBlockedClient(4);
var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client5 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
client1.Definition.Id.Should().Be(2);
client2.Definition.Id.Should().Be(3);
client3.Definition.Id.Should().Be(2);
client4.Definition.Id.Should().Be(3);
}
}
}

View File

@ -10,6 +10,7 @@
using NzbDrone.Core.RemotePathMappings;
using NzbDrone.Common.Disk;
using NzbDrone.Core.Download.Clients;
using NzbDrone.Core.Exceptions;
namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests
{
@ -81,6 +82,7 @@ public void Setup()
DownloadRate = 7000000
});
Mocker.GetMock<INzbgetProxy>()
.Setup(v => v.GetVersion(It.IsAny<NzbgetSettings>()))
.Returns("14.0");
@ -277,16 +279,16 @@ public void should_report_deletestatus_dupe_as_failed()
}
[Test]
public void should_report_deletestatus_copy_as_failed()
public void should_skip_deletestatus_copy()
{
_completed.DeleteStatus = "COPY";
GivenQueue(null);
GivenHistory(_completed);
var result = Subject.GetItems().Single();
var result = Subject.GetItems().SingleOrDefault();
result.Status.Should().Be(DownloadItemStatus.Failed);
result.Should().BeNull();
}
[Test]
@ -350,7 +352,7 @@ public void Download_should_throw_if_failed()
var remoteMovie = CreateRemoteMovie();
Assert.Throws<DownloadClientException>(() => Subject.Download(remoteMovie));
Assert.Throws<DownloadClientRejectedReleaseException>(() => Subject.Download(remoteMovie));
}
[Test]

View File

@ -7,6 +7,8 @@
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Movies;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Languages;
using System.Collections.Generic;
namespace NzbDrone.Core.Test.Housekeeping.Housekeepers
{
@ -16,30 +18,32 @@ public class CleanupOrphanedMovieFilesFixture : DbTest<CleanupOrphanedMovieFiles
[Test]
public void should_delete_orphaned_episode_files()
{
var episodeFile = Builder<MovieFile>.CreateNew()
var movieFile = Builder<MovieFile>.CreateNew()
.With(h => h.Quality = new QualityModel())
.With(h => h.Languages = new List<Language> { Language.English})
.BuildNew();
Db.Insert(episodeFile);
Db.Insert(movieFile);
Subject.Clean();
AllStoredModels.Should().BeEmpty();
}
[Test]
public void should_not_delete_unorphaned_episode_files()
public void should_not_delete_unorphaned_movie_files()
{
var episodeFiles = Builder<MovieFile>.CreateListOfSize(2)
var movieFiles = Builder<MovieFile>.CreateListOfSize(2)
.All()
.With(h => h.Quality = new QualityModel())
.With(h => h.Languages = new List<Language> { Language.English })
.BuildListOfNew();
Db.InsertMany(episodeFiles);
Db.InsertMany(movieFiles);
var episode = Builder<Movie>.CreateNew()
.With(e => e.MovieFileId = episodeFiles.First().Id)
var movie = Builder<Movie>.CreateNew()
.With(e => e.MovieFileId = movieFiles.First().Id)
.BuildNew();
Db.Insert(episode);
Db.Insert(movie);
Subject.Clean();
AllStoredModels.Should().HaveCount(1);

View File

@ -1,9 +1,11 @@
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Languages;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.Framework;
using System.Collections.Generic;
namespace NzbDrone.Core.Test.MediaFiles
{
@ -16,12 +18,13 @@ public void Setup()
}
[Test]
public void get_files_by_series()
public void get_files_by_movie()
{
var files = Builder<MovieFile>.CreateListOfSize(10)
.All()
.With(c => c.Id = 0)
.With(c => c.Quality =new QualityModel())
.With(c => c.Quality = new QualityModel())
.With(c => c.Languages = new List<Language> { Language.English })
.Random(4)
.With(s => s.MovieId = 12)
.BuildListOfNew();
@ -29,10 +32,10 @@ public void get_files_by_series()
Db.InsertMany(files);
var seriesFiles = Subject.GetFilesByMovie(12);
var movieFiles = Subject.GetFilesByMovie(12);
seriesFiles.Should().HaveCount(4);
seriesFiles.Should().OnlyContain(c => c.MovieId == 12);
movieFiles.Should().HaveCount(4);
movieFiles.Should().OnlyContain(c => c.MovieId == 12);
}
}

View File

@ -30,7 +30,7 @@ public void get_runtime()
{
var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", "H264_sample.mp4");
Subject.GetRunTime(path).Seconds.Should().Be(10);
Subject.GetRunTime(path).Value.Seconds.Should().Be(10);
}
[Test]

View File

@ -34,7 +34,7 @@ private void GivenMovieIsReleased()
private void GivenMovieLastRefreshedMonthsAgo()
{
_movie.LastInfoSync = DateTime.UtcNow.AddDays(-90);
_movie.LastInfoSync = DateTime.UtcNow.AddDays(-190);
}
private void GivenMovieLastRefreshedYesterday()

View File

@ -147,6 +147,7 @@
<Compile Include="Datastore\Migration\147_custom_formatsFixture.cs" />
<Compile Include="Datastore\Migration\149_regex_required_tagsFixture.cs" />
<Compile Include="Datastore\Migration\150_fix_format_tags_double_underscoreFixture.cs" />
<Compile Include="Datastore\Migration\156_add_download_client_priorityFixture.cs" />
<Compile Include="Datastore\ObjectDatabaseFixture.cs" />
<Compile Include="Datastore\PagingSpecExtensionsTests\PagingOffsetFixture.cs" />
<Compile Include="Datastore\PagingSpecExtensionsTests\ToSortDirectionFixture.cs" />
@ -177,6 +178,7 @@
<Compile Include="DiskSpace\DiskSpaceServiceFixture.cs" />
<Compile Include="Download\CompletedDownloadServiceFixture.cs" />
<Compile Include="Download\DownloadApprovedReportsTests\DownloadApprovedFixture.cs" />
<Compile Include="Download\DownloadClientProviderFixture.cs" />
<Compile Include="Download\DownloadClientStatusServiceFixture.cs" />
<Compile Include="Download\DownloadClientTests\Blackhole\ScanWatchFolderFixture.cs" />
<Compile Include="Download\DownloadClientTests\Blackhole\TorrentBlackholeFixture.cs" />

View File

@ -87,6 +87,7 @@ public void adding_duplicated_mapping_should_throw(string host, string remotePat
}
[TestCase("my-server.localdomain", "/mnt/storage/downloads/tv", @"D:\mountedstorage\downloads\tv")]
[TestCase("My-Server.localdomain", "/mnt/storage/downloads/tv", @"D:\mountedstorage\downloads\tv")]
[TestCase("my-2server.localdomain", "/mnt/storage/downloads/tv", "/mnt/storage/downloads/tv")]
[TestCase("my-server.localdomain", "/mnt/storageabc/downloads/tv", "/mnt/storageabc/downloads/tv")]
public void should_remap_remote_to_local(string host, string remotePath, string expectedLocalPath)
@ -101,6 +102,7 @@ public void should_remap_remote_to_local(string host, string remotePath, string
}
[TestCase("my-server.localdomain", "/mnt/storage/downloads/tv", @"D:\mountedstorage\downloads\tv")]
[TestCase("My-Server.localdomain", "/mnt/storage/downloads/tv", @"D:\mountedstorage\downloads\tv")]
[TestCase("my-server.localdomain", "/mnt/storage/", @"D:\mountedstorage")]
[TestCase("my-2server.localdomain", "/mnt/storage/downloads/tv", "/mnt/storage/downloads/tv")]
[TestCase("my-server.localdomain", "/mnt/storageabc/downloads/tv", "/mnt/storageabc/downloads/tv")]

View File

@ -19,6 +19,7 @@ public FieldDefinitionAttribute(int order)
public bool Advanced { get; set; }
public Type SelectOptions { get; set; }
public string Section { get; set; }
public HiddenType Hidden { get; set; }
}
public enum FieldType
@ -30,7 +31,6 @@ public enum FieldType
Select,
Path,
FilePath,
Hidden,
Tag,
Action,
Url,
@ -38,4 +38,11 @@ public enum FieldType
OAuth,
Device
}
public enum HiddenType
{
Visible,
Hidden,
HiddenIfNotSet
}
}

View File

@ -12,7 +12,7 @@ public object FromDB(ConverterContext context)
{
if (context.DbValue == DBNull.Value)
{
return null;
return Quality.Unknown;
}
var val = Convert.ToInt32(context.DbValue);
@ -27,7 +27,7 @@ public object FromDB(ColumnMap map, object dbValue)
public object ToDB(object clrValue)
{
if(clrValue == DBNull.Value) return null;
if (clrValue == DBNull.Value) return 0;
if (clrValue as Quality == null)
{

View File

@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Data;
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(156)]
public class add_download_client_priority : NzbDroneMigrationBase
{
// Need snapshot in time without having to instantiate.
private static HashSet<string> _usenetImplementations = new HashSet<string>
{
"Sabnzbd", "NzbGet", "NzbVortex", "UsenetBlackhole", "UsenetDownloadStation"
};
protected override void MainDbUpgrade()
{
Alter.Table("DownloadClients").AddColumn("Priority").AsInt32().WithDefaultValue(1);
Execute.WithConnection(InitPriorityForBackwardCompatibility);
}
private void InitPriorityForBackwardCompatibility(IDbConnection conn, IDbTransaction tran)
{
using (var cmd = conn.CreateCommand())
{
cmd.Transaction = tran;
cmd.CommandText = "SELECT Id, Implementation FROM DownloadClients WHERE Enable = 1";
using (var reader = cmd.ExecuteReader())
{
int nextUsenet = 1;
int nextTorrent = 1;
while (reader.Read())
{
var id = reader.GetInt32(0);
var implName = reader.GetString(1);
var isUsenet = _usenetImplementations.Contains(implName);
using (var updateCmd = conn.CreateCommand())
{
updateCmd.Transaction = tran;
updateCmd.CommandText = "UPDATE DownloadClients SET Priority = ? WHERE Id = ?";
updateCmd.AddParameter(isUsenet ? nextUsenet++ : nextTorrent++);
updateCmd.AddParameter(id);
updateCmd.ExecuteNonQuery();
}
}
}
}
}
}
}

View File

@ -0,0 +1,20 @@
using System;
using NzbDrone.Common.Exceptions;
namespace NzbDrone.Core.Datastore
{
public class ModelConflictException : NzbDroneException
{
public ModelConflictException(Type modelType, int modelId)
: base("{0} with ID {1} cannot be modified", modelType.Name, modelId)
{
}
public ModelConflictException(Type modelType, int modelId, string message)
: base("{0} with ID {1} {2}", modelType.Name, modelId, message)
{
}
}
}

View File

@ -33,13 +33,38 @@ public Deluge(IDelugeProxy proxy,
_proxy = proxy;
}
public override void MarkItemAsImported(DownloadClientItem downloadClientItem)
{
// set post-import category
if (Settings.MovieImportedCategory.IsNotNullOrWhiteSpace() &&
Settings.MovieImportedCategory != Settings.MovieCategory)
{
try
{
_proxy.SetTorrentLabel(downloadClientItem.DownloadId.ToLower(), Settings.MovieImportedCategory, Settings);
}
catch (DownloadClientUnavailableException)
{
_logger.Warn("Failed to set torrent post-import label \"{0}\" for {1} in Deluge. Does the label exist?",
Settings.MovieImportedCategory, downloadClientItem.Title);
}
}
}
protected override string AddFromMagnetLink(RemoteMovie remoteMovie, string hash, string magnetLink)
{
var actualHash = _proxy.AddTorrentFromMagnet(magnetLink, Settings);
if (!Settings.MovieCategory.IsNullOrWhiteSpace())
if (actualHash.IsNullOrWhiteSpace())
{
_proxy.SetLabel(actualHash, Settings.MovieCategory, Settings);
throw new DownloadClientException("Deluge failed to add magnet " + magnetLink);
}
_proxy.SetTorrentSeedingConfiguration(actualHash, remoteMovie.SeedConfiguration, Settings);
if (Settings.MovieCategory.IsNotNullOrWhiteSpace())
{
_proxy.SetTorrentLabel(actualHash, Settings.MovieCategory, Settings);
}
var isRecentMovie = remoteMovie.Movie.IsRecentMovie;
@ -64,9 +89,9 @@ protected override string AddFromTorrentFile(RemoteMovie remoteMovie, string has
_proxy.SetTorrentSeedingConfiguration(actualHash, remoteMovie.SeedConfiguration, Settings);
if (!Settings.MovieCategory.IsNullOrWhiteSpace())
if (Settings.MovieCategory.IsNotNullOrWhiteSpace())
{
_proxy.SetLabel(actualHash, Settings.MovieCategory, Settings);
_proxy.SetTorrentLabel(actualHash, Settings.MovieCategory, Settings);
}
var isRecentMovie = remoteMovie.Movie.IsRecentMovie;
@ -86,21 +111,13 @@ public override IEnumerable<DownloadClientItem> GetItems()
{
IEnumerable<DelugeTorrent> torrents;
try
if (Settings.MovieCategory.IsNotNullOrWhiteSpace())
{
if (!Settings.MovieCategory.IsNullOrWhiteSpace())
{
torrents = _proxy.GetTorrentsByLabel(Settings.MovieCategory, Settings);
}
else
{
torrents = _proxy.GetTorrents(Settings);
}
torrents = _proxy.GetTorrentsByLabel(Settings.MovieCategory, Settings);
}
catch (DownloadClientException ex)
else
{
_logger.Error(ex, ex.Message);
return Enumerable.Empty<DownloadClientItem>();
torrents = _proxy.GetTorrents(Settings);
}
var items = new List<DownloadClientItem>();
@ -110,7 +127,7 @@ public override IEnumerable<DownloadClientItem> GetItems()
if (torrent.Hash == null) continue;
var item = new DownloadClientItem();
item.DownloadId = torrent.Hash?.ToUpper();
item.DownloadId = torrent.Hash.ToUpper();
item.Title = torrent.Name;
item.Category = Settings.MovieCategory;
@ -253,7 +270,7 @@ private ValidationFailure TestConnection()
private ValidationFailure TestCategory()
{
if (Settings.MovieCategory.IsNullOrWhiteSpace())
if (Settings.MovieCategory.IsNullOrWhiteSpace() && Settings.MovieImportedCategory.IsNullOrWhiteSpace())
{
return null;
}
@ -262,7 +279,7 @@ private ValidationFailure TestCategory()
if (!enabledPlugins.Contains("Label"))
{
return new NzbDroneValidationFailure("TvCategory", "Label plugin not activated")
return new NzbDroneValidationFailure("MovieCategory", "Label plugin not activated")
{
DetailedDescription = "You must have the Label plugin enabled in Deluge to use categories."
};
@ -270,7 +287,7 @@ private ValidationFailure TestCategory()
var labels = _proxy.GetAvailableLabels(Settings);
if (!labels.Contains(Settings.MovieCategory))
if (Settings.MovieCategory.IsNotNullOrWhiteSpace() && !labels.Contains(Settings.MovieCategory))
{
_proxy.AddLabel(Settings.MovieCategory, Settings);
labels = _proxy.GetAvailableLabels(Settings);
@ -279,7 +296,21 @@ private ValidationFailure TestCategory()
{
return new NzbDroneValidationFailure("MovieCategory", "Configuration of label failed")
{
DetailedDescription = "Radarr as unable to add the label to Deluge."
DetailedDescription = "Radarr was unable to add the label to Deluge."
};
}
}
if (Settings.MovieImportedCategory.IsNotNullOrWhiteSpace() && !labels.Contains(Settings.MovieImportedCategory))
{
_proxy.AddLabel(Settings.MovieImportedCategory, Settings);
labels = _proxy.GetAvailableLabels(Settings);
if (!labels.Contains(Settings.MovieImportedCategory))
{
return new NzbDroneValidationFailure("MovieImportedCategory", "Configuration of label failed")
{
DetailedDescription = "Radarr was unable to add the label to Deluge."
};
}
}

View File

@ -19,7 +19,7 @@ public interface IDelugeProxy
string[] GetAvailablePlugins(DelugeSettings settings);
string[] GetEnabledPlugins(DelugeSettings settings);
string[] GetAvailableLabels(DelugeSettings settings);
void SetLabel(string hash, string label, DelugeSettings settings);
void SetTorrentLabel(string hash, string label, DelugeSettings settings);
void SetTorrentConfiguration(string hash, string key, object value, DelugeSettings settings);
void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, DelugeSettings settings);
void AddLabel(string label, DelugeSettings settings);
@ -185,7 +185,7 @@ public void AddLabel(string label, DelugeSettings settings)
ProcessRequest<object>(settings, "label.add", label);
}
public void SetLabel(string hash, string label, DelugeSettings settings)
public void SetTorrentLabel(string hash, string label, DelugeSettings settings)
{
ProcessRequest<object>(settings, "label.set_torrent", hash, label);
}

View File

@ -13,6 +13,7 @@ public DelugeSettingsValidator()
RuleFor(c => c.Port).InclusiveBetween(1, 65535);
RuleFor(c => c.MovieCategory).Matches("^[-a-z]*$").WithMessage("Allowed characters a-z and -");
RuleFor(c => c.MovieImportedCategory).Matches("^[-a-z]*$").WithMessage("Allowed characters a-z and -");
}
}
@ -43,16 +44,19 @@ public DelugeSettings()
[FieldDefinition(4, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Radarr avoids conflicts with unrelated downloads, but it's optional")]
public string MovieCategory { get; set; }
[FieldDefinition(5, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing movies that released within the last 21 days")]
[FieldDefinition(5, Label = "Post-Import Category", Type = FieldType.Textbox, Advanced = true, HelpText = "Category for Radarr to set after it has imported the download. Leave blank to disable this feature.")]
public string MovieImportedCategory { get; set; }
[FieldDefinition(6, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing movies that aired within the last 14 days")]
public int RecentMoviePriority { get; set; }
[FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing movies that released over 21 days ago")]
[FieldDefinition(7, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing movies that aired over 14 days ago")]
public int OlderMoviePriority { get; set; }
[FieldDefinition(7, Label = "Add Paused", Type = FieldType.Checkbox)]
[FieldDefinition(8, Label = "Add Paused", Type = FieldType.Checkbox)]
public bool AddPaused { get; set; }
[FieldDefinition(8, Label = "Use SSL", Type = FieldType.Checkbox)]
[FieldDefinition(9, Label = "Use SSL", Type = FieldType.Checkbox)]
public bool UseSsl { get; set; }
public NzbDroneValidationResult Validate()

View File

@ -9,6 +9,7 @@
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Validation;
using NzbDrone.Core.RemotePathMappings;
@ -43,12 +44,11 @@ protected override string AddFromNzbFile(RemoteMovie remoteMovie, string filenam
var priority = remoteMovie.Movie.IsRecentMovie ? Settings.RecentMoviePriority : Settings.OlderMoviePriority;
var addpaused = Settings.AddPaused;
var response = _proxy.DownloadNzb(fileContent, filename, category, priority, addpaused, Settings);
if (response == null)
{
throw new DownloadClientException("Failed to add nzb {0}", filename);
throw new DownloadClientRejectedReleaseException(remoteMovie.Release, "NZBGet rejected the NZB for an unknown reason");
}
return response;
@ -135,7 +135,7 @@ private IEnumerable<DownloadClientItem> GetHistory()
historyItem.CanMoveFiles = true;
historyItem.CanBeRemoved = true;
if (item.DeleteStatus == "MANUAL")
if (item.DeleteStatus == "MANUAL" || item.DeleteStatus == "COPY")
{
continue;
}

View File

@ -35,6 +35,24 @@ public QBittorrent(IQBittorrentProxySelector proxySelector,
private IQBittorrentProxy Proxy => _proxySelector.GetProxy(Settings);
public override void MarkItemAsImported(DownloadClientItem downloadClientItem)
{
// set post-import category
if (Settings.MovieImportedCategory.IsNotNullOrWhiteSpace() &&
Settings.MovieImportedCategory != Settings.MovieCategory)
{
try
{
Proxy.SetTorrentLabel(downloadClientItem.DownloadId.ToLower(), Settings.MovieImportedCategory, Settings);
}
catch (DownloadClientException)
{
_logger.Warn("Failed to set post-import torrent label \"{0}\" for {1} in qBittorrent. Does the label exist?",
Settings.MovieImportedCategory, downloadClientItem.Title);
}
}
}
protected override string AddFromMagnetLink(RemoteMovie remoteMovie, string hash, string magnetLink)
{
if (!Proxy.GetConfig(Settings).DhtEnabled && !magnetLink.Contains("&tr="))
@ -44,11 +62,6 @@ protected override string AddFromMagnetLink(RemoteMovie remoteMovie, string hash
Proxy.AddTorrentFromUrl(magnetLink, Settings);
if (Settings.MovieCategory.IsNotNullOrWhiteSpace())
{
Proxy.SetTorrentLabel(hash.ToLower(), Settings.MovieCategory, Settings);
}
var isRecentMovie = remoteMovie.Movie.IsRecentMovie;
if (isRecentMovie && Settings.RecentMoviePriority == (int)QBittorrentPriority.First ||
@ -71,18 +84,6 @@ protected override string AddFromTorrentFile(RemoteMovie remoteMovie, string has
{
Proxy.AddTorrentFromFile(filename, fileContent, Settings);
try
{
if (Settings.MovieCategory.IsNotNullOrWhiteSpace())
{
Proxy.SetTorrentLabel(hash.ToLower(), Settings.MovieCategory, Settings);
}
}
catch (Exception ex)
{
_logger.Warn(ex, "Failed to set the torrent label for {0}.", filename);
}
try
{
var isRecentMovie = remoteMovie.Movie.IsRecentMovie;
@ -218,6 +219,7 @@ protected override void Test(List<ValidationFailure> failures)
{
failures.AddIfNotNull(TestConnection());
if (failures.HasErrors()) return;
failures.AddIfNotNull(TestCategory());
failures.AddIfNotNull(TestPrioritySupport());
failures.AddIfNotNull(TestGetTorrents());
}
@ -249,7 +251,7 @@ private ValidationFailure TestConnection()
else if (Settings.MovieCategory.IsNullOrWhiteSpace())
{
// warn if labels are supported, but category is not provided
return new NzbDroneValidationFailure("TvCategory", "Category is recommended")
return new NzbDroneValidationFailure("MovieCategory", "Category is recommended")
{
IsWarning = true,
DetailedDescription = "Radarr will not attempt to import completed downloads without a category."
@ -295,6 +297,53 @@ private ValidationFailure TestConnection()
return null;
}
private ValidationFailure TestCategory()
{
if (Settings.MovieCategory.IsNullOrWhiteSpace() && Settings.MovieImportedCategory.IsNullOrWhiteSpace())
{
return null;
}
// api v1 doesn't need to check/add categories as it's done on set
var version = _proxySelector.GetProxy(Settings, true).GetApiVersion(Settings);
if (version < Version.Parse("2.0"))
{
return null;
}
Dictionary<string, QBittorrentLabel> labels = Proxy.GetLabels(Settings);
if (Settings.MovieCategory.IsNotNullOrWhiteSpace() && !labels.ContainsKey(Settings.MovieCategory))
{
Proxy.AddLabel(Settings.MovieCategory, Settings);
labels = Proxy.GetLabels(Settings);
if (!labels.ContainsKey(Settings.MovieCategory))
{
return new NzbDroneValidationFailure("MovieCategory", "Configuration of label failed")
{
DetailedDescription = "Radarr was unable to add the label to qBittorrent."
};
}
}
if (Settings.MovieImportedCategory.IsNotNullOrWhiteSpace() && !labels.ContainsKey(Settings.MovieImportedCategory))
{
Proxy.AddLabel(Settings.MovieImportedCategory, Settings);
labels = Proxy.GetLabels(Settings);
if (!labels.ContainsKey(Settings.MovieImportedCategory))
{
return new NzbDroneValidationFailure("MovieImportedCategory", "Configuration of label failed")
{
DetailedDescription = "Radarr was unable to add the label to qBittorrent."
};
}
}
return null;
}
private ValidationFailure TestPrioritySupport()
{
var recentPriorityDefault = Settings.RecentMoviePriority == (int)QBittorrentPriority.Last;

View File

@ -0,0 +1,10 @@
using Newtonsoft.Json;
namespace NzbDrone.Core.Download.Clients.QBittorrent
{
public class QBittorrentLabel
{
public string Name { get; set; }
public string SavePath { get; set; }
}
}

View File

@ -23,6 +23,8 @@ public interface IQBittorrentProxy
void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings);
void SetTorrentLabel(string hash, string label, QBittorrentSettings settings);
void AddLabel(string label, QBittorrentSettings settings);
Dictionary<string, QBittorrentLabel> GetLabels(QBittorrentSettings settings);
void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings);
void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings);
void PauseTorrent(string hash, QBittorrentSettings settings);

View File

@ -190,6 +190,19 @@ public void SetTorrentLabel(string hash, string label, QBittorrentSettings setti
}
}
public void AddLabel(string label, QBittorrentSettings settings)
{
var request = BuildRequest(settings).Resource("/command/addCategory")
.Post()
.AddFormParameter("category", label);
ProcessRequest(request, settings);
}
public Dictionary<string, QBittorrentLabel> GetLabels(QBittorrentSettings settings)
{
throw new NotSupportedException("qBittorrent api v1 does not support getting all torrent categories");
}
public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings)
{
// Not supported on api v1

View File

@ -177,6 +177,20 @@ public void SetTorrentLabel(string hash, string label, QBittorrentSettings setti
ProcessRequest(request, settings);
}
public void AddLabel(string label, QBittorrentSettings settings)
{
var request = BuildRequest(settings).Resource("/api/v2/torrents/createCategory")
.Post()
.AddFormParameter("category", label);
ProcessRequest(request, settings);
}
public Dictionary<string, QBittorrentLabel> GetLabels(QBittorrentSettings settings)
{
var request = BuildRequest(settings).Resource("/api/v2/torrents/categories");
return Json.Deserialize<Dictionary<string, QBittorrentLabel>>(ProcessRequest(request, settings));
}
public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings)
{
var ratioLimit = seedConfiguration.Ratio.HasValue ? seedConfiguration.Ratio : -2;

View File

@ -11,6 +11,9 @@ public QBittorrentSettingsValidator()
{
RuleFor(c => c.Host).ValidHost();
RuleFor(c => c.Port).InclusiveBetween(1, 65535);
RuleFor(c => c.MovieCategory).Matches(@"^([^\\\/](\/?[^\\\/])*)?$").WithMessage(@"Can not contain '\', '//', or start/end with '/'");
RuleFor(c => c.MovieImportedCategory).Matches(@"^([^\\\/](\/?[^\\\/])*)?$").WithMessage(@"Can not contain '\', '//', or start/end with '/'");
}
}
@ -37,19 +40,22 @@ public QBittorrentSettings()
[FieldDefinition(3, Label = "Password", Type = FieldType.Password)]
public string Password { get; set; }
[FieldDefinition(4, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Radarr avoids conflicts with unrelated downloads, but it's optional")]
[FieldDefinition(4, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Sonarr avoids conflicts with unrelated downloads, but it's optional")]
public string MovieCategory { get; set; }
[FieldDefinition(5, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing movies that released within the last 21 days")]
[FieldDefinition(5, Label = "Post-Import Category", Type = FieldType.Textbox, Advanced = true, HelpText = "Category for Sonarr to set after it has imported the download. Leave blank to disable this feature.")]
public string MovieImportedCategory { get; set; }
[FieldDefinition(6, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")]
public int RecentMoviePriority { get; set; }
[FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing movies that released over 21 days ago")]
[FieldDefinition(7, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")]
public int OlderMoviePriority { get; set; }
[FieldDefinition(7, Label = "Initial State", Type = FieldType.Select, SelectOptions = typeof(QBittorrentState), HelpText = "Initial state for torrents added to qBittorrent")]
[FieldDefinition(8, Label = "Initial State", Type = FieldType.Select, SelectOptions = typeof(QBittorrentState), HelpText = "Initial state for torrents added to qBittorrent")]
public int InitialState { get; set; }
[FieldDefinition(8, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use a secure connection. See Options -> Web UI -> 'Use HTTPS instead of HTTP' in qBittorrent.")]
[FieldDefinition(9, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use a secure connection. See Options -> Web UI -> 'Use HTTPS instead of HTTP' in qBittorrent.")]
public bool UseSsl { get; set; }
public NzbDroneValidationResult Validate()

View File

@ -39,6 +39,24 @@ public RTorrent(IRTorrentProxy proxy,
_rTorrentDirectoryValidator = rTorrentDirectoryValidator;
}
public override void MarkItemAsImported(DownloadClientItem downloadClientItem)
{
// set post-import category
if (Settings.MovieImportedCategory.IsNotNullOrWhiteSpace() &&
Settings.MovieImportedCategory != Settings.MovieCategory)
{
try
{
_proxy.SetTorrentLabel(downloadClientItem.DownloadId.ToLower(), Settings.MovieImportedCategory, Settings);
}
catch (Exception ex)
{
_logger.Warn(ex, "Failed to set torrent post-import label \"{0}\" for {1} in rTorrent. Does the label exist?",
Settings.MovieImportedCategory, downloadClientItem.Title);
}
}
}
protected override string AddFromMagnetLink(RemoteMovie remoteMovie, string hash, string magnetLink)
{
var priority = (RTorrentPriority)(remoteMovie.Movie.IsRecentMovie ? Settings.RecentMoviePriority : Settings.OlderMoviePriority);

View File

@ -18,6 +18,7 @@ public interface IRTorrentProxy
void AddTorrentFromUrl(string torrentUrl, string label, RTorrentPriority priority, string directory, RTorrentSettings settings);
void AddTorrentFromFile(string fileName, byte[] fileContent, string label, RTorrentPriority priority, string directory, RTorrentSettings settings);
void RemoveTorrent(string hash, RTorrentSettings settings);
void SetTorrentLabel(string hash, string label, RTorrentSettings settings);
bool HasHashTorrent(string hash, RTorrentSettings settings);
}
@ -44,6 +45,9 @@ public interface IRTorrent : IXmlRpcProxy
[XmlRpcMethod("d.name")]
string GetName(string hash);
[XmlRpcMethod("d.custom1.set")]
string SetLabel(string hash, string label);
[XmlRpcMethod("system.client_version")]
string GetVersion();
}
@ -90,20 +94,20 @@ public List<RTorrentTorrent> GetTorrents(RTorrentSettings settings)
foreach (object[] torrent in ret)
{
var labelDecoded = System.Web.HttpUtility.UrlDecode((string) torrent[3]);
var labelDecoded = System.Web.HttpUtility.UrlDecode((string)torrent[3]);
var item = new RTorrentTorrent();
item.Name = (string) torrent[0];
item.Hash = (string) torrent[1];
item.Path = (string) torrent[2];
item.Name = (string)torrent[0];
item.Hash = (string)torrent[1];
item.Path = (string)torrent[2];
item.Category = labelDecoded;
item.TotalSize = (long) torrent[4];
item.RemainingSize = (long) torrent[5];
item.DownRate = (long) torrent[6];
item.Ratio = (long) torrent[7];
item.IsOpen = Convert.ToBoolean((long) torrent[8]);
item.IsActive = Convert.ToBoolean((long) torrent[9]);
item.IsFinished = Convert.ToBoolean((long) torrent[10]);
item.TotalSize = (long)torrent[4];
item.RemainingSize = (long)torrent[5];
item.DownRate = (long)torrent[6];
item.Ratio = (long)torrent[7];
item.IsOpen = Convert.ToBoolean((long)torrent[8]);
item.IsActive = Convert.ToBoolean((long)torrent[9]);
item.IsFinished = Convert.ToBoolean((long)torrent[10]);
items.Add(item);
}
@ -157,6 +161,19 @@ public void AddTorrentFromFile(string fileName, byte[] fileContent, string label
}
}
public void SetTorrentLabel(string hash, string label, RTorrentSettings settings)
{
_logger.Debug("Executing remote method: d.custom1.set");
var client = BuildClient(settings);
var response = ExecuteRequest(() => client.SetLabel(hash, label));
if (response != label)
{
throw new DownloadClientException("Could not set label to {1} for torrent: {0}.", hash, label);
}
}
public void RemoveTorrent(string hash, RTorrentSettings settings)
{
_logger.Debug("Executing remote method: d.erase");

View File

@ -27,6 +27,8 @@ public RTorrentSettings()
Port = 8080;
UrlBase = "RPC2";
MovieCategory = "radarr";
OlderMoviePriority = (int)RTorrentPriority.Normal;
RecentMoviePriority = (int)RTorrentPriority.Normal;
}
[FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)]
@ -50,16 +52,19 @@ public RTorrentSettings()
[FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Radarr avoids conflicts with unrelated downloads, but it's optional.")]
public string MovieCategory { get; set; }
[FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default rTorrent location")]
[FieldDefinition(7, Label = "Post-Import Category", Type = FieldType.Textbox, Advanced = true, HelpText = "Category for Sonarr to set after it has imported the download. Leave blank to disable this feature.")]
public string MovieImportedCategory { get; set; }
[FieldDefinition(8, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default rTorrent location")]
public string MovieDirectory { get; set; }
[FieldDefinition(8, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(RTorrentPriority), HelpText = "Priority to use when grabbing movies that released within the last 21 days")]
[FieldDefinition(9, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(RTorrentPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")]
public int RecentMoviePriority { get; set; }
[FieldDefinition(9, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(RTorrentPriority), HelpText = "Priority to use when grabbing movies that released over 21 days ago")]
[FieldDefinition(10, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(RTorrentPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")]
public int OlderMoviePriority { get; set; }
[FieldDefinition(10, Label = "Add Stopped", Type = FieldType.Checkbox, HelpText = "Enabling will prevent magnets from downloading before downloading")]
[FieldDefinition(11, Label = "Add Stopped", Type = FieldType.Checkbox, HelpText = "Enabling will prevent magnets from downloading before downloading")]
public bool AddStopped { get; set; }
public NzbDroneValidationResult Validate()

View File

@ -38,12 +38,32 @@ public UTorrent(IUTorrentProxy proxy,
_torrentCache = cacheManager.GetCache<UTorrentTorrentCache>(GetType(), "differentialTorrents");
}
public override void MarkItemAsImported(DownloadClientItem downloadClientItem)
{
// set post-import category
if (Settings.MovieImportedCategory.IsNotNullOrWhiteSpace() &&
Settings.MovieImportedCategory != Settings.MovieCategory)
{
_proxy.SetTorrentLabel(downloadClientItem.DownloadId.ToLower(), Settings.MovieImportedCategory, Settings);
// old label must be explicitly removed
if (Settings.MovieCategory.IsNotNullOrWhiteSpace())
{
_proxy.RemoveTorrentLabel(downloadClientItem.DownloadId.ToLower(), Settings.MovieCategory, Settings);
}
}
}
protected override string AddFromMagnetLink(RemoteMovie remoteMovie, string hash, string magnetLink)
{
_proxy.AddTorrentFromUrl(magnetLink, Settings);
_proxy.SetTorrentLabel(hash, Settings.MovieCategory, Settings);
_proxy.SetTorrentSeedingConfiguration(hash, remoteMovie.SeedConfiguration, Settings);
if (Settings.MovieCategory.IsNotNullOrWhiteSpace())
{
_proxy.SetTorrentLabel(hash, Settings.MovieCategory, Settings);
}
var isRecentMovie = remoteMovie.Movie.IsRecentMovie;
if (isRecentMovie && Settings.RecentMoviePriority == (int)UTorrentPriority.First ||
@ -60,13 +80,17 @@ protected override string AddFromMagnetLink(RemoteMovie remoteMovie, string hash
protected override string AddFromTorrentFile(RemoteMovie remoteMovie, string hash, string filename, byte[] fileContent)
{
_proxy.AddTorrentFromFile(filename, fileContent, Settings);
_proxy.SetTorrentLabel(hash, Settings.MovieCategory, Settings);
_proxy.SetTorrentSeedingConfiguration(hash, remoteMovie.SeedConfiguration, Settings);
var isRecentMovie = remoteMovie.Movie.IsRecentMovie;
if (Settings.MovieCategory.IsNotNullOrWhiteSpace())
{
_proxy.SetTorrentLabel(hash, Settings.MovieCategory, Settings);
}
if (isRecentMovie && Settings.RecentMoviePriority == (int)UTorrentPriority.First ||
!isRecentMovie && Settings.OlderMoviePriority == (int)UTorrentPriority.First)
var isRecentEpisode = remoteMovie.Movie.IsRecentMovie;
if (isRecentEpisode && Settings.RecentMoviePriority == (int)UTorrentPriority.First ||
!isRecentEpisode && Settings.OlderMoviePriority == (int)UTorrentPriority.First)
{
_proxy.MoveTorrentToTopInQueue(hash, Settings);
}

View File

@ -21,6 +21,7 @@ public interface IUTorrentProxy
void RemoveTorrent(string hash, bool removeData, UTorrentSettings settings);
void SetTorrentLabel(string hash, string label, UTorrentSettings settings);
void RemoveTorrentLabel(string hash, string label, UTorrentSettings settings);
void MoveTorrentToTopInQueue(string hash, UTorrentSettings settings);
void SetState(string hash, UTorrentState state, UTorrentSettings settings);
}
@ -151,6 +152,20 @@ public void SetTorrentLabel(string hash, string label, UTorrentSettings settings
ProcessRequest(requestBuilder, settings);
}
public void RemoveTorrentLabel(string hash, string label, UTorrentSettings settings)
{
var requestBuilder = BuildRequest(settings)
.AddQueryParam("action", "setprops")
.AddQueryParam("hash", hash);
requestBuilder.AddQueryParam("s", "label")
.AddQueryParam("v", label)
.AddQueryParam("s", "label")
.AddQueryParam("v", "");
ProcessRequest(requestBuilder, settings);
}
public void MoveTorrentToTopInQueue(string hash, UTorrentSettings settings)
{
var requestBuilder = BuildRequest(settings)

View File

@ -41,13 +41,16 @@ public UTorrentSettings()
[FieldDefinition(4, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Radarr avoids conflicts with unrelated downloads, but it's optional")]
public string MovieCategory { get; set; }
[FieldDefinition(5, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing movies that released within the last 21 days")]
[FieldDefinition(5, Label = "Post-Import Category", Type = FieldType.Textbox, Advanced = true, HelpText = "Category for Sonarr to set after it has imported the download. Leave blank to disable this feature.")]
public string MovieImportedCategory { get; set; }
[FieldDefinition(6, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")]
public int RecentMoviePriority { get; set; }
[FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing movies that released over 21 days ago")]
[FieldDefinition(7, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")]
public int OlderMoviePriority { get; set; }
[FieldDefinition(7, Label = "Initial State", Type = FieldType.Select, SelectOptions = typeof(UTorrentState), HelpText = "Initial state for torrents added to uTorrent")]
[FieldDefinition(8, Label = "Initial State", Type = FieldType.Select, SelectOptions = typeof(UTorrentState), HelpText = "Initial state for torrents added to uTorrent")]
public int IntialState { get; set; }
public NzbDroneValidationResult Validate()

View File

@ -151,6 +151,10 @@ protected ValidationFailure TestFolder(string folder, string propertyName, bool
return null;
}
public virtual void MarkItemAsImported(DownloadClientItem downloadClientItem)
{
throw new NotSupportedException(this.Name + " does not support marking items as imported");
}
}
}

View File

@ -6,5 +6,6 @@ namespace NzbDrone.Core.Download
public class DownloadClientDefinition : ProviderDefinition
{
public DownloadProtocol Protocol { get; set; }
public int Priority { get; set; } = 1;
}
}

View File

@ -1,6 +1,8 @@
using System.Linq;
using System.Collections.Generic;
using NzbDrone.Core.Indexers;
using NzbDrone.Common.Cache;
using NLog;
namespace NzbDrone.Core.Download
{
@ -13,16 +15,53 @@ public interface IProvideDownloadClient
public class DownloadClientProvider : IProvideDownloadClient
{
private readonly Logger _logger;
private readonly IDownloadClientFactory _downloadClientFactory;
private readonly IDownloadClientStatusService _downloadClientStatusService;
private readonly ICached<int> _lastUsedDownloadClient;
public DownloadClientProvider(IDownloadClientFactory downloadClientFactory)
public DownloadClientProvider(IDownloadClientStatusService downloadClientStatusService, IDownloadClientFactory downloadClientFactory, ICacheManager cacheManager, Logger logger)
{
_logger = logger;
_downloadClientFactory = downloadClientFactory;
_downloadClientStatusService = downloadClientStatusService;
_lastUsedDownloadClient = cacheManager.GetCache<int>(GetType(), "lastDownloadClientId");
}
public IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol)
{
return _downloadClientFactory.GetAvailableProviders().FirstOrDefault(v => v.Protocol == downloadProtocol);
var availableProviders = _downloadClientFactory.GetAvailableProviders().Where(v => v.Protocol == downloadProtocol).ToList();
if (!availableProviders.Any()) return null;
var blockedProviders = new HashSet<int>(_downloadClientStatusService.GetBlockedProviders().Select(v => v.ProviderId));
if (blockedProviders.Any())
{
var nonBlockedProviders = availableProviders.Where(v => !blockedProviders.Contains(v.Definition.Id)).ToList();
if (nonBlockedProviders.Any())
{
availableProviders = nonBlockedProviders;
}
else
{
_logger.Trace("No non-blocked Download Client available, retrying blocked one.");
}
}
// Use the first priority clients first
availableProviders = availableProviders.GroupBy(v => (v.Definition as DownloadClientDefinition).Priority)
.OrderBy(v => v.Key)
.First().OrderBy(v => v.Definition.Id).ToList();
var lastId = _lastUsedDownloadClient.Find(downloadProtocol.ToString());
var provider = availableProviders.FirstOrDefault(v => v.Definition.Id > lastId) ?? availableProviders.First();
_lastUsedDownloadClient.Set(downloadProtocol.ToString(), provider.Definition.Id);
return provider;
}
public IEnumerable<IDownloadClient> GetDownloadClients()

View File

@ -35,15 +35,17 @@ public DownloadEventHub(IConfigService configService,
public void Handle(DownloadCompletedEvent message)
{
if (!_configService.RemoveCompletedDownloads ||
message.TrackedDownload.DownloadItem.Removed ||
!message.TrackedDownload.DownloadItem.CanBeRemoved ||
message.TrackedDownload.DownloadItem.Status == DownloadItemStatus.Downloading)
if (_configService.RemoveCompletedDownloads &&
!message.TrackedDownload.DownloadItem.Removed &&
message.TrackedDownload.DownloadItem.CanBeRemoved &&
message.TrackedDownload.DownloadItem.Status != DownloadItemStatus.Downloading)
{
return;
RemoveFromDownloadClient(message.TrackedDownload);
}
RemoveFromDownloadClient(message.TrackedDownload);
else
{
MarkItemAsImported(message.TrackedDownload);
}
}
public void Handle(DownloadFailedEvent message)
@ -74,7 +76,25 @@ private void RemoveFromDownloadClient(TrackedDownload trackedDownload)
}
catch (Exception e)
{
_logger.Error(e, "Couldn't remove item from client {0}", trackedDownload.DownloadItem.Title);
_logger.Error(e, "Couldn't remove item {0} from client {1}", trackedDownload.DownloadItem.Title, downloadClient.Name);
}
}
private void MarkItemAsImported(TrackedDownload trackedDownload)
{
var downloadClient = _downloadClientProvider.Get(trackedDownload.DownloadClient);
try
{
_logger.Debug("[{0}] Marking download as imported from {1}", trackedDownload.DownloadItem.Title, trackedDownload.DownloadItem.DownloadClient);
downloadClient.MarkItemAsImported(trackedDownload.DownloadItem);
}
catch (NotSupportedException e)
{
_logger.Debug(e.Message);
}
catch (Exception e)
{
_logger.Error(e, "Couldn't mark item {0} as imported from client {1}", trackedDownload.DownloadItem.Title, downloadClient.Name);
}
}
}

View File

@ -13,5 +13,6 @@ public interface IDownloadClient : IProvider
IEnumerable<DownloadClientItem> GetItems();
void RemoveItem(string downloadId, bool deleteData);
DownloadClientInfo GetStatus();
void MarkItemAsImported(DownloadClientItem downloadClientItem);
}
}

View File

@ -72,9 +72,28 @@ public void ImportExtraFiles(LocalMovie localMovie, MovieFile movieFile, bool is
.Select(e => e.Trim(' ', '.'))
.ToList();
var matchingFilenames = files.Where(f => Path.GetFileNameWithoutExtension(f).StartsWith(sourceFileName, StringComparison.InvariantCultureIgnoreCase));
var matchingFilenames = files.Where(f => Path.GetFileNameWithoutExtension(f).StartsWith(sourceFileName, StringComparison.InvariantCultureIgnoreCase)).ToList();
var filteredFilenames = new List<string>();
var hasNfo = false;
foreach (var matchingFilename in matchingFilenames)
{
// Filter out duplicate NFO files
if (matchingFilename.EndsWith(".nfo", StringComparison.InvariantCultureIgnoreCase))
{
if (hasNfo)
{
continue;
}
hasNfo = true;
}
filteredFilenames.Add(matchingFilename);
}
foreach (var matchingFilename in filteredFilenames)
{
var matchingExtension = wantedExtensions.FirstOrDefault(e => matchingFilename.EndsWith(e));

View File

@ -30,13 +30,13 @@ public override HealthCheck Check()
return new HealthCheck(GetType(), HealthCheckResult.Error, $"Your Mono version {monoVersion} has a bug that causes issues connecting to indexers/download clients. You should upgrade to a higher version");
}
if (monoVersion >= new Version("4.8.0"))
if (monoVersion >= new Version("4.4.2"))
{
_logger.Debug("Mono version is 4.8.0 or better: {0}", monoVersion);
_logger.Debug("Mono version is 4.4.2 or better: {0}", monoVersion);
return new HealthCheck(GetType());
}
return new HealthCheck(GetType(), HealthCheckResult.Warning, "Your version of mono which is required by Radarr is deprecated and no longer supported. Core functionality of Radarr including automatic updates may be and will remain broken until you upgrade. You must manually upgrade your mono version to restore automatic update functionality.");
return new HealthCheck(GetType(), HealthCheckResult.Warning, "You are running an old and unsupported version of Mono. Please upgrade Mono for improved stability.");
}
public override bool CheckOnSchedule => false;

View File

@ -33,11 +33,12 @@ public enum HistoryEventType
{
Unknown = 0,
Grabbed = 1,
SeriesFolderImported = 2, // to be deprecate
// SeriesFolderImported = 2, // deprecated
DownloadFolderImported = 3,
DownloadFailed = 4,
EpisodeFileDeleted = 5, // deprecated
// EpisodeFileDeleted = 5, // deprecated
MovieFileDeleted = 6,
MovieFolderImported = 7, // not used yet
MovieFileRenamed = 8
}
}

View File

@ -35,6 +35,7 @@ public class HistoryService : IHistoryService,
IHandle<MovieImportedEvent>,
IHandle<DownloadFailedEvent>,
IHandle<MovieFileDeletedEvent>,
IHandle<MovieFileRenamedEvent>,
IHandle<MovieDeletedEvent>
{
private readonly IHistoryRepository _historyRepository;
@ -195,6 +196,30 @@ public void Handle(MovieFileDeletedEvent message)
_historyRepository.Insert(history);
}
public void Handle(MovieFileRenamedEvent message)
{
var sourcePath = message.OriginalPath;
var sourceRelativePath = message.Movie.Path.GetRelativePath(message.OriginalPath);
var path = Path.Combine(message.Movie.Path, message.MovieFile.RelativePath);
var relativePath = message.MovieFile.RelativePath;
var history = new History
{
EventType = HistoryEventType.MovieFileRenamed,
Date = DateTime.UtcNow,
Quality = message.MovieFile.Quality,
SourceTitle = message.OriginalPath,
MovieId = message.MovieFile.MovieId,
};
history.Data.Add("SourcePath", sourcePath);
history.Data.Add("SourceRelativePath", sourceRelativePath);
history.Data.Add("Path", path);
history.Data.Add("RelativePath", relativePath);
_historyRepository.Insert(history);
}
public void Handle(MovieDeletedEvent message)
{
_historyRepository.DeleteForMovie(message.Movie.Id);

View File

@ -48,7 +48,6 @@ public override IEnumerable<ProviderDefinition> DefaultDefinitions
yield return GetDefinition("NZBFinder.ws", GetSettings("https://nzbfinder.ws"));
yield return GetDefinition("NZBgeek", GetSettings("https://api.nzbgeek.info"));
yield return GetDefinition("nzbplanet.net", GetSettings("https://api.nzbplanet.net"));
yield return GetDefinition("Nzbs.org", GetSettings("http://nzbs.org"));
yield return GetDefinition("omgwtfnzbs", GetSettings("https://api.omgwtfnzbs.me"));
yield return GetDefinition("OZnzb.com", GetSettings("https://api.oznzb.com"));
yield return GetDefinition("PFmonkey", GetSettings("https://www.pfmonkey.com"));

View File

@ -15,17 +15,10 @@ public class PassThePopcorn : HttpIndexerBase<PassThePopcornSettings>
public override bool SupportsSearch => true;
public override int PageSize => 50;
private readonly IHttpClient _httpClient;
private readonly IIndexerStatusService _indexerStatusService;
private readonly Logger _logger;
public PassThePopcorn(IHttpClient httpClient, ICacheManager cacheManager, IIndexerStatusService indexerStatusService,
IConfigService configService, IParsingService parsingService, Logger logger)
: base(httpClient, indexerStatusService, configService, parsingService, logger)
{
_httpClient = httpClient;
_logger = logger;
_indexerStatusService = indexerStatusService;
}
public override IIndexerRequestGenerator GetRequestGenerator()
@ -42,20 +35,5 @@ public override IParseIndexerResponse GetParser()
{
return new PassThePopcornParser(Settings, _logger);
}
/*protected override IndexerResponse FetchIndexerResponse(IndexerRequest request)
{
_logger.Debug("Downloading Feed " + request.HttpRequest.ToString(false));
if (request.HttpRequest.RateLimit < RateLimit)
{
request.HttpRequest.RateLimit = RateLimit;
}
//Potentially dangerous though if ptp moves domains!
request.HttpRequest.AllowAutoRedirect = false;
return new IndexerResponse(request, _httpClient.Execute(request.HttpRequest));
}*/
}
}

View File

@ -5,6 +5,7 @@ public enum DeleteMediaFileReason
MissingFromDisk,
Manual,
Upgrade,
NoLinkedEpisodes
NoLinkedEpisodes,
ManualOverride
}
}

View File

@ -4,6 +4,7 @@
using System.Linq;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.MediaFiles.MovieImport;
@ -32,6 +33,7 @@ public class DownloadedMovieImportService : IDownloadedMovieImportService
private readonly IMakeImportDecision _importDecisionMaker;
private readonly IImportApprovedMovie _importApprovedMovie;
private readonly IDetectSample _detectSample;
private readonly IRuntimeInfo _runtimeInfo;
private readonly IConfigService _config;
private readonly IHistoryService _historyService;
private readonly Logger _logger;
@ -43,6 +45,7 @@ public DownloadedMovieImportService(IDiskProvider diskProvider,
IMakeImportDecision importDecisionMaker,
IImportApprovedMovie importApprovedMovie,
IDetectSample detectSample,
IRuntimeInfo runtimeInfo,
IConfigService config,
IHistoryService historyService,
Logger logger)
@ -54,6 +57,7 @@ public DownloadedMovieImportService(IDiskProvider diskProvider,
_importDecisionMaker = importDecisionMaker;
_importApprovedMovie = importApprovedMovie;
_detectSample = detectSample;
_runtimeInfo = runtimeInfo;
_config = config;
_historyService = historyService;
_logger = logger;
@ -104,7 +108,7 @@ public List<ImportResult> ProcessPath(string path, ImportMode importMode = Impor
return ProcessFile(fileInfo, importMode, movie, downloadClientItem);
}
_logger.Error("Import failed, path does not exist or is not accessible by Radarr: {0}", path);
LogInaccessiblePathError(path);
return new List<ImportResult>();
}
@ -273,5 +277,31 @@ private ImportResult UnknownMovieResult(string message, string videoFile = null)
return new ImportResult(new ImportDecision(localMovie, new Rejection("Unknown Movie")), message);
}
private void LogInaccessiblePathError(string path)
{
if (_runtimeInfo.IsWindowsService)
{
var mounts = _diskProvider.GetMounts();
var mount = mounts.FirstOrDefault(m => m.RootDirectory == Path.GetPathRoot(path));
if (mount.DriveType == DriveType.Network)
{
_logger.Error("Import failed, path does not exist or is not accessible by Sonarr: {0}. It's recommended to avoid mapped network drives when running as a Windows service. See the FAQ for more info", path);
return;
}
}
if (OsInfo.IsWindows)
{
if (path.StartsWith(@"\\"))
{
_logger.Error("Import failed, path does not exist or is not accessible by Sonarr: {0}. Ensure the user running Sonarr has access to the network share", path);
return;
}
}
_logger.Error("Import failed, path does not exist or is not accessible by Sonarr: {0}. Ensure the path exists and the user running Sonarr has the correct permissions to access this file/folder", path);
}
}
}

View File

@ -0,0 +1,19 @@
using NzbDrone.Common.Messaging;
using NzbDrone.Core.Movies;
namespace NzbDrone.Core.MediaFiles.Events
{
public class MovieFileRenamedEvent : IEvent
{
public Movie Movie { get; private set; }
public MovieFile MovieFile { get; private set; }
public string OriginalPath { get; private set; }
public MovieFileRenamedEvent(Movie movie, MovieFile movieFile, string originalPath)
{
Movie = movie;
MovieFile = movieFile;
OriginalPath = originalPath;
}
}
}

View File

@ -92,7 +92,6 @@ private void SetMonoPermissions(string path, string permissions)
{
_logger.Warn(ex, "Unable to apply permissions to: " + path);
_logger.Debug(ex, ex.Message);
}
}
}

Some files were not shown because too many files have changed in this diff Show More