diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.js index f013b3f55..84743df39 100644 --- a/frontend/src/Activity/History/HistoryEventTypeCell.js +++ b/frontend/src/Activity/History/HistoryEventTypeCell.js @@ -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'; } diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js index d53372b35..43e313826 100644 --- a/frontend/src/Components/Form/DeviceInputConnector.js +++ b/frontend/src/Components/Form/DeviceInputConnector.js @@ -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); diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js index 6dafc8bea..214aae25d 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -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 && @@ -439,8 +442,8 @@ EnhancedSelectInput.defaultProps = { disabledClassName: styles.isDisabled, isDisabled: false, selectedValueOptions: {}, - selectedValueComponent: EnhancedSelectInputSelectedValue, - optionComponent: EnhancedSelectInputOption + selectedValueComponent: HintedSelectInputSelectedValue, + optionComponent: HintedSelectInputOption }; export default EnhancedSelectInput; diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.css b/frontend/src/Components/Form/EnhancedSelectInputOption.css index 2b96de47f..18440c50d 100644 --- a/frontend/src/Components/Form/EnhancedSelectInputOption.css +++ b/frontend/src/Components/Form/EnhancedSelectInputOption.css @@ -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; diff --git a/frontend/src/Components/Form/FormInputGroup.css b/frontend/src/Components/Form/FormInputGroup.css index acdeb772f..1a1b104e6 100644 --- a/frontend/src/Components/Form/FormInputGroup.css +++ b/frontend/src/Components/Form/FormInputGroup.css @@ -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 { diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index c90ba3da8..5cbbc1f37 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -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; diff --git a/frontend/src/Components/Form/HintedSelectInputOption.css b/frontend/src/Components/Form/HintedSelectInputOption.css new file mode 100644 index 000000000..74d1fb088 --- /dev/null +++ b/frontend/src/Components/Form/HintedSelectInputOption.css @@ -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; +} diff --git a/frontend/src/Components/Form/HintedSelectInputOption.js b/frontend/src/Components/Form/HintedSelectInputOption.js new file mode 100644 index 000000000..5ccc48a13 --- /dev/null +++ b/frontend/src/Components/Form/HintedSelectInputOption.js @@ -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 ( + +
+
{value}
+ + { + hint != null && +
+ {hint} +
+ } +
+
+ ); +} + +HintedSelectInputOption.propTypes = { + value: PropTypes.string.isRequired, + hint: PropTypes.node, + isMobile: PropTypes.bool.isRequired +}; + +export default HintedSelectInputOption; diff --git a/frontend/src/Components/Form/HintedSelectInputSelectedValue.css b/frontend/src/Components/Form/HintedSelectInputSelectedValue.css new file mode 100644 index 000000000..a31970a9e --- /dev/null +++ b/frontend/src/Components/Form/HintedSelectInputSelectedValue.css @@ -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; +} diff --git a/frontend/src/Components/Form/HintedSelectInputSelectedValue.js b/frontend/src/Components/Form/HintedSelectInputSelectedValue.js new file mode 100644 index 000000000..d43c3e4da --- /dev/null +++ b/frontend/src/Components/Form/HintedSelectInputSelectedValue.js @@ -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 ( + +
+ {value} +
+ + { + hint != null && includeHint && +
+ {hint} +
+ } +
+ ); +} + +HintedSelectInputSelectedValue.propTypes = { + value: PropTypes.string, + hint: PropTypes.string, + includeHint: PropTypes.bool.isRequired +}; + +HintedSelectInputSelectedValue.defaultProps = { + includeHint: true +}; + +export default HintedSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js index 84268806f..3ac96c1c3 100644 --- a/frontend/src/Components/Form/ProviderFieldFormGroup.js +++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js @@ -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 ( @@ -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, diff --git a/frontend/src/Components/Form/TagInput.css b/frontend/src/Components/Form/TagInput.css index 87b2849f1..9798017ef 100644 --- a/frontend/src/Components/Form/TagInput.css +++ b/frontend/src/Components/Form/TagInput.css @@ -1,7 +1,6 @@ .input { composes: input from '~./AutoSuggestInput.css'; - position: relative; padding: 0; min-height: 35px; height: auto; diff --git a/frontend/src/Components/Form/TagInputInput.css b/frontend/src/Components/Form/TagInputInput.css index 059946f34..22515b310 100644 --- a/frontend/src/Components/Form/TagInputInput.css +++ b/frontend/src/Components/Form/TagInputInput.css @@ -1,5 +1,4 @@ .inputContainer { - position: absolute; top: -1px; right: -1px; bottom: -1px; diff --git a/frontend/src/Components/Form/TextInput.js b/frontend/src/Components/Form/TextInput.js index 9feefa616..cc0cbca02 100644 --- a/frontend/src/Components/Form/TextInput.js +++ b/frontend/src/Components/Form/TextInput.js @@ -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, diff --git a/frontend/src/Components/Link/Link.js b/frontend/src/Components/Link/Link.js index 6f3caaef6..0236d2f64 100644 --- a/frontend/src/Components/Link/Link.js +++ b/frontend/src/Components/Link/Link.js @@ -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; diff --git a/frontend/src/Components/Page/Header/MovieSearchInput.js b/frontend/src/Components/Page/Header/MovieSearchInput.js index 60a86eb96..a2d1c0ab1 100644 --- a/frontend/src/Components/Page/Header/MovieSearchInput.js +++ b/frontend/src/Components/Page/Header/MovieSearchInput.js @@ -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 }); } diff --git a/frontend/src/Components/Page/Header/MovieSearchInputConnector.js b/frontend/src/Components/Page/Header/MovieSearchInputConnector.js index 54482a6ab..08d13040f 100644 --- a/frontend/src/Components/Page/Header/MovieSearchInputConnector.js +++ b/frontend/src/Components/Page/Header/MovieSearchInputConnector.js @@ -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 { diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index 6c1b28faf..cbf48a031 100644 --- a/frontend/src/Components/SignalRConnector.js +++ b/frontend/src/Components/SignalRConnector.js @@ -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; diff --git a/frontend/src/Content/Images/Icons/favicon-debug-16x16.png b/frontend/src/Content/Images/Icons/favicon-debug-16x16.png new file mode 100644 index 000000000..40d6cc24c Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug-16x16.png differ diff --git a/frontend/src/Content/Images/Icons/favicon-debug-32x32.png b/frontend/src/Content/Images/Icons/favicon-debug-32x32.png new file mode 100644 index 000000000..a05042842 Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug-32x32.png differ diff --git a/frontend/src/Content/Images/Icons/favicon-debug.ico b/frontend/src/Content/Images/Icons/favicon-debug.ico new file mode 100644 index 000000000..73ba8d1ba Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug.ico differ diff --git a/frontend/src/Movie/Delete/DeleteMovieModalContent.js b/frontend/src/Movie/Delete/DeleteMovieModalContent.js index 5cd470f1a..f76a4de35 100644 --- a/frontend/src/Movie/Delete/DeleteMovieModalContent.js +++ b/frontend/src/Movie/Delete/DeleteMovieModalContent.js @@ -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 {
The movie folder {path} and all it's content will be deleted.
{ - !!episodeFileCount && -
{episodeFileCount} movie files totaling {formatBytes(sizeOnDisk)}
+ !!movieFileCount && +
{movieFileCount} movie files totaling {formatBytes(sizeOnDisk)}
} } @@ -137,7 +137,7 @@ DeleteMovieModalContent.propTypes = { DeleteMovieModalContent.defaultProps = { statistics: { - episodeFileCount: 0 + movieFileCount: 0 } }; diff --git a/frontend/src/Movie/Details/MovieDetails.js b/frontend/src/Movie/Details/MovieDetails.js index 88efe3182..343350d96 100644 --- a/frontend/src/Movie/Details/MovieDetails.js +++ b/frontend/src/Movie/Details/MovieDetails.js @@ -584,7 +584,7 @@ MovieDetails.propTypes = { }; MovieDetails.defaultProps = { - tag: [], + tags: [], isSaving: false, sizeOnDisk: 0 }; diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPoster.js b/frontend/src/Movie/Index/Posters/MovieIndexPoster.js index 64d70adf2..0ae0cfa46 100644 --- a/frontend/src/Movie/Index/Posters/MovieIndexPoster.js +++ b/frontend/src/Movie/Index/Posters/MovieIndexPoster.js @@ -254,10 +254,7 @@ MovieIndexPoster.propTypes = { MovieIndexPoster.defaultProps = { statistics: { - seasonCount: 0, - episodeCount: 0, - episodeFileCount: 0, - totalEpisodeCount: 0 + movieFileCount: 0 } }; diff --git a/frontend/src/Movie/MovieBanner.js b/frontend/src/Movie/MovieBanner.js new file mode 100644 index 000000000..3e24f78e7 --- /dev/null +++ b/frontend/src/Movie/MovieBanner.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import MovieImage from './MovieImage'; + +const bannerPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXsAAABGCAIAAACiz6ObAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAYdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjEuMWMqnEsAAAVeSURBVHhe7d3dduI4EEXheaMOfzPv/2ZzpCqLsmULQWjf1P4WkwnEtrhhr7IhnX9uAHAWigPgPBQHwHkoDoDzUBwA56E4AM5DcQCch+IAOA/FwQfuuonfA6ZRHLymuDwej3+r/zp6TI9rAxqElygODtXQ7CRmwNLj+wMdioMdas3uODOPkQe7KA5Wft+aiO5gg+LAfbc1DedZiCgOzF/JTaOD+zrIjeKguF6vmnE8D9+mlKloWsIXQ2IUByU3Rqc/HomviktQneQoTnaXy/Wi/xbfnXQ03eiAfuirL+QLIyWKk1oLQWhOic5XrunoIJvc+DK+ODKiOEmpBY9HuZpbaxByUOnxX0bHLhX74Zbpxuhx3r1Ki+IkZUGJXVAS+i5YPt5io83zsOuztrY00cmJ4mSkIlgdZBWdy/Xn51kHozTMjzuxNSbmRuKvTdTnglwoTkY2ZTS66z2ogdhEx+4oJZu9Gj2uKmmDuuHKj44VirMZmix2SIXipBMHnGZ9TWdbCrPct8M43dVD/cY6QJebnWDZTIQ8KE46R6OKBhBvQ51NdqMzQ3tp1z9/ygHsES26mW4axpxsKE4uuwNO086MajU+iY7vGHIjR7kxelL+5JAAxcnlaMAx+mnrhLVDo8pb0VFoSmxCbhS50ZK8aZUMxcnFX+XH4gVgi04fHD2iH+2WqH/8fn/xFjsnVqlQnETGp1Qmjjk91URTT7vZ2dNgBtKi46lKKE4qFCeR8fWUxt5+6pWTrHqe1d+OqqNF/aBDvGOVB8VJZLI49/CmVWPXdEz5pr91Hx2UmalKKE4eFCeRlyc45hE+EGjsZMpa03/T7vaTzmTjuHicB8VJZLI42syDsShRWXhrluK0R8rdneLMNY7ipEFxEpksjngwFq0pJTXt++4mvsNidqqiOGlQnETeKE78bcxLKU4dZupXad+Y8FPfZUFxsEFxEpkvjjb2ZtRP37QRZvvNMt34XYqDVyhOIm/MOPEN8kFxFu2u77KgONigOIlMvv61lQdj8fzg3xKXzc2Gnf4NcoqDDYqTyHRxdj52XCeZ8mXANw2UEj/o0P1OcbKgOIlMvv61WV+cS/0VTZ9o9iad3Y8d8wlAbFCcRCZf/9rSg7GmqFhcNsXR7POb33LQSEVx8qA4iUwVp7uIE6ksJTdt2Cn12W+N0aIvT+W0gT09ZEBxcnn5+leVvBb1ffH6q+FdU/SA3TqlQOvtX57KUZxUKE4u49e/Xvzts3/KhurRF2Ss7LI+ydKi48xxSpUKxUln8PqPA84HuTHltKte6/H7wzFHz8WfFnKgOOkcFcfObqwRPqoMr9EMLNHx3QeLKkb1SSELipPO7vXjmBspI8r7001ULyo/x5z7wZhjTwl5UJyMNqc5ys36gnHhd6K6r7ZclL+KJ/Vh1Wr1nnrZP/z9X/1Pe/p6CwachChORspEO80Z58Y2VhqOTouMfliPU/8yZ5iV4tFKdG6rde3JIBWKk5SNOfaytyJI35pxaHYpTrE7OuT6sOWYom3qE0EuFCevelLj042SELugMHzQmmj9Z4UL+17UGnKTFsVJzRKwzc31qjnFy/ELatZzifJhZV/ClkZOFCe7koPwLrjK88vpJtKk48et0bGFfGGkRHFwiwPOF3Nj7A0pO7gWshWRFsVBoRzo69dzY1p06lJIjeLA3ef+LYsP2AUdQCgOnhSdv3FWxTtTaCgOtr7VHR3DzqeAhuJgn2Lh5XifgkVrsIvi4JCGHYVD+ZifeLQp51AYoDiYoYyU+hwpPyY0mEBxAJyH4gA4D8UBcB6KA+A8FAfAeSgOgPNQHADnoTgAzkNxAJzldvsfnbIbPuBaveQAAAAASUVORK5CYII='; + +function MovieBanner(props) { + return ( + + ); +} + +MovieBanner.propTypes = { + size: PropTypes.number.isRequired +}; + +MovieBanner.defaultProps = { + size: 70 +}; + +export default MovieBanner; diff --git a/frontend/src/Movie/MovieImage.js b/frontend/src/Movie/MovieImage.js new file mode 100644 index 000000000..ab587e6a7 --- /dev/null +++ b/frontend/src/Movie/MovieImage.js @@ -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 ( + + ); + } + + if (lazy) { + return ( + + } + > + + + ); + } + + return ( + + ); + } +} + +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; diff --git a/frontend/src/Movie/MoviePoster.js b/frontend/src/Movie/MoviePoster.js index 17c86264c..c34202149 100644 --- a/frontend/src/Movie/MoviePoster.js +++ b/frontend/src/Movie/MoviePoster.js @@ -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,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3CAMAAAC+Te+kAAAAZlBMVEUvLi8vLy8vLzAvMDAwLy8wLzAwMDAwMDEwMTExMDAxMDExMTExMTIxMjIyMjIyMjMyMzMzMjMzMzMzMzQzNDQ0NDQ0NDU0NTU1NTU1NTY1NjY2NTY2NjY2Njc2Nzc3Njc3Nzc3NziHChLWAAAKHklEQVR42u2c25bbKhJATTmUPAZKPerBjTMo0fn/n5wHSYBkXUDCnXPWwEPaneVIO0XdKAouzT9kXApoAS2gBbSAFtACWkALaAEtoAW0gBbQAlpAC2gBLaAFtIAW0AJaQAtoAS2gBbSAFtACWkALaAEtoAW0gP4DQLXW+vF4GGMeD6211n87UK2NsW33OlprTB7eSw5I220PmwH2JKh+7EGOoj3Lejkly0hKx/pHQLVpu9RhzbeDHsEc1PU7QbXpDo/WfB/oQWmeUoADoMZ2Z4fV7wfV5zG7ruvMu0FPzvpxoV7+hDiPCDUJVLddzmHfBfqZlzONNAG0VrXNy/lB7wCtifKSth+KKD8oEREpshk5JRFRnRm0VkREJLKR2kYQERF9ZAUdHkokM5EO8iQiQRlBiSG552Yhdf91wfDf2UBrkj+Q6nyk9mPklAzj9PQSqZ/qR0aZtrWXZ0UUZfuXKL9ERBKzkdray/Nf/YcsoIrmpOcsynMKqMZHngfVn4MHJeVJz/jTYN7RORN1GlTb7tM5Eqw86fMg55Pc47jjpGY3698DtV3Xfgo1kjqZEulD4tTKafrVO+cP23WPU6Bm6vSC2SfVJK/w2p8fntPPu6ht13WtPgE6SK0dSeuQlMSn/ZWW1EvHWYGYOxF7AtTOAzMpHpKKRwqm8jpZMfHq7MxhUD+3bXMb9QmwdwIDqrYx6bS1WnhMuoWcrX/JQdBw5RHMPgQyJRKiee6w/rLmM8RclueOSC9RAp1YlPyBKnirEoK0sXZVlk9NQrh/URMhm9mRG/oQ6Mz/tKGehqRESgjVaGPsRLSttU+jGxJCBt+Vap1zy542QJ9/zYTjPL/iWAmasd4EUdNoYx7m68sYrT8/ahJTSlIVIrq/kc18HvQB0AWH3jhBIuN3ehlSSiGEFEoKIYWQcv4FVQGwSjlP3MavS9dBl2Lk5xiiGICPp/EDOQBzetMs6LVOBl2MkL/G5BkAYEmmm0NVAAAuIi1xrov0EmfyLqVQnhThni5Pz7mSgOlE0JXqTaulI0WAW4o8kfGAUz7SKlJroGuxsXUxiXO8Tn3/jjwZIvfypLUXJIKuppvGp+eAHKp4TkDwGaj4ufYCnQS6kWz6xBcQgVWRdsT4FcKMfjXqPpNAN1JN4xRT8CtCnEXdGCD6zI6E3citU0A3lkStEymJKwPGZQSoRBbIk+THRg6TArq5zDA+wxDAcMZZKymlVK82D2Ga9zO5En0A1AYUwYKiF5XAYQgxllbGZCD4FrXJ5d1Lqop2XauDd05EJypkDBgHYIxxrNaU4ra9ZaHjQTdX7a0Vaun1Aq8AAAA4/MGwWvzilimtzv0leea7rq0XRKVuwELQ4aNY4my+CbTTC69HAHgFDVx8sBIxB/YgLinx0/lkscgJiAgAHJEDICICcFyQqdirB0WD7lUWLKlXTgQERARE4IjAAThH5K+zv1+40rGguz0izUxJb6E4e9l6HeBzge7uVz1ygc6VVKBjG37wAHSeuIjdUpCJBd2tJ3yJeWY06OQg10GwAzuIN4Hu1+nMZOrltRclH7S0l2ivrr2Upzq6W7G02UCn1lQxBOQcOCBw4JwDAFwHSg4I04LF/vZfTlA5WWP04R0QARAAOSBcERGG31k493LfBNp8oB9yakq97cxCqDMohpO4tF9VywfaBDISzr4XItNAG/6/IkrV2UDb/wSgdzayIf+7gXYBaH1ng29yUdP/gtjHU+lz05jibz6J6kBEzoHy8AcfP3PEScD/VtBJaKogiJpjZOKBDuDE5X8r6K9dUJyA/j0kegevk5MQ6gIT+3NWryfuiY/JKALiFQA4R47IB2qc+tFvBW3UJDL1wkNAuCLnCPw6ps8c+VRFSex3T70pMlEfQgHh2ufPCFfoQ+iop6JOikzvSkrEIFG4YuDjPSibJCUyX1Kyn48+J6AKt0Mou6WtRBbrZMdAzbRmI9jo7H0kxd5FcYRplkdK7YKabEsRI2aFJeS9jY/pXv+p/3Cdre7Ef78NtJ0v7CUHQOQ4WHmf3l9HhzUv6Ox6fJ1tudzMl8CCuwwKAQBYYFWUvArVuQoQr+t6EnwlhOJrBXLPmtpsJR0jlkpki6CvnKT2KiXxJZ0dl/x7qfZECoE5VzrqwWLdfC8tiS+S7VjTZGk3FSrvSRGBM0Bc/p78sMkqeqSQ+9uKtVK9QAQGDBgDfNmAjq6SJYBul8b1pMo9V8D7XVTVXcwoJ1u82wlUSml8M8EJbV4s7TPVS9u17B5bw0/ZbNice7/RRAoZrJS/Z3bGryHp7Zlp+2Zr7n/7wrhEhvwSsXMrGOdhbrLVhWjTthjX5+Z584L6wafZ+wYpcM6idu5M2qat2d8LVQjIGaoYUKoY8nA7ct1Vp23ars+9EQEnxnIS3QEhIJUm8bTDZa/b7WUn1PW9AiCP5uzzlnD11MaXxQ+0anSurfKlSrdPOqk+r3RApPeULJ8Isr6PGID3IbJe959T5yqmK1Kb0qmx0U60KNJxmdwvN+W+q59F2LBg1sRv1m93ki11JXlDWszg9i0qUBelEwS6BfoqUqP8ImmZUykphRJCSKnUwhfuWAX9Gia+kWyz29Gu7IXUhFxUYjrPSgpxE5Lq/pDKR01S3MR8H1pJuju/r+SjjRXoJuhjbXMJ5+0ZStwENfpp+9H2P/pex9scVnjS2ZaTPdqRa5c7NJBNXy0ENcYud5Dap/mUNznbPxtnQ00TPn0UNHzKw8uTyWnvaGPtViZs22czTU/HjlxFMlyW2OPN2G5mfn+5PlAEFfaQyK+IJufWPijUAAxmX0e1OO/14VsnTznae6ifkqIPtLaGwjYd13AgHak5AzqkewEnHsLsSfzCpb77bkL5tdVBFnsEw/T27uwojEbJ526tDvR0fFKtpN6d+IjTN6brHtJHeOfyqTlyrCU4g+E9v1J62+LjzjNZV2NUXp5KHTrT0nWtVguzo/TuQeZ9UE2vJ1rUoFdHhlHSxVOvs1nO3PW5csgpjnN2nfGezulpplOMpKgO4qYSp07Zt0/n/hGpJlKZDgc2TdM/03m+R3dqtDOZRp0KjjxpK4GP+e5pzq7rjJfpj6wnbRvya50MnF3nZl8BNjlBGz/vpssx/Ow3eUHHc+syD+e4A6SiD9gn3FhARErl4uzXNapu3gDa1IrycXadIXrL1QpN09Q5ORPv/0i7pyQvqH4faM4bVRKvfkm+SyeTUJMvU0q/nSiLUNOvJzpy39Ppi3+OXPh06GIq/fzWWT8Oegb16F1vh295O3Z72uG7087cm6cT7/z66wTm2ZsIU8RqT93vd/puRx0n1/O3O+a4LVM/NmFtlvsyc90/qrUxz5fT4MZku4Q0/42uWue+I/VNoG8aBbSAFtACWkALaAEtoAW0gBbQAlpAC2gBLaAFtIAW0AJaQAtoAS2gBbSAFtACWkALaAEtoAW0gBbQAlpA/99B/wd7kHH8CSaCpAAAAABJRU5ErkJggg=='; -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 ( - - ); - } - - if (lazy) { - return ( - - } - > - - - ); - } - - return ( - - ); - } +function MoviePoster(props) { + return ( + + ); } 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; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js index 6a86fef16..4724ab9ad 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js @@ -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 } + + { + priority > 1 && + + } +
{ !!message && + Client Priority + + + + } diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js index 59c1cb498..b474cbea2 100644 --- a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js @@ -23,6 +23,7 @@ function EditRemotePathMappingModalContent(props) { isSaving, saveError, item, + downloadClientHosts, onInputChange, onSavePress, onModalClose, @@ -55,17 +56,16 @@ function EditRemotePathMappingModalContent(props) { { !isFetching && !error && -
+ Host @@ -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, diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js index 00aa7b8ac..df7f59f52 100644 --- a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js @@ -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 }; diff --git a/frontend/src/Settings/General/BackupSettings.js b/frontend/src/Settings/General/BackupSettings.js index f0f9316e2..01c3ef755 100644 --- a/frontend/src/Settings/General/BackupSettings.js +++ b/frontend/src/Settings/General/BackupSettings.js @@ -49,7 +49,8 @@ function BackupSettings(props) { @@ -64,7 +65,8 @@ function BackupSettings(props) { diff --git a/frontend/src/Settings/General/GeneralSettings.js b/frontend/src/Settings/General/GeneralSettings.js index 772112582..785b3d3e2 100644 --- a/frontend/src/Settings/General/GeneralSettings.js +++ b/frontend/src/Settings/General/GeneralSettings.js @@ -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, diff --git a/frontend/src/Settings/General/GeneralSettingsConnector.js b/frontend/src/Settings/General/GeneralSettingsConnector.js index 804fdfde7..bd27c26e3 100644 --- a/frontend/src/Settings/General/GeneralSettingsConnector.js +++ b/frontend/src/Settings/General/GeneralSettingsConnector.js @@ -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}` }); } // diff --git a/frontend/src/Settings/General/HostSettings.js b/frontend/src/Settings/General/HostSettings.js index 42c97e0dd..1f4bf6775 100644 --- a/frontend/src/Settings/General/HostSettings.js +++ b/frontend/src/Settings/General/HostSettings.js @@ -87,56 +87,59 @@ function HostSettings(props) { { - enableSsl.value && - - SSL Port - - - + enableSsl.value ? + + SSL Port + + + : + null } { - isWindows && enableSsl.value && - - SSL Cert Hash - - - + isWindows && enableSsl.value ? + + SSL Cert Hash + + + : + null } { - mode !== 'service' && - - Open browser on start - - - + isWindows && mode !== 'service' ? + + Open browser on start + + + : + null } diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.js b/frontend/src/Settings/Quality/Definition/QualityDefinition.js index 41af7cf1c..8429c2aef 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinition.js +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js @@ -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 (
@@ -178,13 +178,35 @@ class QualityDefinition extends Component {
- - + {minSixty} + } + title="Minimum Limits" + body={ + + } + position={tooltipPositions.BOTTOM} + />
- - + {maxSixty} + } + title="Maximum Limits" + body={ + + } + position={tooltipPositions.BOTTOM} + />
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.js index c925dc9c0..3451dbf1a 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.js +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.js @@ -12,15 +12,15 @@ function QualityDefinitionLimits(props) { return
{message}
; } - 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 (
-
30 Minutes: {thirty}
-
45 Minutes: {fourtyFive}
60 Minutes: {sixty}
+
90 Minutes: {ninety}
+
120 Minutes: {hundredTwenty}
); } diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index d27f9c5b9..8e14da9ac 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -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, diff --git a/frontend/src/Store/Actions/deviceActions.js b/frontend/src/Store/Actions/providerOptionActions.js similarity index 68% rename from frontend/src/Store/Actions/deviceActions.js rename to frontend/src/Store/Actions/providerOptionActions.js index 089d49bf3..c8d05e7e1 100644 --- a/frontend/src/Store/Actions/deviceActions.js +++ b/frontend/src/Store/Actions/providerOptionActions.js @@ -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); } diff --git a/frontend/src/Store/Selectors/createMovieQualityProfileSelector.js b/frontend/src/Store/Selectors/createMovieQualityProfileSelector.js index 9892358e2..3b804bcb4 100644 --- a/frontend/src/Store/Selectors/createMovieQualityProfileSelector.js +++ b/frontend/src/Store/Selectors/createMovieQualityProfileSelector.js @@ -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; }); diff --git a/frontend/src/Styles/globals.css b/frontend/src/Styles/globals.css index ffdc885c2..aa30af147 100644 --- a/frontend/src/Styles/globals.css +++ b/frontend/src/Styles/globals.css @@ -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 */ diff --git a/frontend/src/System/Backup/BackupRow.js b/frontend/src/System/Backup/BackupRow.js index df5ff974c..451cbd214 100644 --- a/frontend/src/System/Backup/BackupRow.js +++ b/frontend/src/System/Backup/BackupRow.js @@ -96,7 +96,7 @@ class BackupRow extends Component { {name} diff --git a/frontend/src/System/Updates/Updates.css b/frontend/src/System/Updates/Updates.css index 86968845c..6ed588890 100644 --- a/frontend/src/System/Updates/Updates.css +++ b/frontend/src/System/Updates/Updates.css @@ -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; diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js index 215d1aa5b..feac8fb6f 100644 --- a/frontend/src/System/Updates/Updates.js +++ b/frontend/src/System/Updates/Updates.js @@ -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 ( { - !isPopulated && !error && + !isPopulated && !hasError && } @@ -48,15 +58,30 @@ class Updates extends Component { { hasUpdateToInstall && -
- - Install Latest - +
+ { + updateMechanism === 'builtIn' || updateMechanism === 'script' ? + + Install Latest + : + + + + +
+ {externalUpdaterMessages[updateMechanism] || externalUpdaterMessages.external} +
+
+ } { isFetching && @@ -70,13 +95,14 @@ class Updates extends Component { { noUpdateToInstall && -
+
-
+ +
The latest version of Radarr is already installed
@@ -108,13 +134,25 @@ class Updates extends Component {
{formatDate(update.releaseDate, shortDateFormat)}
{ - update.branch !== 'master' && + update.branch === 'master' ? + null: } + + { + update.version === currentVersion ? + : + null + }
{ @@ -144,11 +182,18 @@ class Updates extends Component { } { - !!error && + !!updatesError &&
Failed to fetch updates
} + + { + !!generalSettingsError && +
+ Failed to update settings +
+ } ); @@ -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 }; diff --git a/frontend/src/System/Updates/UpdatesConnector.js b/frontend/src/System/Updates/UpdatesConnector.js index 0d0aa491f..8836bbb94 100644 --- a/frontend/src/System/Updates/UpdatesConnector.js +++ b/frontend/src/System/Updates/UpdatesConnector.js @@ -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); diff --git a/frontend/src/index.html b/frontend/src/index.html index b4cec5a9e..069609647 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -1,30 +1,56 @@ - - - - - + + + + - + - + - + - - - - - - - + + + + + + + - + + - Radarr + Radarr (Preview) + + + + + + + + + + + + + + + + + + Login - Radarr + + - - - -
-
-
-
- -
-
- + .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; + } + } + + + + +
+
+
+
+
-
- - - - - - Forgot your password? +
+ + + +
+ +
+ +
+ +
+ +
+ + + + + + Forgot your password? +
+ + + +
- +
- - +
+ - -
-
- - - + loginFailedDiv.classList.remove("hidden"); + } + diff --git a/src/NzbDrone.Api/DownloadClient/DownloadClientModule.cs b/src/NzbDrone.Api/DownloadClient/DownloadClientModule.cs index d7568189f..8b5ad25d4 100644 --- a/src/NzbDrone.Api/DownloadClient/DownloadClientModule.cs +++ b/src/NzbDrone.Api/DownloadClient/DownloadClientModule.cs @@ -15,6 +15,7 @@ namespace NzbDrone.Api.DownloadClient resource.Enable = definition.Enable; resource.Protocol = definition.Protocol; + resource.Priority = definition.Priority; } protected override void MapToModel(DownloadClientDefinition definition, DownloadClientResource resource) @@ -23,6 +24,7 @@ namespace NzbDrone.Api.DownloadClient definition.Enable = resource.Enable; definition.Protocol = resource.Protocol; + definition.Priority = resource.Priority; } protected override void Validate(DownloadClientDefinition definition, bool includeWarnings) diff --git a/src/NzbDrone.Api/DownloadClient/DownloadClientResource.cs b/src/NzbDrone.Api/DownloadClient/DownloadClientResource.cs index a7156e08d..5e268578b 100644 --- a/src/NzbDrone.Api/DownloadClient/DownloadClientResource.cs +++ b/src/NzbDrone.Api/DownloadClient/DownloadClientResource.cs @@ -6,5 +6,6 @@ namespace NzbDrone.Api.DownloadClient { public bool Enable { get; set; } public DownloadProtocol Protocol { get; set; } + public int Priority { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.Common.Test/PathExtensionFixture.cs b/src/NzbDrone.Common.Test/PathExtensionFixture.cs index e2b5eace3..08e9b32b2 100644 --- a/src/NzbDrone.Common.Test/PathExtensionFixture.cs +++ b/src/NzbDrone.Common.Test/PathExtensionFixture.cs @@ -86,7 +86,7 @@ namespace NzbDrone.Common.Test { first.AsOsAgnostic().PathEquals(second.AsOsAgnostic()).Should().BeFalse(); } - + [Test] public void should_return_false_when_not_a_child() { @@ -113,6 +113,7 @@ namespace NzbDrone.Common.Test [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 @@ namespace NzbDrone.Common.Test } [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); } diff --git a/src/NzbDrone.Common/Disk/SystemFolders.cs b/src/NzbDrone.Common/Disk/SystemFolders.cs new file mode 100644 index 000000000..c108e3d02 --- /dev/null +++ b/src/NzbDrone.Common/Disk/SystemFolders.cs @@ -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 GetSystemFolders() + { + if (OsInfo.IsWindows) + { + return new List { Environment.GetFolderPath(Environment.SpecialFolder.Windows) }; + } + + if (OsInfo.IsOsx) + { + return new List { "/System" }; + } + + return new List + { + "/bin", + "/boot", + "/lib", + "/sbin", + "/proc" + }; + } + } +} diff --git a/src/NzbDrone.Common/Exceptions/RadarrStartupException.cs b/src/NzbDrone.Common/Exceptions/RadarrStartupException.cs index 218afe6e6..3cd74cc09 100644 --- a/src/NzbDrone.Common/Exceptions/RadarrStartupException.cs +++ b/src/NzbDrone.Common/Exceptions/RadarrStartupException.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace NzbDrone.Common.Exceptions { @@ -10,19 +7,16 @@ namespace NzbDrone.Common.Exceptions 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 @@ namespace NzbDrone.Common.Exceptions public RadarrStartupException(Exception innerException) : base("Radarr failed to start: " + innerException.Message) { - } } } diff --git a/src/NzbDrone.Common/NzbDrone.Common.csproj b/src/NzbDrone.Common/NzbDrone.Common.csproj index af93dfd23..ca0db01b6 100644 --- a/src/NzbDrone.Common/NzbDrone.Common.csproj +++ b/src/NzbDrone.Common/NzbDrone.Common.csproj @@ -98,6 +98,7 @@ + diff --git a/src/NzbDrone.Common/Processes/ProcessProvider.cs b/src/NzbDrone.Common/Processes/ProcessProvider.cs index dee42d1c2..85637e888 100644 --- a/src/NzbDrone.Common/Processes/ProcessProvider.cs +++ b/src/NzbDrone.Common/Processes/ProcessProvider.cs @@ -27,7 +27,7 @@ namespace NzbDrone.Common.Processes bool Exists(string processName); ProcessPriorityClass GetCurrentProcessPriority(); Process Start(string path, string args = null, StringDictionary environmentVariables = null, Action onOutputDataReceived = null, Action 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 @@ namespace NzbDrone.Common.Processes public Process Start(string path, string args = null, StringDictionary environmentVariables = null, Action onOutputDataReceived = null, Action 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 @@ namespace NzbDrone.Common.Processes 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 @@ namespace NzbDrone.Common.Processes var monoProcesses = Process.GetProcessesByName("mono") .Union(Process.GetProcessesByName("mono-sgen")) - .Union(Process.GetProcessesByName("mono-sgen32")) .Where(process => process.Modules.Cast() .Any(module => @@ -359,9 +353,19 @@ namespace NzbDrone.Common.Processes 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); } } } diff --git a/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs b/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs index 9257c407f..ee1a7eb3a 100644 --- a/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs @@ -6,6 +6,8 @@ using NzbDrone.Core.MediaFiles; 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 @@ namespace NzbDrone.Core.Test.Datastore public void embedded_document_as_json() { var quality = new QualityModel { Quality = Quality.Bluray720p, Revision = new Revision(version: 2 )}; + var languages = new List { Language.English }; var history = Builder.CreateNew() .With(c => c.Id = 0) .With(c => c.Quality = quality) + .With(c => c.Languages = languages) .Build(); Db.Insert(history); @@ -79,14 +83,18 @@ namespace NzbDrone.Core.Test.Datastore [Test] public void embedded_list_of_document_with_json() { + var languages = new List { Language.English }; + var history = Builder.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(); diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/156_add_download_client_priorityFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/156_add_download_client_priorityFixture.cs new file mode 100644 index 000000000..1ea3237aa --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/156_add_download_client_priorityFixture.cs @@ -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 + { + [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("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("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("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; } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RawDiskSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RawDiskSpecificationFixture.cs index ef36d40bf..f7e365f7b 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RawDiskSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RawDiskSpecificationFixture.cs @@ -19,7 +19,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { _remoteMovie = new RemoteMovie { - Release = new ReleaseInfo() { DownloadProtocol = DownloadProtocol.Torrent } + Release = new ReleaseInfo + { + Title = "Movie.title.1998", + DownloadProtocol = DownloadProtocol.Torrent + } }; } @@ -69,5 +73,15 @@ namespace NzbDrone.Core.Test.DecisionEngineTests 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(); + } } } diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs new file mode 100644 index 000000000..0dbed674f --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientProviderFixture.cs @@ -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 + { + private List _downloadClients; + private List _blockedProviders; + private int _nextId; + + [SetUp] + public void SetUp() + { + _downloadClients = new List(); + _blockedProviders = new List(); + _nextId = 1; + + Mocker.GetMock() + .Setup(v => v.GetAvailableProviders()) + .Returns(_downloadClients); + + Mocker.GetMock() + .Setup(v => v.GetBlockedProviders()) + .Returns(_blockedProviders); + } + + private Mock WithUsenetClient(int priority = 0) + { + var mock = new Mock(MockBehavior.Default); + mock.SetupGet(s => s.Definition) + .Returns(Builder + .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 WithTorrentClient(int priority = 0) + { + var mock = new Mock(MockBehavior.Default); + mock.SetupGet(s => s.Definition) + .Returns(Builder + .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); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs index 756098a6e..e00d53d85 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs @@ -10,6 +10,7 @@ using NzbDrone.Test.Common; 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 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests DownloadRate = 7000000 }); + Mocker.GetMock() .Setup(v => v.GetVersion(It.IsAny())) .Returns("14.0"); @@ -277,16 +279,16 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests } [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 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests var remoteMovie = CreateRemoteMovie(); - Assert.Throws(() => Subject.Download(remoteMovie)); + Assert.Throws(() => Subject.Download(remoteMovie)); } [Test] diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedMovieFilesFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedMovieFilesFixture.cs index d924d7991..c92e12549 100644 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedMovieFilesFixture.cs +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedMovieFilesFixture.cs @@ -7,6 +7,8 @@ using NzbDrone.Core.MediaFiles; 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 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers [Test] public void should_delete_orphaned_episode_files() { - var episodeFile = Builder.CreateNew() + var movieFile = Builder.CreateNew() .With(h => h.Quality = new QualityModel()) + .With(h => h.Languages = new List { 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.CreateListOfSize(2) + var movieFiles = Builder.CreateListOfSize(2) .All() .With(h => h.Quality = new QualityModel()) + .With(h => h.Languages = new List { Language.English }) .BuildListOfNew(); - Db.InsertMany(episodeFiles); + Db.InsertMany(movieFiles); - var episode = Builder.CreateNew() - .With(e => e.MovieFileId = episodeFiles.First().Id) + var movie = Builder.CreateNew() + .With(e => e.MovieFileId = movieFiles.First().Id) .BuildNew(); - Db.Insert(episode); + Db.Insert(movie); Subject.Clean(); AllStoredModels.Should().HaveCount(1); diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs index 6c270c560..85b57989f 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs @@ -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 @@ namespace NzbDrone.Core.Test.MediaFiles } [Test] - public void get_files_by_series() + public void get_files_by_movie() { var files = Builder.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.English }) .Random(4) .With(s => s.MovieId = 12) .BuildListOfNew(); @@ -29,10 +32,10 @@ namespace NzbDrone.Core.Test.MediaFiles 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); } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs index 5a4372369..10afb30cc 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs @@ -30,7 +30,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaInfo { 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] diff --git a/src/NzbDrone.Core.Test/MovieTests/ShouldRefreshMovieFixture.cs b/src/NzbDrone.Core.Test/MovieTests/ShouldRefreshMovieFixture.cs index 762dab6d6..e8d9bc037 100644 --- a/src/NzbDrone.Core.Test/MovieTests/ShouldRefreshMovieFixture.cs +++ b/src/NzbDrone.Core.Test/MovieTests/ShouldRefreshMovieFixture.cs @@ -34,7 +34,7 @@ namespace NzbDrone.Core.Test.MovieTests private void GivenMovieLastRefreshedMonthsAgo() { - _movie.LastInfoSync = DateTime.UtcNow.AddDays(-90); + _movie.LastInfoSync = DateTime.UtcNow.AddDays(-190); } private void GivenMovieLastRefreshedYesterday() diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 016db40c4..a202f6dd4 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -147,6 +147,7 @@ + @@ -177,6 +178,7 @@ + diff --git a/src/NzbDrone.Core.Test/RemotePathMappingsTests/RemotePathMappingServiceFixture.cs b/src/NzbDrone.Core.Test/RemotePathMappingsTests/RemotePathMappingServiceFixture.cs index b041e1dfe..15bda4fd9 100644 --- a/src/NzbDrone.Core.Test/RemotePathMappingsTests/RemotePathMappingServiceFixture.cs +++ b/src/NzbDrone.Core.Test/RemotePathMappingsTests/RemotePathMappingServiceFixture.cs @@ -87,6 +87,7 @@ namespace NzbDrone.Core.Test.RemotePathMappingsTests } [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 @@ namespace NzbDrone.Core.Test.RemotePathMappingsTests } [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")] diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index 1da298c1d..70a9fbe46 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -19,6 +19,7 @@ namespace NzbDrone.Core.Annotations 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 @@ namespace NzbDrone.Core.Annotations Select, Path, FilePath, - Hidden, Tag, Action, Url, @@ -38,4 +38,11 @@ namespace NzbDrone.Core.Annotations OAuth, Device } + + public enum HiddenType + { + Visible, + Hidden, + HiddenIfNotSet + } } diff --git a/src/NzbDrone.Core/Datastore/Converters/QualityIntConverter.cs b/src/NzbDrone.Core/Datastore/Converters/QualityIntConverter.cs index 7893bd256..72a3cb53a 100644 --- a/src/NzbDrone.Core/Datastore/Converters/QualityIntConverter.cs +++ b/src/NzbDrone.Core/Datastore/Converters/QualityIntConverter.cs @@ -12,7 +12,7 @@ namespace NzbDrone.Core.Datastore.Converters { if (context.DbValue == DBNull.Value) { - return null; + return Quality.Unknown; } var val = Convert.ToInt32(context.DbValue); @@ -27,7 +27,7 @@ namespace NzbDrone.Core.Datastore.Converters public object ToDB(object clrValue) { - if(clrValue == DBNull.Value) return null; + if (clrValue == DBNull.Value) return 0; if (clrValue as Quality == null) { diff --git a/src/NzbDrone.Core/Datastore/Migration/156_add_download_client_priority.cs b/src/NzbDrone.Core/Datastore/Migration/156_add_download_client_priority.cs new file mode 100644 index 000000000..824c0b0fe --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/156_add_download_client_priority.cs @@ -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 _usenetImplementations = new HashSet + { + "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(); + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Datastore/ModelConflictException.cs b/src/NzbDrone.Core/Datastore/ModelConflictException.cs new file mode 100644 index 000000000..66c7f94ff --- /dev/null +++ b/src/NzbDrone.Core/Datastore/ModelConflictException.cs @@ -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) + { + + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs b/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs index 822d72f9a..5d44be05f 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs @@ -33,13 +33,38 @@ namespace NzbDrone.Core.Download.Clients.Deluge _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()) + { + throw new DownloadClientException("Deluge failed to add magnet " + magnetLink); + } + + _proxy.SetTorrentSeedingConfiguration(actualHash, remoteMovie.SeedConfiguration, Settings); + + if (Settings.MovieCategory.IsNotNullOrWhiteSpace()) { - _proxy.SetLabel(actualHash, Settings.MovieCategory, Settings); + _proxy.SetTorrentLabel(actualHash, Settings.MovieCategory, Settings); } var isRecentMovie = remoteMovie.Movie.IsRecentMovie; @@ -64,9 +89,9 @@ namespace NzbDrone.Core.Download.Clients.Deluge _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 @@ namespace NzbDrone.Core.Download.Clients.Deluge { IEnumerable 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(); + torrents = _proxy.GetTorrents(Settings); } var items = new List(); @@ -110,7 +127,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge 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 @@ namespace NzbDrone.Core.Download.Clients.Deluge private ValidationFailure TestCategory() { - if (Settings.MovieCategory.IsNullOrWhiteSpace()) + if (Settings.MovieCategory.IsNullOrWhiteSpace() && Settings.MovieImportedCategory.IsNullOrWhiteSpace()) { return null; } @@ -262,7 +279,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge 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 @@ namespace NzbDrone.Core.Download.Clients.Deluge 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 @@ namespace NzbDrone.Core.Download.Clients.Deluge { 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." }; } } diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs index f2f1c86c0..f3068c497 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs @@ -19,7 +19,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge 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 @@ namespace NzbDrone.Core.Download.Clients.Deluge ProcessRequest(settings, "label.add", label); } - public void SetLabel(string hash, string label, DelugeSettings settings) + public void SetTorrentLabel(string hash, string label, DelugeSettings settings) { ProcessRequest(settings, "label.set_torrent", hash, label); } diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs index 9b0010f67..d0377f81e 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs @@ -13,6 +13,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge 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 @@ namespace NzbDrone.Core.Download.Clients.Deluge [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() diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs index bc8fdf3ed..e856ac0fa 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs @@ -9,6 +9,7 @@ using NzbDrone.Common.Disk; 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 @@ namespace NzbDrone.Core.Download.Clients.Nzbget 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 @@ namespace NzbDrone.Core.Download.Clients.Nzbget historyItem.CanMoveFiles = true; historyItem.CanBeRemoved = true; - if (item.DeleteStatus == "MANUAL") + if (item.DeleteStatus == "MANUAL" || item.DeleteStatus == "COPY") { continue; } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index 949b72a5d..6d0a0b3df 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -35,6 +35,24 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent 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 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent 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 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { 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 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { failures.AddIfNotNull(TestConnection()); if (failures.HasErrors()) return; + failures.AddIfNotNull(TestCategory()); failures.AddIfNotNull(TestPrioritySupport()); failures.AddIfNotNull(TestGetTorrents()); } @@ -249,7 +251,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent 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 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent 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 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; diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentLabel.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentLabel.cs new file mode 100644 index 000000000..7be7d97e7 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentLabel.cs @@ -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; } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs index 41e9719c6..0bde61ce0 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs @@ -23,6 +23,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings); void SetTorrentLabel(string hash, string label, QBittorrentSettings settings); + void AddLabel(string label, QBittorrentSettings settings); + Dictionary GetLabels(QBittorrentSettings settings); void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings); void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings); void PauseTorrent(string hash, QBittorrentSettings settings); diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs index 7830072e5..20f2cbd9f 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs @@ -190,6 +190,19 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent } } + public void AddLabel(string label, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/command/addCategory") + .Post() + .AddFormParameter("category", label); + ProcessRequest(request, settings); + } + + public Dictionary 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 diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs index 09d1568d0..8a9134607 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -177,6 +177,20 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent 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 GetLabels(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/categories"); + return Json.Deserialize>(ProcessRequest(request, settings)); + } + public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) { var ratioLimit = seedConfiguration.Ratio.HasValue ? seedConfiguration.Ratio : -2; diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs index 95a4cbbf5..7fcfcf6c3 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs @@ -11,6 +11,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { 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 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent [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() diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs index 5d4624499..232b96b4f 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs @@ -39,6 +39,24 @@ namespace NzbDrone.Core.Download.Clients.RTorrent _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); diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs index ff9a4332f..b35063076 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs @@ -18,6 +18,7 @@ namespace NzbDrone.Core.Download.Clients.RTorrent 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 @@ namespace NzbDrone.Core.Download.Clients.RTorrent [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 @@ namespace NzbDrone.Core.Download.Clients.RTorrent 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 @@ namespace NzbDrone.Core.Download.Clients.RTorrent } } + 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"); diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs index 4d09849f8..df6a1f9cc 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs @@ -27,6 +27,8 @@ namespace NzbDrone.Core.Download.Clients.RTorrent 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 @@ namespace NzbDrone.Core.Download.Clients.RTorrent [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() diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs index 7d22cd5e5..a1140277f 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs @@ -38,12 +38,32 @@ namespace NzbDrone.Core.Download.Clients.UTorrent _torrentCache = cacheManager.GetCache(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 @@ namespace NzbDrone.Core.Download.Clients.UTorrent 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); } diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs index 557ae0e6e..684078f1a 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs @@ -21,6 +21,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent 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 @@ namespace NzbDrone.Core.Download.Clients.UTorrent 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) diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs index 2b772ddcf..91bcbbf79 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs @@ -41,13 +41,16 @@ namespace NzbDrone.Core.Download.Clients.UTorrent [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() diff --git a/src/NzbDrone.Core/Download/DownloadClientBase.cs b/src/NzbDrone.Core/Download/DownloadClientBase.cs index 7bf98ec58..c8975aea3 100644 --- a/src/NzbDrone.Core/Download/DownloadClientBase.cs +++ b/src/NzbDrone.Core/Download/DownloadClientBase.cs @@ -151,6 +151,10 @@ namespace NzbDrone.Core.Download return null; } - + + public virtual void MarkItemAsImported(DownloadClientItem downloadClientItem) + { + throw new NotSupportedException(this.Name + " does not support marking items as imported"); + } } } diff --git a/src/NzbDrone.Core/Download/DownloadClientDefinition.cs b/src/NzbDrone.Core/Download/DownloadClientDefinition.cs index b81536a53..1c0dfa927 100644 --- a/src/NzbDrone.Core/Download/DownloadClientDefinition.cs +++ b/src/NzbDrone.Core/Download/DownloadClientDefinition.cs @@ -6,5 +6,6 @@ namespace NzbDrone.Core.Download public class DownloadClientDefinition : ProviderDefinition { public DownloadProtocol Protocol { get; set; } + public int Priority { get; set; } = 1; } } diff --git a/src/NzbDrone.Core/Download/DownloadClientProvider.cs b/src/NzbDrone.Core/Download/DownloadClientProvider.cs index 0c8ab03ba..978f9ea60 100644 --- a/src/NzbDrone.Core/Download/DownloadClientProvider.cs +++ b/src/NzbDrone.Core/Download/DownloadClientProvider.cs @@ -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 @@ namespace NzbDrone.Core.Download public class DownloadClientProvider : IProvideDownloadClient { + private readonly Logger _logger; private readonly IDownloadClientFactory _downloadClientFactory; + private readonly IDownloadClientStatusService _downloadClientStatusService; + private readonly ICached _lastUsedDownloadClient; - public DownloadClientProvider(IDownloadClientFactory downloadClientFactory) + public DownloadClientProvider(IDownloadClientStatusService downloadClientStatusService, IDownloadClientFactory downloadClientFactory, ICacheManager cacheManager, Logger logger) { + _logger = logger; _downloadClientFactory = downloadClientFactory; + _downloadClientStatusService = downloadClientStatusService; + _lastUsedDownloadClient = cacheManager.GetCache(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(_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 GetDownloadClients() diff --git a/src/NzbDrone.Core/Download/DownloadEventHub.cs b/src/NzbDrone.Core/Download/DownloadEventHub.cs index 0168b013e..8d0f50bc1 100644 --- a/src/NzbDrone.Core/Download/DownloadEventHub.cs +++ b/src/NzbDrone.Core/Download/DownloadEventHub.cs @@ -35,15 +35,17 @@ namespace NzbDrone.Core.Download 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 @@ namespace NzbDrone.Core.Download } 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); } } } diff --git a/src/NzbDrone.Core/Download/IDownloadClient.cs b/src/NzbDrone.Core/Download/IDownloadClient.cs index 8f884b9fb..a4897b59b 100644 --- a/src/NzbDrone.Core/Download/IDownloadClient.cs +++ b/src/NzbDrone.Core/Download/IDownloadClient.cs @@ -13,5 +13,6 @@ namespace NzbDrone.Core.Download IEnumerable GetItems(); void RemoveItem(string downloadId, bool deleteData); DownloadClientInfo GetStatus(); + void MarkItemAsImported(DownloadClientItem downloadClientItem); } } diff --git a/src/NzbDrone.Core/Extras/ExtraService.cs b/src/NzbDrone.Core/Extras/ExtraService.cs index aedb233dd..4c4421478 100644 --- a/src/NzbDrone.Core/Extras/ExtraService.cs +++ b/src/NzbDrone.Core/Extras/ExtraService.cs @@ -72,9 +72,28 @@ namespace NzbDrone.Core.Extras .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(); + 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)); diff --git a/src/NzbDrone.Core/HealthCheck/Checks/MonoVersionCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/MonoVersionCheck.cs index 07d7431a8..faf2152d7 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/MonoVersionCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/MonoVersionCheck.cs @@ -30,13 +30,13 @@ namespace NzbDrone.Core.HealthCheck.Checks 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; diff --git a/src/NzbDrone.Core/History/History.cs b/src/NzbDrone.Core/History/History.cs index e68fe5884..f022d65b4 100644 --- a/src/NzbDrone.Core/History/History.cs +++ b/src/NzbDrone.Core/History/History.cs @@ -33,11 +33,12 @@ namespace NzbDrone.Core.History { 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 } } diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index 3bbbed8ec..589b95308 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -35,6 +35,7 @@ namespace NzbDrone.Core.History IHandle, IHandle, IHandle, + IHandle, IHandle { private readonly IHistoryRepository _historyRepository; @@ -195,6 +196,30 @@ namespace NzbDrone.Core.History _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); diff --git a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs index 7e956bf02..4245b8551 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs @@ -48,7 +48,6 @@ namespace NzbDrone.Core.Indexers.Newznab 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")); diff --git a/src/NzbDrone.Core/Indexers/PassThePopcorn/PassThePopcorn.cs b/src/NzbDrone.Core/Indexers/PassThePopcorn/PassThePopcorn.cs index 21768d0ff..2e17e66c4 100644 --- a/src/NzbDrone.Core/Indexers/PassThePopcorn/PassThePopcorn.cs +++ b/src/NzbDrone.Core/Indexers/PassThePopcorn/PassThePopcorn.cs @@ -15,17 +15,10 @@ namespace NzbDrone.Core.Indexers.PassThePopcorn 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 @@ namespace NzbDrone.Core.Indexers.PassThePopcorn { 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)); - }*/ } } diff --git a/src/NzbDrone.Core/MediaFiles/DeleteMediaFileReason.cs b/src/NzbDrone.Core/MediaFiles/DeleteMediaFileReason.cs index 918eedc31..6c198937f 100644 --- a/src/NzbDrone.Core/MediaFiles/DeleteMediaFileReason.cs +++ b/src/NzbDrone.Core/MediaFiles/DeleteMediaFileReason.cs @@ -5,6 +5,7 @@ MissingFromDisk, Manual, Upgrade, - NoLinkedEpisodes + NoLinkedEpisodes, + ManualOverride } } diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedMovieImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedMovieImportService.cs index 1b80c0e16..3283a727a 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedMovieImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedMovieImportService.cs @@ -4,6 +4,7 @@ using System.IO; 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 @@ namespace NzbDrone.Core.MediaFiles 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 @@ namespace NzbDrone.Core.MediaFiles IMakeImportDecision importDecisionMaker, IImportApprovedMovie importApprovedMovie, IDetectSample detectSample, + IRuntimeInfo runtimeInfo, IConfigService config, IHistoryService historyService, Logger logger) @@ -54,6 +57,7 @@ namespace NzbDrone.Core.MediaFiles _importDecisionMaker = importDecisionMaker; _importApprovedMovie = importApprovedMovie; _detectSample = detectSample; + _runtimeInfo = runtimeInfo; _config = config; _historyService = historyService; _logger = logger; @@ -104,7 +108,7 @@ namespace NzbDrone.Core.MediaFiles 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(); } @@ -273,5 +277,31 @@ namespace NzbDrone.Core.MediaFiles 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); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/Events/MovieFileRenamedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/MovieFileRenamedEvent.cs new file mode 100644 index 000000000..cab93382a --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/MovieFileRenamedEvent.cs @@ -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; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs index 920e9a701..3e0839cb4 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs @@ -92,7 +92,6 @@ namespace NzbDrone.Core.MediaFiles { _logger.Warn(ex, "Unable to apply permissions to: " + path); - _logger.Debug(ex, ex.Message); } } } diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs index a2d6f9f71..36c02083d 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs @@ -42,8 +42,8 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo var audioFormat = mediaInfo.AudioFormat; var audioCodecID = mediaInfo.AudioCodecID ?? string.Empty; var audioProfile = mediaInfo.AudioProfile ?? string.Empty; - var audioAdditionalFeatures = mediaInfo.AudioAdditionalFeatures ?? string.Empty; var audioCodecLibrary = mediaInfo.AudioCodecLibrary ?? string.Empty; + var splitAdditionalFeatures = (mediaInfo.AudioAdditionalFeatures ?? string.Empty).Split(new[] {' '}, StringSplitOptions.RemoveEmptyEntries); if (audioFormat.IsNullOrWhiteSpace()) { @@ -72,22 +72,21 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo if (audioFormat.EqualsIgnoreCase("DTS")) { - if (audioAdditionalFeatures.StartsWithIgnoreCase("XLL")) + if (splitAdditionalFeatures.ContainsIgnoreCase("XLL")) { - if (audioAdditionalFeatures.EndsWithIgnoreCase("X")) + if (splitAdditionalFeatures.ContainsIgnoreCase("X")) { return "DTS-X"; } - return "DTS-HD MA"; } - if (audioAdditionalFeatures.EqualsIgnoreCase("ES")) + if (splitAdditionalFeatures.ContainsIgnoreCase("ES")) { return "DTS-ES"; } - if (audioAdditionalFeatures.EqualsIgnoreCase("XBR")) + if (splitAdditionalFeatures.ContainsIgnoreCase("XBR")) { return "DTS-HD HRA"; } @@ -128,9 +127,14 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo return "PCM"; } + if (audioFormat.EqualsIgnoreCase("TrueHD")) + { + return "TrueHD"; + } + if (audioFormat.EqualsIgnoreCase("MLP FBA")) { - if (audioAdditionalFeatures == "16-ch") + if (splitAdditionalFeatures.ContainsIgnoreCase("16-ch")) { return "TrueHD Atmos"; } @@ -148,7 +152,9 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo return "WMA"; } - Logger.Debug("Unknown audio format: '{0}' in '{1}'.", string.Join(", ", audioFormat, audioCodecID, audioProfile, audioAdditionalFeatures, audioCodecLibrary), sceneName); + Logger.Debug() + .Message("Unknown audio format: '{0}' in '{1}'.", string.Join(", ", audioFormat, audioCodecID, audioProfile, audioCodecLibrary), sceneName) + .Write(); return audioFormat; } @@ -387,8 +393,6 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo try { - Logger.Debug("Formatting audio channels using 'AudioChannelPositions', with a value of: '{0}'", audioChannelPositions); - if (audioChannelPositions.Contains("+")) { return audioChannelPositions.Split('+') @@ -397,17 +401,18 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo if (audioChannelPositions.Contains("/")) { - return Regex.Replace(audioChannelPositions, @"^\d+\sobjects", "", RegexOptions.Compiled | RegexOptions.IgnoreCase) - .Replace("Object Based / ", "") - .Split(new string[] { " / " }, StringSplitOptions.RemoveEmptyEntries) - .FirstOrDefault() - ?.Split('/') - .Sum(s => decimal.Parse(s, CultureInfo.InvariantCulture)); + return Regex.Replace(audioChannelPositions, @"^\d+\sobjects", "", + RegexOptions.Compiled | RegexOptions.IgnoreCase) + .Replace("Object Based / ", "") + .Split(new string[] {" / "}, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault() + ?.Split('/') + .Sum(s => decimal.Parse(s, CultureInfo.InvariantCulture)); } } catch (Exception e) { - Logger.Warn(e, "Unable to format audio channels using 'AudioChannelPositions'"); + Logger.Warn(e, "Unable to format audio channels using 'AudioChannelPositions', with a value of: '{0}'", audioChannelPositions); } return null; @@ -425,13 +430,11 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo try { - Logger.Debug("Formatting audio channels using 'AudioChannelPositionsText', with a value of: '{0}'", audioChannelPositionsText); - return audioChannelPositionsText.ContainsIgnoreCase("LFE") ? audioChannels - 1 + 0.1m : audioChannels; } catch (Exception e) { - Logger.Warn(e, "Unable to format audio channels using 'AudioChannelPositionsText'"); + Logger.Warn(e, "Unable to format audio channels using 'AudioChannelPositionsText', with a value of: '{0}'", audioChannelPositionsText); } return null; @@ -443,8 +446,6 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo if (mediaInfo.SchemaRevision >= 3) { - Logger.Debug("Formatting audio channels using 'AudioChannels', with a value of: '{0}'", audioChannels); - return audioChannels; } @@ -466,5 +467,27 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo // Last token is the default. return tokens.Last(); } + + private static readonly string[] ValidHdrTransferFunctions = {"PQ", "HLG"}; + private const string ValidHdrColourPrimaries = "BT.2020"; + + public static string FormatVideoDynamicRange(MediaInfoModel mediaInfo) + { + // assume SDR by default + var videoDynamicRange = ""; + + if (mediaInfo.VideoBitDepth >= 10 && + !string.IsNullOrEmpty(mediaInfo.VideoColourPrimaries) && + !string.IsNullOrEmpty(mediaInfo.VideoTransferCharacteristics)) + { + if (mediaInfo.VideoColourPrimaries.EqualsIgnoreCase(ValidHdrColourPrimaries) && + ValidHdrTransferFunctions.Any(mediaInfo.VideoTransferCharacteristics.Contains)) + { + videoDynamicRange = "HDR"; + } + } + + return videoDynamicRange; + } } } diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoLib.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoLib.cs index b18e2e462..9343d103e 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoLib.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoLib.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; using System.Text; -using NzbDrone.Common.Instrumentation; using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation; namespace NzbDrone.Core.MediaFiles.MediaInfo { @@ -98,18 +100,24 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo } else { + var responses = new List(); + // Linux normally UCS-4. As fallback we try UCS-2 and plain Ansi. MustUseAnsi = false; Encoding = Encoding.UTF32; - if (Option("Info_Version", "").StartsWith("MediaInfoLib")) + var version = Option("Info_Version", ""); + responses.Add(version); + if (version.StartsWith("MediaInfoLib")) { return; } Encoding = Encoding.Unicode; - if (Option("Info_Version", "").StartsWith("MediaInfoLib")) + version = Option("Info_Version", ""); + responses.Add(version); + if (version.StartsWith("MediaInfoLib")) { return; } @@ -117,12 +125,14 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo MustUseAnsi = true; Encoding = Encoding.Default; - if (Option("Info_Version", "").StartsWith("MediaInfoLib")) + version = Option("Info_Version", ""); + responses.Add(version); + if (version.StartsWith("MediaInfoLib")) { return; } - throw new NotSupportedException("Unsupported MediaInfoLib encoding"); + throw new NotSupportedException("Unsupported MediaInfoLib encoding, version check responses (may be gibberish, show it to the Sonarr devs): " + responses.Join(", ") ); } } @@ -272,7 +282,7 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo public string Option(string option, string value) { - var pOption = MakeStringParameter(option); + var pOption = MakeStringParameter(option.ToLowerInvariant()); var pValue = MakeStringParameter(value); try { diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoModel.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoModel.cs index fe62216cd..3024f6c6d 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoModel.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoModel.cs @@ -10,6 +10,7 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo public class MediaInfoModel : IEmbeddedDocument { public string ContainerFormat { get; set; } + // Deprecated according to MediaInfo public string VideoCodec { get; set; } public string VideoFormat { get; set; } public string VideoCodecID { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs index a3f03eed1..9841d65ce 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs @@ -9,7 +9,7 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo public interface IVideoFileInfoReader { MediaInfoModel GetMediaInfo(string filename); - TimeSpan GetRunTime(string filename); + TimeSpan? GetRunTime(string filename); } public class VideoFileInfoReader : IVideoFileInfoReader @@ -17,7 +17,7 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo private readonly IDiskProvider _diskProvider; private readonly Logger _logger; - public const int MINIMUM_MEDIA_INFO_SCHEMA_REVISION = 4; + public const int MINIMUM_MEDIA_INFO_SCHEMA_REVISION = 3; public const int CURRENT_MEDIA_INFO_SCHEMA_REVISION = 5; public VideoFileInfoReader(IDiskProvider diskProvider, Logger logger) @@ -36,6 +36,8 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo MediaInfo mediaInfo = null; + // TODO: Cache media info by path, mtime and length so we don't need to read files multiple times + try { mediaInfo = new MediaInfo(); @@ -117,6 +119,7 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo int.TryParse(aBitRate, out audioBitRate); int.TryParse(mediaInfo.Get(StreamKind.Audio, 0, "StreamCount"), out streamCount); + string audioChannelsStr = mediaInfo.Get(StreamKind.Audio, 0, "Channel(s)").Split(new string[] { " /" }, StringSplitOptions.None)[0].Trim(); var audioChannelPositions = mediaInfo.Get(StreamKind.Audio, 0, "ChannelPositions/String2"); @@ -177,25 +180,17 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo } finally { - if (mediaInfo != null) - { - mediaInfo.Close(); - } + mediaInfo?.Close(); } return null; } - public TimeSpan GetRunTime(string filename) + public TimeSpan? GetRunTime(string filename) { var info = GetMediaInfo(filename); - if (info == null) - { - return new TimeSpan(); - } - - return info.RunTime; + return info?.RunTime; } private TimeSpan GetBestRuntime(int audio, int video, int general) diff --git a/src/NzbDrone.Core/MediaFiles/MovieImport/DetectSample.cs b/src/NzbDrone.Core/MediaFiles/MovieImport/DetectSample.cs index cdbe935b7..94f5db07d 100644 --- a/src/NzbDrone.Core/MediaFiles/MovieImport/DetectSample.cs +++ b/src/NzbDrone.Core/MediaFiles/MovieImport/DetectSample.cs @@ -57,13 +57,13 @@ namespace NzbDrone.Core.MediaFiles.MovieImport var runTime = _videoFileInfoReader.GetRunTime(path); var minimumRuntime = GetMinimumAllowedRuntime(movie); - if (runTime.TotalMinutes.Equals(0)) + if (runTime.Value.TotalMinutes.Equals(0)) { _logger.Error("[{0}] has a runtime of 0, is it a valid video file?", path); return true; } - if (runTime.TotalSeconds < minimumRuntime) + if (runTime.Value.TotalSeconds < minimumRuntime) { _logger.Debug("[{0}] appears to be a sample. Runtime: {1} seconds. Expected at least: {2} seconds", path, runTime, minimumRuntime); return true; diff --git a/src/NzbDrone.Core/MediaFiles/RenameMovieFileService.cs b/src/NzbDrone.Core/MediaFiles/RenameMovieFileService.cs index dd2e751f1..0b80e7177 100644 --- a/src/NzbDrone.Core/MediaFiles/RenameMovieFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/RenameMovieFileService.cs @@ -119,6 +119,8 @@ namespace NzbDrone.Core.MediaFiles _logger.Debug("Renamed movie file: {0}", movieFile); + _eventAggregator.PublishEvent(new MovieFileRenamedEvent(movie, movieFile, oldMovieFilePath)); + } catch (SameFilenameException ex) { diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs index 1c9c11954..25887ba83 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs @@ -2,26 +2,23 @@ using System; using System.Collections.Generic; using System.Linq; using System.Net; - using System.ServiceModel; - using NLog; +using NLog; using NzbDrone.Common.Cloud; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MetadataSource.SkyHook.Resource; -using NzbDrone.Core.MetadataSource; using NzbDrone.Core.MetadataSource.PreDB; using NzbDrone.Core.Movies; using System.Threading; using NzbDrone.Core.Parser; using NzbDrone.Core.Languages; using NzbDrone.Core.Profiles; -using NzbDrone.Common.Serializer; using NzbDrone.Core.NetImport.ImportExclusions; -using NzbDrone.Core.Configuration; using NzbDrone.Core.MetadataSource.RadarrAPI; - using NzbDrone.Core.Movies.AlternativeTitles; +using NzbDrone.Core.Movies.AlternativeTitles; +using System.Globalization; namespace NzbDrone.Core.MetadataSource.SkyHook { @@ -201,23 +198,6 @@ namespace NzbDrone.Core.MetadataSource.SkyHook movie.Genres.Add(genre.name); } - //this is the way it should be handled - //but unfortunately it seems - //tmdb lacks alot of release date info - //omdbapi is actually quite good for this info - //except omdbapi has been having problems recently - //so i will just leave this in as a comment - //and use the 3 month logic that we were using before - /*var now = DateTime.Now; - if (now < movie.InCinemas) - movie.Status = MovieStatusType.Announced; - if (now >= movie.InCinemas) - movie.Status = MovieStatusType.InCinemas; - if (now >= movie.PhysicalRelease) - movie.Status = MovieStatusType.Released; - */ - - var now = DateTime.Now; //handle the case when we have both theatrical and physical release dates if (movie.InCinemas.HasValue && movie.PhysicalRelease.HasValue) @@ -263,23 +243,6 @@ namespace NzbDrone.Core.MetadataSource.SkyHook } } - //this matches with the old behavior before the creation of the MovieStatusType.InCinemas - /*if (resource.status == "Released") - { - if (movie.InCinemas.HasValue && (((DateTime.Now).Subtract(movie.InCinemas.Value)).TotalSeconds <= 60 * 60 * 24 * 30 * 3)) - { - movie.Status = MovieStatusType.InCinemas; - } - else - { - movie.Status = MovieStatusType.Released; - } - } - else - { - movie.Status = MovieStatusType.Announced; - }*/ - if (resource.videos != null) { foreach (Video video in resource.videos.results) @@ -485,22 +448,6 @@ namespace NzbDrone.Core.MetadataSource.SkyHook request.AllowAutoRedirect = true; request.SuppressHttpError = true; - /*var imdbRequest = new HttpRequest("https://v2.sg.media-imdb.com/suggests/" + firstChar + "/" + searchTerm + ".json"); - - var response = _httpClient.Get(imdbRequest); - - var imdbCallback = "imdb$" + searchTerm + "("; - - var responseCleaned = response.Content.Replace(imdbCallback, "").TrimEnd(")"); - - _logger.Warn("Cleaned response: " + responseCleaned); - - ImdbResource json =Json Convert.DeserializeObject(responseCleaned); - - _logger.Warn("Json object: " + json); - - _logger.Warn("Crash ahead.");*/ - var response = _httpClient.Get(request); var movieResults = response.Resource.results; @@ -522,13 +469,13 @@ namespace NzbDrone.Core.MetadataSource.SkyHook { if (result.release_date.IsNotNullOrWhiteSpace()) { - imdbMovie.InCinemas = DateTime.Parse(result.release_date); + imdbMovie.InCinemas = DateTime.ParseExact(result.release_date, "yyyy-MM-dd", DateTimeFormatInfo.InvariantInfo); imdbMovie.Year = imdbMovie.InCinemas.Value.Year; } if (result.physical_release.IsNotNullOrWhiteSpace()) { - imdbMovie.PhysicalRelease = DateTime.Parse(result.physical_release); + imdbMovie.PhysicalRelease = DateTime.ParseExact(result.physical_release, "yyyy-MM-dd", DateTimeFormatInfo.InvariantInfo); if (result.physical_release_note.IsNotNullOrWhiteSpace()) { imdbMovie.PhysicalReleaseNote = result.physical_release_note; diff --git a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs index 4cc01dc6a..f68a5a0af 100755 --- a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs +++ b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs @@ -6,7 +6,9 @@ using System.Linq; using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; using NzbDrone.Common.Processes; +using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Movies; using NzbDrone.Core.Validation; @@ -29,6 +31,8 @@ namespace NzbDrone.Core.Notifications.CustomScript public override string Link => "https://github.com/Radarr/Radarr/wiki/Custom-Post-Processing-Scripts"; + public override ProviderMessage Message => new ProviderMessage("Testing will execute the script with the EventType set to Test, ensure your script handles this correctly", ProviderMessageType.Warning); + public override void OnGrab(GrabMessage message) { var movie = message.Movie; @@ -84,6 +88,7 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Radarr_DeletedRelativePaths", string.Join("|", message.OldMovieFiles.Select(e => e.RelativePath))); environmentVariables.Add("Radarr_DeletedPaths", string.Join("|", message.OldMovieFiles.Select(e => Path.Combine(movie.Path, e.RelativePath)))); } + ExecuteScript(environmentVariables); } @@ -110,22 +115,33 @@ namespace NzbDrone.Core.Notifications.CustomScript failures.Add(new NzbDroneValidationFailure("Path", "File does not exist")); } - try + foreach (var systemFolder in SystemFolders.GetSystemFolders()) { - var environmentVariables = new StringDictionary(); - environmentVariables.Add("Radarr_EventType", "Test"); - - var processOutput = ExecuteScript(environmentVariables); - - if (processOutput.ExitCode != 0) + if (systemFolder.IsParentPath(Settings.Path)) { - failures.Add(new NzbDroneValidationFailure(string.Empty, $"Script exited with code: {processOutput.ExitCode}")); + failures.Add(new NzbDroneValidationFailure("Path", $"Must not be a descendant of '{systemFolder}'")); } } - catch (Exception ex) + + if (failures.Empty()) { - _logger.Error(ex); - failures.Add(new NzbDroneValidationFailure(string.Empty, ex.Message)); + try + { + var environmentVariables = new StringDictionary(); + environmentVariables.Add("Radarr_EventType", "Test"); + + var processOutput = ExecuteScript(environmentVariables); + + if (processOutput.ExitCode != 0) + { + failures.Add(new NzbDroneValidationFailure(string.Empty, $"Script exited with code: {processOutput.ExitCode}")); + } + } + catch (Exception ex) + { + _logger.Error(ex); + failures.Add(new NzbDroneValidationFailure(string.Empty, ex.Message)); + } } return new ValidationResult(failures); @@ -142,5 +158,10 @@ namespace NzbDrone.Core.Notifications.CustomScript return processOutput; } + + private bool ValidatePathParent(string possibleParent, string path) + { + return possibleParent.IsParentPath(path); + } } } diff --git a/src/NzbDrone.Core/Notifications/CustomScript/CustomScriptSettings.cs b/src/NzbDrone.Core/Notifications/CustomScript/CustomScriptSettings.cs index e426ec651..f4d4d7803 100644 --- a/src/NzbDrone.Core/Notifications/CustomScript/CustomScriptSettings.cs +++ b/src/NzbDrone.Core/Notifications/CustomScript/CustomScriptSettings.cs @@ -11,6 +11,7 @@ namespace NzbDrone.Core.Notifications.CustomScript public CustomScriptSettingsValidator() { RuleFor(c => c.Path).IsValidPath(); + RuleFor(c => c.Arguments).Empty().WithMessage("Arguments are no longer supported for custom scripts"); } } @@ -21,7 +22,7 @@ namespace NzbDrone.Core.Notifications.CustomScript [FieldDefinition(0, Label = "Path", Type = FieldType.FilePath)] public string Path { get; set; } - [FieldDefinition(1, Label = "Arguments", HelpText = "Arguments to pass to the script")] + [FieldDefinition(1, Label = "Arguments", HelpText = "Arguments to pass to the script", Hidden = HiddenType.HiddenIfNotSet)] public string Arguments { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexHomeTheaterSettings.cs b/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexHomeTheaterSettings.cs index 589d75f7a..f22a43b7d 100644 --- a/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexHomeTheaterSettings.cs +++ b/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexHomeTheaterSettings.cs @@ -13,22 +13,22 @@ namespace NzbDrone.Core.Notifications.Plex.HomeTheater //These need to be kept in the same order as XBMC Settings, but we don't want them displayed - [FieldDefinition(2, Label = "Username", Type = FieldType.Hidden)] + [FieldDefinition(2, Label = "Username", Hidden = HiddenType.Hidden)] public new string Username { get; set; } - [FieldDefinition(3, Label = "Password", Type = FieldType.Hidden)] + [FieldDefinition(3, Label = "Password", Hidden = HiddenType.Hidden)] public new string Password { get; set; } - [FieldDefinition(5, Label = "GUI Notification", Type = FieldType.Hidden)] + [FieldDefinition(5, Label = "GUI Notification", Type = FieldType.Checkbox, Hidden = HiddenType.Hidden)] public new bool Notify { get; set; } - [FieldDefinition(6, Label = "Update Library", HelpText = "Update Library on Download & Rename?", Type = FieldType.Hidden)] + [FieldDefinition(6, Label = "Update Library", HelpText = "Update Library on Download & Rename?", Type = FieldType.Checkbox, Hidden = HiddenType.Hidden)] public new bool UpdateLibrary { get; set; } - [FieldDefinition(7, Label = "Clean Library", HelpText = "Clean Library after update?", Type = FieldType.Hidden)] + [FieldDefinition(7, Label = "Clean Library", HelpText = "Clean Library after update?", Type = FieldType.Checkbox, Hidden = HiddenType.Hidden)] public new bool CleanLibrary { get; set; } - [FieldDefinition(8, Label = "Always Update", HelpText = "Update Library even when a video is playing?", Type = FieldType.Hidden)] + [FieldDefinition(8, Label = "Always Update", HelpText = "Update Library even when a video is playing?", Type = FieldType.Checkbox, Hidden = HiddenType.Hidden)] public new bool AlwaysUpdate { get; set; } } } diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs index 1ea5e7ee7..a92970696 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs @@ -56,7 +56,7 @@ namespace NzbDrone.Core.Notifications.PushBullet return new { - devices = devices.Where(d => d.Nickname.IsNotNullOrWhiteSpace()) + options = devices.Where(d => d.Nickname.IsNotNullOrWhiteSpace()) .OrderBy(d => d.Nickname, StringComparer.InvariantCultureIgnoreCase) .Select(d => new { diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 5632bde4f..ef604afa3 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -156,6 +156,8 @@ + + @@ -165,6 +167,7 @@ + @@ -199,6 +202,7 @@ + diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 75e099b4b..c67b2a067 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -113,13 +113,13 @@ namespace NzbDrone.Core.Parser private static readonly Regex SixDigitAirDateRegex = new Regex(@"(?<=[_.-])(?(?[1-9]\d{1})(?[0-1][0-9])(?[0-3][0-9]))(?=[_.-])", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex CleanReleaseGroupRegex = new Regex(@"^(.*?[-._ ](S\d+E\d+)[-._ ])|(-(RP|1|NZBGeek|Obfuscated|sample|Pre|postbot|xpost|Rakuv[a-z]*|WhiteRev|BUYMORE))+$", + private static readonly Regex CleanReleaseGroupRegex = new Regex(@"^(.*?[-._ ](S\d+E\d+)[-._ ])|(-(RP|1|NZBGeek|Obfuscated|sample|Pre|postbot|xpost|Rakuv[a-z]*|WhiteRev|BUYMORE|AsRequested))+$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex CleanTorrentSuffixRegex = new Regex(@"\[(?:ettv|rartv|rarbg|cttv)\]$", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex ReleaseGroupRegex = new Regex(@"-(?[a-z0-9]+)(?[a-z0-9]+(?!.+?(?:480p|720p|1080p|2160p)))(?[a-z0-9]+)\]$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex AnimeReleaseGroupRegex = new Regex(@"^(?:\[(?(?!\s).+?(?M?BluRay|Blu-Ray|HDDVD|BD|BDISO|BD25|BD50|BR.?DISK)| - (?WEB[-_. ]DL|HDRIP|WEBDL|WebRip|Web-Rip|iTunesHD|WebHD|[. ]WEB[. ](?:[xh]26[45]|DD5[. ]1)|\d+0p[. ]WEB[. ])| + (?WEB[-_. ]DL|HDRIP|WEBDL|WebRip|Web-Rip|iTunesHD|WebHD|[. ]WEB[. ](?:[xh]26[45]|DDP?5[. ]1)|\d+0p[-. ]WEB[-. ]|WEB-DLMux|\b\s\/\sWEB\s\/\s\b)| (?HDTV)| (?BDRip)|(?BRRip)| (?DVD-R|DVDR)| diff --git a/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingService.cs b/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingService.cs index d0d2e05be..5e7b0d889 100644 --- a/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingService.cs +++ b/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingService.cs @@ -129,7 +129,7 @@ namespace NzbDrone.Core.RemotePathMappings foreach (var mapping in All()) { - if (host == mapping.Host && new OsPath(mapping.RemotePath).Contains(remotePath)) + if (host.Equals(mapping.Host, StringComparison.InvariantCultureIgnoreCase) && new OsPath(mapping.RemotePath).Contains(remotePath)) { var localPath = new OsPath(mapping.LocalPath) + (remotePath - new OsPath(mapping.RemotePath)); @@ -149,7 +149,7 @@ namespace NzbDrone.Core.RemotePathMappings foreach (var mapping in All()) { - if (host == mapping.Host && new OsPath(mapping.LocalPath).Contains(localPath)) + if (host.Equals(mapping.Host, StringComparison.InvariantCultureIgnoreCase) && new OsPath(mapping.LocalPath).Contains(localPath)) { var remotePath = new OsPath(mapping.RemotePath) + (localPath - new OsPath(mapping.LocalPath)); diff --git a/src/NzbDrone.Core/Tags/TagDetails.cs b/src/NzbDrone.Core/Tags/TagDetails.cs index 8d134622a..3eae3a35f 100644 --- a/src/NzbDrone.Core/Tags/TagDetails.cs +++ b/src/NzbDrone.Core/Tags/TagDetails.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using NzbDrone.Core.Datastore; namespace NzbDrone.Core.Tags @@ -10,5 +11,13 @@ namespace NzbDrone.Core.Tags public List NotificationIds { get; set; } public List RestrictionIds { get; set; } public List DelayProfileIds { get; set; } + + public bool InUse + { + get + { + return (MovieIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any()); + } + } } } diff --git a/src/NzbDrone.Core/Tags/TagRepository.cs b/src/NzbDrone.Core/Tags/TagRepository.cs index 3173d926a..a35228094 100644 --- a/src/NzbDrone.Core/Tags/TagRepository.cs +++ b/src/NzbDrone.Core/Tags/TagRepository.cs @@ -8,6 +8,7 @@ namespace NzbDrone.Core.Tags public interface ITagRepository : IBasicRepository { Tag GetByLabel(string label); + Tag FindByLabel(string label); } public class TagRepository : BasicRepository, ITagRepository @@ -28,5 +29,10 @@ namespace NzbDrone.Core.Tags return model; } + + public Tag FindByLabel(string label) + { + return Query.Where(c => c.Label == label).SingleOrDefault(); + } } } diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs index 651a17ad6..6a83ae181 100644 --- a/src/NzbDrone.Core/Tags/TagService.cs +++ b/src/NzbDrone.Core/Tags/TagService.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Linq; +using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Notifications; using NzbDrone.Core.Profiles.Delay; @@ -120,7 +122,12 @@ namespace NzbDrone.Core.Tags public Tag Add(Tag tag) { - //TODO: check for duplicate tag by label and return that tag instead? + var existingTag = _repo.FindByLabel(tag.Label); + + if (existingTag != null) + { + return existingTag; + } tag.Label = tag.Label.ToLowerInvariant(); @@ -142,6 +149,12 @@ namespace NzbDrone.Core.Tags public void Delete(int tagId) { + var details = Details(tagId); + if (details.InUse) + { + throw new ModelConflictException(typeof(Tag), tagId, $"'{details.Label}' cannot be deleted since it's still in use"); + } + _repo.Delete(tagId); _eventAggregator.PublishEvent(new TagsUpdatedEvent()); } diff --git a/src/NzbDrone.Core/Validation/Paths/SystemFolderValidator.cs b/src/NzbDrone.Core/Validation/Paths/SystemFolderValidator.cs index ad321f87a..d8f3a7f15 100644 --- a/src/NzbDrone.Core/Validation/Paths/SystemFolderValidator.cs +++ b/src/NzbDrone.Core/Validation/Paths/SystemFolderValidator.cs @@ -1,6 +1,5 @@ -using System; using FluentValidation.Validators; -using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; namespace NzbDrone.Core.Validation.Paths @@ -16,33 +15,13 @@ namespace NzbDrone.Core.Validation.Paths { var folder = context.PropertyValue.ToString(); - if (OsInfo.IsWindows) + foreach (var systemFolder in SystemFolders.GetSystemFolders()) { - var windowsFolder = Environment.GetFolderPath(Environment.SpecialFolder.Windows); - context.MessageFormatter.AppendArgument("systemFolder", windowsFolder); - - if (windowsFolder.PathEquals(folder)) - { - context.MessageFormatter.AppendArgument("relationship", "set to"); - - return false; - } - - if (windowsFolder.IsParentPath(folder)) - { - context.MessageFormatter.AppendArgument("relationship", "child of"); - - return false; - } - } - else if (OsInfo.IsOsx) - { - var systemFolder = "/System"; context.MessageFormatter.AppendArgument("systemFolder", systemFolder); if (systemFolder.PathEquals(folder)) { - context.MessageFormatter.AppendArgument("relationship", "child of"); + context.MessageFormatter.AppendArgument("relationship", "set to"); return false; } @@ -54,36 +33,6 @@ namespace NzbDrone.Core.Validation.Paths return false; } } - else - { - var folders = new[] - { - "/bin", - "/boot", - "/lib", - "/sbin", - "/proc" - }; - - foreach (var f in folders) - { - context.MessageFormatter.AppendArgument("systemFolder", f); - - if (f.PathEquals(folder)) - { - context.MessageFormatter.AppendArgument("relationship", "child of"); - - return false; - } - - if (f.IsParentPath(folder)) - { - context.MessageFormatter.AppendArgument("relationship", "child of"); - - return false; - } - } - } return true; } diff --git a/src/NzbDrone.Host/AccessControl/UrlAclAdapter.cs b/src/NzbDrone.Host/AccessControl/UrlAclAdapter.cs index ff365c0a4..77a4777d1 100644 --- a/src/NzbDrone.Host/AccessControl/UrlAclAdapter.cs +++ b/src/NzbDrone.Host/AccessControl/UrlAclAdapter.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.RegularExpressions; using NLog; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Exceptions; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; @@ -54,18 +55,27 @@ namespace Radarr.Host.AccessControl public void ConfigureUrls() { + var enableSsl = _configFileProvider.EnableSsl; + var port = _configFileProvider.Port; + var sslPort = _configFileProvider.SslPort; + + if (enableSsl && sslPort == port) + { + throw new RadarrStartupException("Cannot use the same port for HTTP and HTTPS. Port {0}", port); + } + if (RegisteredUrls.Empty()) { GetRegisteredUrls(); } - var localHostHttpUrls = BuildUrlAcls("http", "localhost", _configFileProvider.Port); - var interfaceHttpUrls = BuildUrlAcls("http", _configFileProvider.BindAddress, _configFileProvider.Port); + var localHostHttpUrls = BuildUrlAcls("http", "localhost", port); + var interfaceHttpUrls = BuildUrlAcls("http", _configFileProvider.BindAddress, port); - var localHostHttpsUrls = BuildUrlAcls("https", "localhost", _configFileProvider.SslPort); - var interfaceHttpsUrls = BuildUrlAcls("https", _configFileProvider.BindAddress, _configFileProvider.SslPort); + var localHostHttpsUrls = BuildUrlAcls("https", "localhost", sslPort); + var interfaceHttpsUrls = BuildUrlAcls("https", _configFileProvider.BindAddress, sslPort); - if (!_configFileProvider.EnableSsl) + if (!enableSsl) { localHostHttpsUrls.Clear(); interfaceHttpsUrls.Clear(); diff --git a/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs b/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs index e599fd15e..f4305fb8e 100644 --- a/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs +++ b/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs @@ -16,9 +16,14 @@ namespace Radarr.Host.Owin.MiddleWare SignalRDependencyResolver.Register(container); SignalRJsonSerializer.Register(); - // Half the default time (110s) to get under nginx's default 60 proxy_read_timeout - GlobalHost.Configuration.ConnectionTimeout = TimeSpan.FromSeconds(55); - GlobalHost.Configuration.DisconnectTimeout = TimeSpan.FromMinutes(3); + // Note there are some important timeouts involved here: + // nginx has a default 60 sec proxy_read_timeout, this means the connection will be terminated if the server doesn't send anything within that time. + // Previously we lowered the ConnectionTimeout from 110s to 55s to remedy that, however all we should've done is set an appropriate KeepAlive. + // By default KeepAlive is 1/3rd of the DisconnectTimeout, which we set incredibly high 5 years ago, resulting in KeepAlive being 1 minute. + // So when adjusting these values in the future, please keep that all in mind. + GlobalHost.Configuration.ConnectionTimeout = TimeSpan.FromSeconds(110); + GlobalHost.Configuration.DisconnectTimeout = TimeSpan.FromSeconds(180); + GlobalHost.Configuration.KeepAlive = TimeSpan.FromSeconds(30); } public void Attach(IAppBuilder appBuilder) diff --git a/src/NzbDrone.SignalR/NzbDronePersistentConnection.cs b/src/NzbDrone.SignalR/NzbDronePersistentConnection.cs index ff1a2dca8..d0a57bb79 100644 --- a/src/NzbDrone.SignalR/NzbDronePersistentConnection.cs +++ b/src/NzbDrone.SignalR/NzbDronePersistentConnection.cs @@ -12,6 +12,7 @@ namespace NzbDrone.SignalR { public interface IBroadcastSignalRMessage { + bool IsConnected { get; } void BroadcastMessage(SignalRMessage message); } @@ -20,7 +21,8 @@ namespace NzbDrone.SignalR private IPersistentConnectionContext Context => ((ConnectionManager)GlobalHost.ConnectionManager).GetConnection(GetType()); private static string API_KEY; - private readonly Dictionary _messageHistory; + private readonly Dictionary _messageHistory; + private HashSet _connections = new HashSet(); public NzbDronePersistentConnection(IConfigFileProvider configFileProvider) { @@ -28,6 +30,17 @@ namespace NzbDrone.SignalR _messageHistory = new Dictionary(); } + public bool IsConnected + { + get + { + lock (_connections) + { + return _connections.Count != 0; + } + } + } + public void BroadcastMessage(SignalRMessage message) { @@ -59,14 +72,34 @@ namespace NzbDrone.SignalR protected override Task OnConnected(IRequest request, string connectionId) { + lock (_connections) + { + _connections.Add(connectionId); + } + return SendVersion(connectionId); } protected override Task OnReconnected(IRequest request, string connectionId) { + lock (_connections) + { + _connections.Add(connectionId); + } + return SendVersion(connectionId); } + protected override Task OnDisconnected(IRequest request, string connectionId, bool stopCalled) + { + lock (_connections) + { + _connections.Remove(connectionId); + } + + return base.OnDisconnected(request, connectionId, stopCalled); + } + private Task SendVersion(string connectionId) { return Context.Connection.Send(connectionId, new SignalRMessage diff --git a/src/NzbDrone.SignalR/SignalRJsonSerializer.cs b/src/NzbDrone.SignalR/SignalRJsonSerializer.cs index e00b7914a..f86795b90 100644 --- a/src/NzbDrone.SignalR/SignalRJsonSerializer.cs +++ b/src/NzbDrone.SignalR/SignalRJsonSerializer.cs @@ -13,6 +13,7 @@ namespace NzbDrone.SignalR { _serializerSettings = Json.GetSerializerSettings(); _serializerSettings.ContractResolver = new SignalRContractResolver(); + _serializerSettings.Formatting = Formatting.None; // ServerSentEvents doesn't like newlines _serializer = JsonSerializer.Create(_serializerSettings); diff --git a/src/NzbDrone.Update.Test/StartNzbDroneService.cs b/src/NzbDrone.Update.Test/StartNzbDroneService.cs index bf76cfdf9..ddfeb14e1 100644 --- a/src/NzbDrone.Update.Test/StartNzbDroneService.cs +++ b/src/NzbDrone.Update.Test/StartNzbDroneService.cs @@ -32,7 +32,7 @@ namespace NzbDrone.Update.Test Subject.Start(AppType.Service, targetFolder); - Mocker.GetMock().Verify(c => c.SpawnNewProcess("c:\\Radarr\\Radarr.Console.exe", "/" + StartupContext.NO_BROWSER, null), Times.Once()); + Mocker.GetMock().Verify(c => c.SpawnNewProcess("c:\\Radarr\\Radarr.Console.exe", "/" + StartupContext.NO_BROWSER, null, false), Times.Once()); ExceptionVerification.ExpectedWarns(1); } diff --git a/src/Radarr.Api.V2/Config/HostConfigModule.cs b/src/Radarr.Api.V2/Config/HostConfigModule.cs index c9e07ba7b..b64805731 100644 --- a/src/Radarr.Api.V2/Config/HostConfigModule.cs +++ b/src/Radarr.Api.V2/Config/HostConfigModule.cs @@ -43,6 +43,7 @@ namespace Radarr.Api.V2.Config SharedValidator.RuleFor(c => c.Password).NotEmpty().When(c => c.AuthenticationMethod != AuthenticationType.None); SharedValidator.RuleFor(c => c.SslPort).ValidPort().When(c => c.EnableSsl); + SharedValidator.RuleFor(c => c.SslPort).NotEqual(c => c.Port).When(c => c.EnableSsl); SharedValidator.RuleFor(c => c.SslCertHash).NotEmpty().When(c => c.EnableSsl && OsInfo.IsWindows); SharedValidator.RuleFor(c => c.Branch).NotEmpty().WithMessage("Branch name is required, 'master' is the default"); diff --git a/src/Radarr.Api.V2/DownloadClient/DownloadClientResource.cs b/src/Radarr.Api.V2/DownloadClient/DownloadClientResource.cs index de013273b..a78deea57 100644 --- a/src/Radarr.Api.V2/DownloadClient/DownloadClientResource.cs +++ b/src/Radarr.Api.V2/DownloadClient/DownloadClientResource.cs @@ -7,6 +7,7 @@ namespace Radarr.Api.V2.DownloadClient { public bool Enable { get; set; } public DownloadProtocol Protocol { get; set; } + public int Priority { get; set; } } public class DownloadClientResourceMapper : ProviderResourceMapper @@ -19,6 +20,7 @@ namespace Radarr.Api.V2.DownloadClient resource.Enable = definition.Enable; resource.Protocol = definition.Protocol; + resource.Priority = definition.Priority; return resource; } @@ -31,6 +33,7 @@ namespace Radarr.Api.V2.DownloadClient definition.Enable = resource.Enable; definition.Protocol = resource.Protocol; + definition.Priority = resource.Priority; return definition; } diff --git a/src/Radarr.Api.V2/History/HistoryModule.cs b/src/Radarr.Api.V2/History/HistoryModule.cs index 14f632b4d..33287d72d 100644 --- a/src/Radarr.Api.V2/History/HistoryModule.cs +++ b/src/Radarr.Api.V2/History/HistoryModule.cs @@ -56,6 +56,7 @@ namespace Radarr.Api.V2.History var includeMovie = Request.GetBooleanQueryParameter("includeMovie"); var eventTypeFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "eventType"); + var downloadIdFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "downloadId"); if (eventTypeFilter != null) { @@ -63,6 +64,12 @@ namespace Radarr.Api.V2.History pagingSpec.FilterExpressions.Add(v => v.EventType == filterValue); } + if (downloadIdFilter != null) + { + var downloadId = downloadIdFilter.Value; + pagingSpec.FilterExpressions.Add(h => h.DownloadId == downloadId); + } + return ApplyToPage(_historyService.Paged, pagingSpec, h => MapToResource(h, includeMovie)); } diff --git a/src/Radarr.Http/ClientSchema/Field.cs b/src/Radarr.Http/ClientSchema/Field.cs index 9ec4e7ccc..8675789c9 100644 --- a/src/Radarr.Http/ClientSchema/Field.cs +++ b/src/Radarr.Http/ClientSchema/Field.cs @@ -15,6 +15,7 @@ namespace Radarr.Http.ClientSchema public bool Advanced { get; set; } public List SelectOptions { get; set; } public string Section { get; set; } + public string Hidden { get; set; } public Field Clone() { diff --git a/src/Radarr.Http/ClientSchema/SchemaBuilder.cs b/src/Radarr.Http/ClientSchema/SchemaBuilder.cs index ec629183d..5f348c2d9 100644 --- a/src/Radarr.Http/ClientSchema/SchemaBuilder.cs +++ b/src/Radarr.Http/ClientSchema/SchemaBuilder.cs @@ -100,8 +100,8 @@ namespace Radarr.Http.ClientSchema HelpLink = fieldAttribute.HelpLink, Order = fieldAttribute.Order, Advanced = fieldAttribute.Advanced, - Type = fieldAttribute.Type.ToString().ToLowerInvariant(), - Section = fieldAttribute.Section, + Type = fieldAttribute.Type.ToString().FirstCharToLower(), + Section = fieldAttribute.Section }; if (fieldAttribute.Type == FieldType.Select) @@ -109,6 +109,11 @@ namespace Radarr.Http.ClientSchema field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions); } + if (fieldAttribute.Hidden != HiddenType.Visible) + { + field.Hidden = fieldAttribute.Hidden.ToString().FirstCharToLower(); + } + var valueConverter = GetValueConverter(propertyInfo.PropertyType); result.Add(new FieldMapping diff --git a/src/Radarr.Http/ErrorManagement/RadarrErrorPipeline.cs b/src/Radarr.Http/ErrorManagement/RadarrErrorPipeline.cs index 9542e7902..7171454a9 100644 --- a/src/Radarr.Http/ErrorManagement/RadarrErrorPipeline.cs +++ b/src/Radarr.Http/ErrorManagement/RadarrErrorPipeline.cs @@ -3,6 +3,7 @@ using System.Data.SQLite; using FluentValidation; using Nancy; using NLog; +using NzbDrone.Core.Datastore; using NzbDrone.Core.Exceptions; using Radarr.Http.Exceptions; using Radarr.Http.Extensions; @@ -23,26 +24,20 @@ namespace Radarr.Http.ErrorManagement { _logger.Trace("Handling Exception"); - var apiException = exception as ApiException; - - if (apiException != null) + if (exception is ApiException apiException) { _logger.Warn(apiException, "API Error"); return apiException.ToErrorResponse(); } - - var validationException = exception as ValidationException; - - if (validationException != null) + + if (exception is ValidationException validationException) { _logger.Warn("Invalid request {0}", validationException.Message); return validationException.Errors.AsResponse(HttpStatusCode.BadRequest); } - var clientException = exception as NzbDroneClientException; - - if (clientException != null) + if (exception is NzbDroneClientException clientException) { return new ErrorModel { @@ -51,9 +46,25 @@ namespace Radarr.Http.ErrorManagement }.AsResponse((HttpStatusCode)clientException.StatusCode); } - var sqLiteException = exception as SQLiteException; + if (exception is ModelNotFoundException notFoundException) + { + return new ErrorModel + { + Message = exception.Message, + Description = exception.ToString() + }.AsResponse(HttpStatusCode.NotFound); + } + + if (exception is ModelConflictException conflictException) + { + return new ErrorModel + { + Message = exception.Message, + Description = exception.ToString() + }.AsResponse(HttpStatusCode.Conflict); + } - if (sqLiteException != null) + if (exception is SQLiteException sqLiteException) { if (context.Request.Method == "PUT" || context.Request.Method == "POST") { diff --git a/src/Radarr.Http/RadarrRestModuleWithSignalR.cs b/src/Radarr.Http/RadarrRestModuleWithSignalR.cs index 582e9a5dc..a50955d5a 100644 --- a/src/Radarr.Http/RadarrRestModuleWithSignalR.cs +++ b/src/Radarr.Http/RadarrRestModuleWithSignalR.cs @@ -25,6 +25,8 @@ namespace Radarr.Http public void Handle(ModelEvent message) { + if (!_signalRBroadcaster.IsConnected) return; + if (message.Action == ModelAction.Deleted || message.Action == ModelAction.Sync) { BroadcastResourceChange(message.Action); @@ -35,6 +37,8 @@ namespace Radarr.Http protected void BroadcastResourceChange(ModelAction action, int id) { + if (!_signalRBroadcaster.IsConnected) return; + if (action == ModelAction.Deleted) { BroadcastResourceChange(action, new TResource {Id = id}); @@ -48,6 +52,8 @@ namespace Radarr.Http protected void BroadcastResourceChange(ModelAction action, TResource resource) { + if (!_signalRBroadcaster.IsConnected) return; + if (GetType().Namespace.Contains("V2")) { var signalRMessage = new SignalRMessage @@ -63,6 +69,8 @@ namespace Radarr.Http protected void BroadcastResourceChange(ModelAction action) { + if (!_signalRBroadcaster.IsConnected) return; + if (GetType().Namespace.Contains("V2")) { var signalRMessage = new SignalRMessage