From 72caab1b2bc3cc8db128f4f9ec12f6939a9c9c6f Mon Sep 17 00:00:00 2001 From: Qstick Date: Sun, 29 Mar 2020 13:48:34 -0400 Subject: [PATCH] New: Monitor and Process downloads separately Co-Authored-By: Mark McDowall --- .../History/Details/HistoryDetails.js | 24 ++ .../History/Details/HistoryDetailsModal.js | 2 + .../Activity/History/HistoryEventTypeCell.js | 4 + frontend/src/Activity/Queue/Queue.js | 22 +- frontend/src/Activity/Queue/QueueConnector.js | 12 +- frontend/src/Activity/Queue/QueueRow.js | 8 +- .../src/Activity/Queue/QueueRowConnector.js | 4 +- .../src/Activity/Queue/QueueStatusCell.js | 41 +++- .../Activity/Queue/RemoveQueueItemModal.css | 4 - .../Activity/Queue/RemoveQueueItemModal.js | 51 +++- .../Activity/Queue/RemoveQueueItemsModal.js | 55 ++++- frontend/src/Commands/commandNames.js | 2 +- frontend/src/Components/SignalRConnector.js | 2 +- frontend/src/Helpers/Props/icons.js | 1 + frontend/src/Store/Actions/historyActions.js | 11 + frontend/src/Store/Actions/queueActions.js | 6 +- .../src/System/Tasks/Queued/QueuedTaskRow.js | 2 +- src/NzbDrone.Api/Queue/QueueActionModule.cs | 7 +- src/NzbDrone.Api/Queue/QueueResource.cs | 2 +- src/NzbDrone.Common.Test/OsPathFixture.cs | 11 +- src/NzbDrone.Common/Disk/OsPath.cs | 10 +- .../QueueSpecificationFixture.cs | 30 ++- .../ImportFixture.cs} | 225 +++--------------- .../ProcessFixture.cs | 189 +++++++++++++++ .../ProcessFailedFixture.cs | 100 ++++++++ .../ProcessFixture.cs} | 53 +---- .../TrackedDownloadAlreadyImportedFixture.cs | 76 ++++++ .../DownloadedMoviesCommandServiceFixture.cs | 2 +- .../Commands/CommandQueueManagerFixture.cs | 2 +- .../Specifications/QueueSpecification.cs | 9 + .../CheckForFinishedDownloadCommand.cs | 1 - .../Download/CompletedDownloadService.cs | 118 +++++---- .../Download/DownloadIgnoredEvent.cs | 18 ++ .../Download/DownloadProcessingService.cs | 78 ++++++ .../Download/DownloadsProcessedEvent.cs | 11 + .../Download/FailedDownloadService.cs | 58 +++-- .../Download/IgnoredDownloadService.cs | 49 ++++ .../ProcessMonitoredDownloadsCommand.cs | 9 + .../RedownloadFailedDownloadService.cs | 9 +- .../Download/RefreshDownloadsCommand.cs | 8 + .../DownloadMonitoringService.cs | 66 ++--- .../TrackedDownloads/TrackedDownload.cs | 13 +- .../TrackedDownloadAlreadyImported.cs | 36 +++ .../TrackedDownloadService.cs | 43 +++- src/NzbDrone.Core/History/History.cs | 3 +- src/NzbDrone.Core/History/HistoryService.cs | 22 +- src/NzbDrone.Core/Jobs/TaskManager.cs | 2 +- .../DownloadedMovieImportService.cs | 50 ++-- .../MovieImport/Manual/ManualImportService.cs | 2 +- .../AlreadyImportedSpecification.cs | 65 +++++ .../Messaging/Events/EventAggregator.cs | 7 +- src/NzbDrone.Core/Queue/Queue.cs | 3 +- src/NzbDrone.Core/Queue/QueueService.cs | 3 +- src/NzbDrone.Host.Test/ContainerFixture.cs | 2 +- src/Radarr.Api.V3/Queue/QueueActionModule.cs | 34 ++- src/Radarr.Api.V3/Queue/QueueResource.cs | 7 +- src/Radarr.Api.V3/Queue/QueueStatusModule.cs | 9 +- 57 files changed, 1223 insertions(+), 470 deletions(-) delete mode 100644 frontend/src/Activity/Queue/RemoveQueueItemModal.css rename src/NzbDrone.Core.Test/Download/{CompletedDownloadServiceFixture.cs => CompletedDownloadServiceTests/ImportFixture.cs} (50%) create mode 100644 src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ProcessFixture.cs create mode 100644 src/NzbDrone.Core.Test/Download/FailedDownloadServiceTests/ProcessFailedFixture.cs rename src/NzbDrone.Core.Test/Download/{FailedDownloadServiceFixture.cs => FailedDownloadServiceTests/ProcessFixture.cs} (69%) create mode 100644 src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadAlreadyImportedFixture.cs create mode 100644 src/NzbDrone.Core/Download/DownloadIgnoredEvent.cs create mode 100644 src/NzbDrone.Core/Download/DownloadProcessingService.cs create mode 100644 src/NzbDrone.Core/Download/DownloadsProcessedEvent.cs create mode 100644 src/NzbDrone.Core/Download/IgnoredDownloadService.cs create mode 100644 src/NzbDrone.Core/Download/ProcessMonitoredDownloadsCommand.cs create mode 100644 src/NzbDrone.Core/Download/RefreshDownloadsCommand.cs create mode 100644 src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadAlreadyImported.cs create mode 100644 src/NzbDrone.Core/MediaFiles/MovieImport/Specifications/AlreadyImportedSpecification.cs diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js index 294fcbd9b..fcdd3db16 100644 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -232,6 +232,30 @@ function HistoryDetails(props) { ); } + if (eventType === 'downloadIgnored') { + const { + message + } = data; + + return ( + + + + { + !!message && + + } + + ); + } + return ( { - return selectedIds.indexOf(item.id) > -1 && item.status === 'Delay'; + return selectedIds.indexOf(item.id) > -1 && item.status === 'delay'; }); if (isPendingSelected !== this.state.isPendingSelected) { @@ -87,8 +87,8 @@ class Queue extends Component { this.setState({ isConfirmRemoveModalOpen: true }); } - onRemoveSelectedConfirmed = (blacklist) => { - this.props.onRemoveSelectedPress(this.getSelectedIds(), blacklist); + onRemoveSelectedConfirmed = (payload) => { + this.props.onRemoveSelectedPress({ ids: this.getSelectedIds(), ...payload }); this.setState({ isConfirmRemoveModalOpen: false }); } @@ -112,7 +112,7 @@ class Queue extends Component { totalRecords, isGrabbing, isRemoving, - isCheckForFinishedDownloadExecuting, + isRefreshMonitoredDownloadsExecuting, onRefreshPress, ...otherProps } = this.props; @@ -125,10 +125,11 @@ class Queue extends Component { isPendingSelected } = this.state; - const isRefreshing = isFetching || isMoviesFetching || isCheckForFinishedDownloadExecuting; + const isRefreshing = isFetching || isMoviesFetching || isRefreshMonitoredDownloadsExecuting; const isAllPopulated = isPopulated && (isMoviesPopulated || !items.length || items.every((e) => !e.movieId)); const hasError = error || moviesError; - const selectedCount = this.getSelectedIds().length; + const selectedIds = this.getSelectedIds(); + const selectedCount = selectedIds.length; const disableSelectedActions = selectedCount === 0; return ( @@ -239,6 +240,13 @@ class Queue extends Component { { + const item = items.find((i) => i.id === id); + + return !!(item && item.movieId); + }) + )} onRemovePress={this.onRemoveSelectedConfirmed} onModalClose={this.onConfirmRemoveModalClose} /> @@ -259,7 +267,7 @@ Queue.propTypes = { totalRecords: PropTypes.number, isGrabbing: PropTypes.bool.isRequired, isRemoving: PropTypes.bool.isRequired, - isCheckForFinishedDownloadExecuting: PropTypes.bool.isRequired, + isRefreshMonitoredDownloadsExecuting: PropTypes.bool.isRequired, onRefreshPress: PropTypes.func.isRequired, onGrabSelectedPress: PropTypes.func.isRequired, onRemoveSelectedPress: PropTypes.func.isRequired diff --git a/frontend/src/Activity/Queue/QueueConnector.js b/frontend/src/Activity/Queue/QueueConnector.js index 9c6354cf3..2535293c4 100644 --- a/frontend/src/Activity/Queue/QueueConnector.js +++ b/frontend/src/Activity/Queue/QueueConnector.js @@ -15,13 +15,13 @@ function createMapStateToProps() { (state) => state.movies, (state) => state.queue.options, (state) => state.queue.paged, - createCommandExecutingSelector(commandNames.CHECK_FOR_FINISHED_DOWNLOAD), - (movies, options, queue, isCheckForFinishedDownloadExecuting) => { + createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS), + (movies, options, queue, isRefreshMonitoredDownloadsExecuting) => { return { isMoviesFetching: movies.isFetching, isMoviesPopulated: movies.isPopulated, moviesError: movies.error, - isCheckForFinishedDownloadExecuting, + isRefreshMonitoredDownloadsExecuting, ...options, ...queue }; @@ -113,7 +113,7 @@ class QueueConnector extends Component { onRefreshPress = () => { this.props.executeCommand({ - name: commandNames.CHECK_FOR_FINISHED_DOWNLOAD + name: commandNames.REFRESH_MONITORED_DOWNLOADS }); } @@ -121,8 +121,8 @@ class QueueConnector extends Component { this.props.grabQueueItems({ ids }); } - onRemoveSelectedPress = (ids, blacklist) => { - this.props.removeQueueItems({ ids, blacklist }); + onRemoveSelectedPress = (payload) => { + this.props.removeQueueItems(payload); } // diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js index b1112ef56..0b6aa6450 100644 --- a/frontend/src/Activity/Queue/QueueRow.js +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -68,6 +68,7 @@ class QueueRow extends Component { title, status, trackedDownloadStatus, + trackedDownloadState, statusMessages, errorMessage, movie, @@ -100,8 +101,8 @@ class QueueRow extends Component { } = this.state; const progress = 100 - (sizeleft / size * 100); - const showInteractiveImport = status === 'Completed' && trackedDownloadStatus === 'Warning'; - const isPending = status === 'Delay' || status === 'DownloadClientUnavailable'; + const showInteractiveImport = status === 'completed' && trackedDownloadStatus === 'warning'; + const isPending = status === 'delay' || status === 'downloadClientUnavailable'; return ( @@ -129,6 +130,7 @@ class QueueRow extends Component { sourceTitle={title} status={status} trackedDownloadStatus={trackedDownloadStatus} + trackedDownloadState={trackedDownloadState} statusMessages={statusMessages} errorMessage={errorMessage} /> @@ -315,6 +317,7 @@ class QueueRow extends Component { @@ -330,6 +333,7 @@ QueueRow.propTypes = { title: PropTypes.string.isRequired, status: PropTypes.string.isRequired, trackedDownloadStatus: PropTypes.string, + trackedDownloadState: PropTypes.string, statusMessages: PropTypes.arrayOf(PropTypes.object), errorMessage: PropTypes.string, movie: PropTypes.object, diff --git a/frontend/src/Activity/Queue/QueueRowConnector.js b/frontend/src/Activity/Queue/QueueRowConnector.js index c486656eb..4a46374b3 100644 --- a/frontend/src/Activity/Queue/QueueRowConnector.js +++ b/frontend/src/Activity/Queue/QueueRowConnector.js @@ -39,8 +39,8 @@ class QueueRowConnector extends Component { this.props.grabQueueItem({ id: this.props.id }); } - onRemoveQueueItemPress = (blacklist) => { - this.props.removeQueueItem({ id: this.props.id, blacklist }); + onRemoveQueueItemPress = (payload) => { + this.props.removeQueueItem({ id: this.props.id, ...payload }); } // diff --git a/frontend/src/Activity/Queue/QueueStatusCell.js b/frontend/src/Activity/Queue/QueueStatusCell.js index 552fa1444..7ce0b6300 100644 --- a/frontend/src/Activity/Queue/QueueStatusCell.js +++ b/frontend/src/Activity/Queue/QueueStatusCell.js @@ -37,13 +37,14 @@ function QueueStatusCell(props) { const { sourceTitle, status, - trackedDownloadStatus = 'Ok', + trackedDownloadStatus, + trackedDownloadState, statusMessages, errorMessage } = props; - const hasWarning = trackedDownloadStatus === 'Warning'; - const hasError = trackedDownloadStatus === 'Error'; + const hasWarning = trackedDownloadStatus === 'warning'; + const hasError = trackedDownloadStatus === 'error'; // status === 'downloading' let iconName = icons.DOWNLOADING; @@ -54,22 +55,34 @@ function QueueStatusCell(props) { iconKind = kinds.WARNING; } - if (status === 'Paused') { + if (status === 'paused') { iconName = icons.PAUSED; title = 'Paused'; } - if (status === 'Queued') { + if (status === 'queued') { iconName = icons.QUEUED; title = 'Queued'; } - if (status === 'Completed') { + if (status === 'completed') { iconName = icons.DOWNLOADED; title = 'Downloaded'; + + if (trackedDownloadState === 'importPending') { + title += ' - Waiting to Import'; + } + + if (trackedDownloadState === 'importing') { + title += ' - Importing'; + } + + if (trackedDownloadState === 'failedPending') { + title += ' - Waiting to Process'; + } } - if (status === 'Delay') { + if (status === 'delay') { iconName = icons.PENDING; title = 'Pending'; } @@ -80,20 +93,20 @@ function QueueStatusCell(props) { title = 'Pending - Download client is unavailable'; } - if (status === 'Failed') { + if (status === 'failed') { iconName = icons.DOWNLOADING; iconKind = kinds.DANGER; title = 'Download failed'; } - if (status === 'Warning') { + if (status === 'warning') { iconName = icons.DOWNLOADING; iconKind = kinds.WARNING; title = `Download warning: ${errorMessage || 'check download client for more details'}`; } if (hasError) { - if (status === 'Completed') { + if (status === 'completed') { iconName = icons.DOWNLOAD; iconKind = kinds.DANGER; title = `Import failed: ${sourceTitle}`; @@ -125,9 +138,15 @@ function QueueStatusCell(props) { QueueStatusCell.propTypes = { sourceTitle: PropTypes.string.isRequired, status: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string, + trackedDownloadStatus: PropTypes.string.isRequired, + trackedDownloadState: PropTypes.string.isRequired, statusMessages: PropTypes.arrayOf(PropTypes.object), errorMessage: PropTypes.string }; +QueueStatusCell.defaultProps = { + trackedDownloadStatus: 'Ok', + trackedDownloadState: 'Downloading' +}; + export default QueueStatusCell; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.css b/frontend/src/Activity/Queue/RemoveQueueItemModal.css deleted file mode 100644 index d7a643463..000000000 --- a/frontend/src/Activity/Queue/RemoveQueueItemModal.css +++ /dev/null @@ -1,4 +0,0 @@ -.messageRemove { - margin-bottom: 30px; - color: $dangerColor; -} diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.js b/frontend/src/Activity/Queue/RemoveQueueItemModal.js index dcacf0dae..3de9372d1 100644 --- a/frontend/src/Activity/Queue/RemoveQueueItemModal.js +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.js @@ -10,7 +10,6 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalHeader from 'Components/Modal/ModalHeader'; import ModalBody from 'Components/Modal/ModalBody'; import ModalFooter from 'Components/Modal/ModalFooter'; -import styles from './RemoveQueueItemModal.css'; class RemoveQueueItemModal extends Component { @@ -21,26 +20,41 @@ class RemoveQueueItemModal extends Component { super(props, context); this.state = { + remove: true, blacklist: false }; } + // + // Control + + resetState = function() { + this.setState({ + remove: true, + blacklist: false + }); + } + // // Listeners + onRemoveChange = ({ value }) => { + this.setState({ remove: value }); + } + onBlacklistChange = ({ value }) => { this.setState({ blacklist: value }); } - onRemoveQueueItemConfirmed = () => { - const blacklist = this.state.blacklist; + onRemoveConfirmed = () => { + const state = this.state; - this.setState({ blacklist: false }); - this.props.onRemovePress(blacklist); + this.resetState(); + this.props.onRemovePress(state); } onModalClose = () => { - this.setState({ blacklist: false }); + this.resetState(); this.props.onModalClose(); } @@ -50,10 +64,11 @@ class RemoveQueueItemModal extends Component { render() { const { isOpen, - sourceTitle + sourceTitle, + canIgnore } = this.props; - const blacklist = this.state.blacklist; + const { remove, blacklist } = this.state; return ( -
- Removing will remove the download and the file(s) from the download client. -
+ + Remove From Download Client + + + Blacklist Release @@ -83,7 +107,7 @@ class RemoveQueueItemModal extends Component { type={inputTypes.CHECK} name="blacklist" value={blacklist} - helpText="Prevents Radarr from automatically grabbing this movie again" + helpText="Starts a search for this movie again and prevents this release from being grabbed again" onChange={this.onBlacklistChange} /> @@ -97,7 +121,7 @@ class RemoveQueueItemModal extends Component { @@ -111,6 +135,7 @@ class RemoveQueueItemModal extends Component { RemoveQueueItemModal.propTypes = { isOpen: PropTypes.bool.isRequired, sourceTitle: PropTypes.string.isRequired, + canIgnore: PropTypes.bool.isRequired, onRemovePress: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js index 09a05890d..a41692bd5 100644 --- a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js +++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js @@ -21,26 +21,41 @@ class RemoveQueueItemsModal extends Component { super(props, context); this.state = { + remove: true, blacklist: false }; } // - // Listeners + // Control + + resetState = function() { + this.setState({ + remove: true, + blacklist: false + }); + } + + // + // Listeners + + onRemoveChange = ({ value }) => { + this.setState({ remove: value }); + } onBlacklistChange = ({ value }) => { this.setState({ blacklist: value }); } - onRemoveQueueItemConfirmed = () => { - const blacklist = this.state.blacklist; + onRemoveConfirmed = () => { + const state = this.state; - this.setState({ blacklist: false }); - this.props.onRemovePress(blacklist); + this.resetState(); + this.props.onRemovePress(state); } onModalClose = () => { - this.setState({ blacklist: false }); + this.resetState(); this.props.onModalClose(); } @@ -50,10 +65,11 @@ class RemoveQueueItemsModal extends Component { render() { const { isOpen, - selectedCount + selectedCount, + canIgnore } = this.props; - const blacklist = this.state.blacklist; + const { remove, blacklist } = this.state; return ( - Blacklist Release + Remove From Download Client + + + + + + + Blacklist Release{selectedCount > 1 ? 's' : ''} + + @@ -93,7 +125,7 @@ class RemoveQueueItemsModal extends Component { @@ -107,6 +139,7 @@ class RemoveQueueItemsModal extends Component { RemoveQueueItemsModal.propTypes = { isOpen: PropTypes.bool.isRequired, selectedCount: PropTypes.number.isRequired, + canIgnore: PropTypes.bool.isRequired, onRemovePress: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js index 7e3df59b8..22ed45322 100644 --- a/frontend/src/Commands/commandNames.js +++ b/frontend/src/Commands/commandNames.js @@ -1,6 +1,6 @@ export const APPLICATION_UPDATE = 'ApplicationUpdate'; export const BACKUP = 'Backup'; -export const CHECK_FOR_FINISHED_DOWNLOAD = 'CheckForFinishedDownload'; +export const REFRESH_MONITORED_DOWNLOADS = 'RefreshMonitoredDownloads'; export const CLEAR_BLACKLIST = 'ClearBlacklist'; export const CLEAR_LOGS = 'ClearLog'; export const CUTOFF_UNMET_MOVIES_SEARCH = 'CutoffUnmetMoviesSearch'; diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index ee862f5c0..f9e586c98 100644 --- a/frontend/src/Components/SignalRConnector.js +++ b/frontend/src/Components/SignalRConnector.js @@ -224,7 +224,7 @@ class SignalRConnector extends Component { } handleSystemTask = () => { - // No-op for now, we may want this later + this.props.dispatchFetchCommands(); } handleRootfolder = () => { diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 25e4c4627..3a895e1d3 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -153,6 +153,7 @@ export const HEALTH = fasMedkit; export const HEART = fasHeart; export const HISTORY = fasHistory; export const HOUSEKEEPING = fasHome; +export const IGNORE = fasTimesCircle; export const IN_CINEMAS = fasTicketAlt; export const INFO = fasInfoCircle; export const INTERACTIVE = fasUser; diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js index 54d1be04a..e1468d258 100644 --- a/frontend/src/Store/Actions/historyActions.js +++ b/frontend/src/Store/Actions/historyActions.js @@ -148,6 +148,17 @@ export const defaultState = { type: filterTypes.EQUAL } ] + }, + { + key: 'ignored', + label: 'Ignored', + filters: [ + { + key: 'eventType', + value: '9', + type: filterTypes.EQUAL + } + ] } ] diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js index 1f568c14b..15e971c43 100644 --- a/frontend/src/Store/Actions/queueActions.js +++ b/frontend/src/Store/Actions/queueActions.js @@ -351,13 +351,14 @@ export const actionHandlers = handleThunks({ [REMOVE_QUEUE_ITEM]: function(getState, payload, dispatch) { const { id, + remove, blacklist } = payload; dispatch(updateItem({ section: paged, id, isRemoving: true })); const promise = createAjaxRequest({ - url: `/queue/${id}?blacklist=${blacklist}`, + url: `/queue/${id}?removeFromClient=${remove}&blacklist=${blacklist}`, method: 'DELETE' }).request; @@ -373,6 +374,7 @@ export const actionHandlers = handleThunks({ [REMOVE_QUEUE_ITEMS]: function(getState, payload, dispatch) { const { ids, + remove, blacklist } = payload; @@ -389,7 +391,7 @@ export const actionHandlers = handleThunks({ ])); const promise = createAjaxRequest({ - url: `/queue/bulk?blacklist=${blacklist}`, + url: `/queue/bulk?removeFromClient=${remove}&blacklist=${blacklist}`, method: 'DELETE', dataType: 'json', data: JSON.stringify({ ids }) diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.js b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js index 4aa6d76d6..1a64e6f84 100644 --- a/frontend/src/System/Tasks/Queued/QueuedTaskRow.js +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js @@ -168,7 +168,7 @@ class QueuedTaskRow extends Component { isCancelConfirmModalOpen } = this.state; - let triggerIcon = icons.UNKNOWN; + let triggerIcon = icons.QUICK; if (trigger === 'manual') { triggerIcon = icons.INTERACTIVE; diff --git a/src/NzbDrone.Api/Queue/QueueActionModule.cs b/src/NzbDrone.Api/Queue/QueueActionModule.cs index 287e5eeca..01eb8e3d7 100644 --- a/src/NzbDrone.Api/Queue/QueueActionModule.cs +++ b/src/NzbDrone.Api/Queue/QueueActionModule.cs @@ -86,12 +86,7 @@ namespace NzbDrone.Api.Queue private object Import() { - var resource = Request.Body.FromJson(); - var trackedDownload = GetTrackedDownload(resource.Id); - - _completedDownloadService.Process(trackedDownload, true); - - return resource; + throw new BadRequestException("No longer available"); } private object Grab() diff --git a/src/NzbDrone.Api/Queue/QueueResource.cs b/src/NzbDrone.Api/Queue/QueueResource.cs index 99640a540..a395e982a 100644 --- a/src/NzbDrone.Api/Queue/QueueResource.cs +++ b/src/NzbDrone.Api/Queue/QueueResource.cs @@ -44,7 +44,7 @@ namespace NzbDrone.Api.Queue Timeleft = model.Timeleft, EstimatedCompletionTime = model.EstimatedCompletionTime, Status = model.Status, - TrackedDownloadStatus = model.TrackedDownloadStatus, + TrackedDownloadStatus = model.TrackedDownloadStatus.ToString(), StatusMessages = model.StatusMessages, DownloadId = model.DownloadId, Protocol = model.Protocol, diff --git a/src/NzbDrone.Common.Test/OsPathFixture.cs b/src/NzbDrone.Common.Test/OsPathFixture.cs index 6d79e0cfc..9f2a3c42c 100644 --- a/src/NzbDrone.Common.Test/OsPathFixture.cs +++ b/src/NzbDrone.Common.Test/OsPathFixture.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; using NzbDrone.Common.Disk; using NzbDrone.Test.Common; @@ -185,6 +185,15 @@ namespace NzbDrone.Common.Test osPath.FullPath.Should().Be(@"/just/a/test/to/verify the/slashes/"); } + [Test] + public void should_fix_double_slashes_unix() + { + var osPath = new OsPath(@"/just/a//test////to/verify the/slashes/"); + + osPath.Kind.Should().Be(OsPathKind.Unix); + osPath.FullPath.Should().Be(@"/just/a/test/to/verify the/slashes/"); + } + [Test] public void should_combine_mixed_slashes() { diff --git a/src/NzbDrone.Common/Disk/OsPath.cs b/src/NzbDrone.Common/Disk/OsPath.cs index 02aaf6561..c4327ddeb 100644 --- a/src/NzbDrone.Common/Disk/OsPath.cs +++ b/src/NzbDrone.Common/Disk/OsPath.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using NzbDrone.Common.Extensions; @@ -85,7 +85,13 @@ namespace NzbDrone.Common.Disk case OsPathKind.Windows: return path.Replace('/', '\\'); case OsPathKind.Unix: - return path.Replace('\\', '/'); + path = path.Replace('\\', '/'); + while (path.Contains("//")) + { + path = path.Replace("//", "/"); + } + + return path; } return path; diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs index 06016bd1f..4ed94445c 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs @@ -6,6 +6,7 @@ using Moq; using NUnit.Framework; using NzbDrone.Core.CustomFormats; using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.Movies; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles; @@ -24,6 +25,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private Movie _otherMovie; + private ReleaseInfo _releaseInfo; + [SetUp] public void Setup() { @@ -45,6 +48,9 @@ namespace NzbDrone.Core.Test.DecisionEngineTests .With(s => s.Id = 2) .Build(); + _releaseInfo = Builder.CreateNew() + .Build(); + _remoteMovie = Builder.CreateNew() .With(r => r.Movie = _movie) .With(r => r.ParsedMovieInfo = new ParsedMovieInfo { Quality = new QualityModel(Quality.DVD) }) @@ -63,11 +69,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests .Returns(new List()); } - private void GivenQueue(IEnumerable remoteMovies) + private void GivenQueue(IEnumerable remoteMovies, TrackedDownloadState trackedDownloadState = TrackedDownloadState.Downloading) { var queue = remoteMovies.Select(remoteMovie => new Queue.Queue { - RemoteMovie = remoteMovie + RemoteMovie = remoteMovie, + TrackedDownloadState = trackedDownloadState }); Mocker.GetMock() @@ -178,5 +185,24 @@ namespace NzbDrone.Core.Test.DecisionEngineTests GivenQueue(new List { remoteMovie }); Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeFalse(); } + + [Test] + public void should_return_true_if_everything_is_the_same_for_failed_pending() + { + _movie.Profile.Cutoff = Quality.Bluray1080p.Id; + + var remoteMovie = Builder.CreateNew() + .With(r => r.Movie = _movie) + .With(r => r.ParsedMovieInfo = new ParsedMovieInfo + { + Quality = new QualityModel(Quality.DVD) + }) + .With(r => r.Release = _releaseInfo) + .Build(); + + GivenQueue(new List { remoteMovie }, TrackedDownloadState.FailedPending); + + Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeTrue(); + } } } diff --git a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs similarity index 50% rename from src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs rename to src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs index 1e2a56bb7..3744fdc6b 100644 --- a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs @@ -20,7 +20,7 @@ using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.Download { [TestFixture] - public class CompletedDownloadServiceFixture : CoreTest + public class ImportFixture : CoreTest { private TrackedDownload _trackedDownload; @@ -33,12 +33,12 @@ namespace NzbDrone.Core.Test.Download .With(h => h.Title = "Drone.1998") .Build(); - var remoteEpisode = BuildRemoteMovie(); + var remoteMovie = BuildRemoteMovie(); _trackedDownload = Builder.CreateNew() - .With(c => c.State = TrackedDownloadStage.Downloading) + .With(c => c.State = TrackedDownloadState.Downloading) .With(c => c.DownloadItem = completed) - .With(c => c.RemoteMovie = remoteEpisode) + .With(c => c.RemoteMovie = remoteMovie) .Build(); Mocker.GetMock() @@ -55,7 +55,7 @@ namespace NzbDrone.Core.Test.Download Mocker.GetMock() .Setup(s => s.GetMovie("Drone.1998")) - .Returns(remoteEpisode.Movie); + .Returns(remoteMovie.Movie); } private RemoteMovie BuildRemoteMovie() @@ -66,23 +66,6 @@ namespace NzbDrone.Core.Test.Download }; } - private void GivenNoGrabbedHistory() - { - Mocker.GetMock() - .Setup(s => s.MostRecentForDownloadId(_trackedDownload.DownloadItem.DownloadId)) - .Returns((History.History)null); - } - - private void GivenSuccessfulImport() - { - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new List - { - new ImportResult(new ImportDecision(new LocalMovie() { Path = @"C:\TestPath\Droned.1998.mkv" })) - }); - } - private void GivenABadlyNamedDownload() { _trackedDownload.DownloadItem.DownloadId = "1234"; @@ -107,75 +90,6 @@ namespace NzbDrone.Core.Test.Download .Returns(_trackedDownload.RemoteMovie.Movie); } - [TestCase(DownloadItemStatus.Downloading)] - [TestCase(DownloadItemStatus.Failed)] - [TestCase(DownloadItemStatus.Queued)] - [TestCase(DownloadItemStatus.Paused)] - [TestCase(DownloadItemStatus.Warning)] - public void should_not_process_if_download_status_isnt_completed(DownloadItemStatus status) - { - _trackedDownload.DownloadItem.Status = status; - - Subject.Process(_trackedDownload); - - AssertNoAttemptedImport(); - } - - [Test] - public void should_not_process_if_matching_history_is_not_found_and_no_category_specified() - { - _trackedDownload.DownloadItem.Category = null; - GivenNoGrabbedHistory(); - - Subject.Process(_trackedDownload); - - AssertNoAttemptedImport(); - } - - [Test] - public void should_process_if_matching_history_is_not_found_but_category_specified() - { - _trackedDownload.DownloadItem.Category = "tv"; - GivenNoGrabbedHistory(); - GivenSeriesMatch(); - GivenSuccessfulImport(); - - Subject.Process(_trackedDownload); - - AssertCompletedDownload(); - } - - [Test] - public void should_not_process_if_output_path_is_empty() - { - _trackedDownload.DownloadItem.OutputPath = default; - - Subject.Process(_trackedDownload); - - AssertNoAttemptedImport(); - } - - [Test] - public void should_mark_as_imported_if_all_episodes_were_imported() - { - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new List - { - new ImportResult( - new ImportDecision( - new LocalMovie { Path = @"C:\TestPath\Droned.1998.mkv" })), - - new ImportResult( - new ImportDecision( - new LocalMovie { Path = @"C:\TestPath\Droned.1998.mkv" })) - }); - - Subject.Process(_trackedDownload); - - AssertCompletedDownload(); - } - [Test] public void should_not_mark_as_imported_if_all_files_were_rejected() { @@ -192,16 +106,16 @@ namespace NzbDrone.Core.Test.Download new LocalMovie { Path = @"C:\TestPath\Droned.1999.mkv" }, new Rejection("Rejected!")), "Test Failure") }); - Subject.Process(_trackedDownload); + Subject.Import(_trackedDownload); Mocker.GetMock() .Verify(v => v.PublishEvent(It.IsAny()), Times.Never()); - AssertNoCompletedDownload(); + AssertNotImported(); } [Test] - public void should_not_mark_as_imported_if_no_episodes_were_parsed() + public void should_not_mark_as_imported_if_no_movies_were_parsed() { Mocker.GetMock() .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) @@ -218,9 +132,9 @@ namespace NzbDrone.Core.Test.Download _trackedDownload.RemoteMovie.Movie = null; - Subject.Process(_trackedDownload); + Subject.Import(_trackedDownload); - AssertNoCompletedDownload(); + AssertNotImported(); } [Test] @@ -234,138 +148,61 @@ namespace NzbDrone.Core.Test.Download new ImportResult(new ImportDecision(new LocalMovie { Path = @"C:\TestPath\Droned.1998.mkv" }), "Test Failure") }); - Subject.Process(_trackedDownload); + Subject.Import(_trackedDownload); - AssertNoCompletedDownload(); + AssertNotImported(); } [Test] - public void should_mark_as_imported_if_all_episodes_were_imported_but_extra_files_were_not() + public void should_mark_as_imported_if_all_movies_were_imported_but_extra_files_were_not() { GivenSeriesMatch(); + _trackedDownload.RemoteMovie.Movie = new Movie(); + Mocker.GetMock() .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List - { - new ImportResult(new ImportDecision(new LocalMovie { Path = @"C:\TestPath\Droned.1998.mkv" })), - new ImportResult(new ImportDecision(new LocalMovie { Path = @"C:\TestPath\Droned.1998.mkv" }), "Test Failure") - }); + { + new ImportResult(new ImportDecision(new LocalMovie { Path = @"C:\TestPath\Droned.S01E01.mkv", Movie = _trackedDownload.RemoteMovie.Movie })), + new ImportResult(new ImportDecision(new LocalMovie { Path = @"C:\TestPath\Droned.S01E01.mkv" }), "Test Failure") + }); - Subject.Process(_trackedDownload); + Subject.Import(_trackedDownload); - AssertCompletedDownload(); + AssertImported(); } [Test] - public void should_mark_as_imported_if_the_download_can_be_tracked_using_the_source_seriesid() + public void should_mark_as_imported_if_the_download_can_be_tracked_using_the_source_movieid() { GivenABadlyNamedDownload(); Mocker.GetMock() .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List - { - new ImportResult(new ImportDecision(new LocalMovie { Path = @"C:\TestPath\Droned.1998.mkv" })) - }); + { + new ImportResult(new ImportDecision(new LocalMovie { Path = @"C:\TestPath\Droned.S01E01.mkv", Movie = _trackedDownload.RemoteMovie.Movie })) + }); Mocker.GetMock() .Setup(v => v.GetMovie(It.IsAny())) .Returns(BuildRemoteMovie().Movie); - Subject.Process(_trackedDownload); - - AssertCompletedDownload(); - } - - [Test] - public void should_not_mark_as_imported_if_the_download_cannot_be_tracked_using_the_source_title_as_it_was_initiated_externally() - { - GivenABadlyNamedDownload(); - - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new List - { - new ImportResult(new ImportDecision(new LocalMovie { Path = @"C:\TestPath\Droned.1998.mkv" })) - }); - - Mocker.GetMock() - .Setup(s => s.MostRecentForDownloadId(It.Is(i => i == "1234"))); - - Subject.Process(_trackedDownload); - - AssertNoCompletedDownload(); - } - - [Test] - public void should_not_import_when_there_is_a_title_mismatch() - { - Mocker.GetMock() - .Setup(s => s.GetMovie("Drone.1998")) - .Returns((Movie)null); - - Subject.Process(_trackedDownload); - - AssertNoCompletedDownload(); - } - - [Test] - public void should_mark_as_import_title_mismatch_if_ignore_warnings_is_true() - { - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new List - { - new ImportResult(new ImportDecision(new LocalMovie { Path = @"C:\TestPath\Droned.1998.mkv" })) - }); - - Subject.Process(_trackedDownload, true); - - AssertCompletedDownload(); - } - - [Test] - public void should_warn_if_path_is_not_valid_for_windows() - { - WindowsOnly(); - - _trackedDownload.DownloadItem.OutputPath = new OsPath(@"/invalid/Windows/Path"); - - Subject.Process(_trackedDownload); - - AssertNoAttemptedImport(); - } - - [Test] - public void should_warn_if_path_is_not_valid_for_linux() - { - PosixOnly(); - - _trackedDownload.DownloadItem.OutputPath = new OsPath(@"C:\Invalid\Mono\Path"); - - Subject.Process(_trackedDownload); - - AssertNoAttemptedImport(); - } - - private void AssertNoAttemptedImport() - { - Mocker.GetMock() - .Verify(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + Subject.Import(_trackedDownload); - AssertNoCompletedDownload(); + AssertImported(); } - private void AssertNoCompletedDownload() + private void AssertNotImported() { Mocker.GetMock() .Verify(v => v.PublishEvent(It.IsAny()), Times.Never()); - _trackedDownload.State.Should().NotBe(TrackedDownloadStage.Imported); + _trackedDownload.State.Should().Be(TrackedDownloadState.ImportPending); } - private void AssertCompletedDownload() + private void AssertImported() { Mocker.GetMock() .Verify(v => v.ProcessPath(_trackedDownload.DownloadItem.OutputPath.FullPath, ImportMode.Auto, _trackedDownload.RemoteMovie.Movie, _trackedDownload.DownloadItem), Times.Once()); @@ -373,7 +210,7 @@ namespace NzbDrone.Core.Test.Download Mocker.GetMock() .Verify(v => v.PublishEvent(It.IsAny()), Times.Once()); - _trackedDownload.State.Should().Be(TrackedDownloadStage.Imported); + _trackedDownload.State.Should().Be(TrackedDownloadState.Imported); } } } diff --git a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ProcessFixture.cs b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ProcessFixture.cs new file mode 100644 index 000000000..9e97eecb5 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ProcessFixture.cs @@ -0,0 +1,189 @@ +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.History; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.MovieImport; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests +{ + [TestFixture] + public class ProcessFixture : CoreTest + { + private TrackedDownload _trackedDownload; + + [SetUp] + public void Setup() + { + var completed = Builder.CreateNew() + .With(h => h.Status = DownloadItemStatus.Completed) + .With(h => h.OutputPath = new OsPath(@"C:\DropFolder\MyDownload".AsOsAgnostic())) + .With(h => h.Title = "Drone.S01E01.HDTV") + .Build(); + + var remoteMovie = BuildRemoteMovie(); + + _trackedDownload = Builder.CreateNew() + .With(c => c.State = TrackedDownloadState.Downloading) + .With(c => c.DownloadItem = completed) + .With(c => c.RemoteMovie = remoteMovie) + .Build(); + + Mocker.GetMock() + .SetupGet(c => c.Definition) + .Returns(new DownloadClientDefinition { Id = 1, Name = "testClient" }); + + Mocker.GetMock() + .Setup(c => c.Get(It.IsAny())) + .Returns(Mocker.GetMock().Object); + + Mocker.GetMock() + .Setup(s => s.MostRecentForDownloadId(_trackedDownload.DownloadItem.DownloadId)) + .Returns(new History.History()); + + Mocker.GetMock() + .Setup(s => s.GetMovie("Drone.S01E01.HDTV")) + .Returns(remoteMovie.Movie); + } + + private RemoteMovie BuildRemoteMovie() + { + return new RemoteMovie + { + Movie = new Movie(), + }; + } + + private void GivenNoGrabbedHistory() + { + Mocker.GetMock() + .Setup(s => s.MostRecentForDownloadId(_trackedDownload.DownloadItem.DownloadId)) + .Returns((History.History)null); + } + + private void GivenMovieMatch() + { + Mocker.GetMock() + .Setup(s => s.GetMovie(It.IsAny())) + .Returns(_trackedDownload.RemoteMovie.Movie); + } + + private void GivenABadlyNamedDownload() + { + _trackedDownload.DownloadItem.DownloadId = "1234"; + _trackedDownload.DownloadItem.Title = "Droned Pilot"; // Set a badly named download + Mocker.GetMock() + .Setup(s => s.MostRecentForDownloadId(It.Is(i => i == "1234"))) + .Returns(new History.History() { SourceTitle = "Droned S01E01" }); + + Mocker.GetMock() + .Setup(s => s.GetMovie(It.IsAny())) + .Returns((Movie)null); + + Mocker.GetMock() + .Setup(s => s.GetMovie("Droned S01E01")) + .Returns(BuildRemoteMovie().Movie); + } + + [TestCase(DownloadItemStatus.Downloading)] + [TestCase(DownloadItemStatus.Failed)] + [TestCase(DownloadItemStatus.Queued)] + [TestCase(DownloadItemStatus.Paused)] + [TestCase(DownloadItemStatus.Warning)] + public void should_not_process_if_download_status_isnt_completed(DownloadItemStatus status) + { + _trackedDownload.DownloadItem.Status = status; + + Subject.Check(_trackedDownload); + + AssertNotReadyToImport(); + } + + [Test] + public void should_not_process_if_matching_history_is_not_found_and_no_category_specified() + { + _trackedDownload.DownloadItem.Category = null; + GivenNoGrabbedHistory(); + + Subject.Check(_trackedDownload); + + AssertNotReadyToImport(); + } + + [Test] + public void should_process_if_matching_history_is_not_found_but_category_specified() + { + _trackedDownload.DownloadItem.Category = "tv"; + GivenNoGrabbedHistory(); + GivenMovieMatch(); + + Subject.Check(_trackedDownload); + + AssertReadyToImport(); + } + + [Test] + public void should_not_process_if_output_path_is_empty() + { + _trackedDownload.DownloadItem.OutputPath = default(OsPath); + + Subject.Check(_trackedDownload); + + AssertNotReadyToImport(); + } + + [Test] + public void should_not_process_if_the_download_cannot_be_tracked_using_the_source_title_as_it_was_initiated_externally() + { + GivenABadlyNamedDownload(); + + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List + { + new ImportResult(new ImportDecision(new LocalMovie { Path = @"C:\TestPath\Droned.S01E01.mkv" })) + }); + + Mocker.GetMock() + .Setup(s => s.MostRecentForDownloadId(It.Is(i => i == "1234"))); + + Subject.Check(_trackedDownload); + + AssertNotReadyToImport(); + } + + [Test] + public void should_not_process_when_there_is_a_title_mismatch() + { + Mocker.GetMock() + .Setup(s => s.GetMovie("Drone.S01E01.HDTV")) + .Returns((Movie)null); + + Subject.Check(_trackedDownload); + + AssertNotReadyToImport(); + } + + private void AssertNotReadyToImport() + { + _trackedDownload.State.Should().NotBe(TrackedDownloadState.ImportPending); + } + + private void AssertReadyToImport() + { + _trackedDownload.State.Should().Be(TrackedDownloadState.ImportPending); + } + } +} diff --git a/src/NzbDrone.Core.Test/Download/FailedDownloadServiceTests/ProcessFailedFixture.cs b/src/NzbDrone.Core.Test/Download/FailedDownloadServiceTests/ProcessFailedFixture.cs new file mode 100644 index 000000000..b1bcd9808 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/FailedDownloadServiceTests/ProcessFailedFixture.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.History; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Download.FailedDownloadServiceTests +{ + [TestFixture] + public class ProcessFailedFixture : CoreTest + { + private TrackedDownload _trackedDownload; + private List _grabHistory; + + [SetUp] + public void Setup() + { + var completed = Builder.CreateNew() + .With(h => h.Status = DownloadItemStatus.Completed) + .With(h => h.OutputPath = new OsPath(@"C:\DropFolder\MyDownload".AsOsAgnostic())) + .With(h => h.Title = "Drone.S01E01.HDTV") + .Build(); + + _grabHistory = Builder.CreateListOfSize(2).BuildList(); + + var remoteMovie = new RemoteMovie + { + Movie = new Movie() + }; + + _trackedDownload = Builder.CreateNew() + .With(c => c.State = TrackedDownloadState.FailedPending) + .With(c => c.DownloadItem = completed) + .With(c => c.RemoteMovie = remoteMovie) + .Build(); + + Mocker.GetMock() + .Setup(s => s.Find(_trackedDownload.DownloadItem.DownloadId, HistoryEventType.Grabbed)) + .Returns(_grabHistory); + } + + [Test] + public void should_mark_failed_if_encrypted() + { + _trackedDownload.DownloadItem.IsEncrypted = true; + + Subject.ProcessFailed(_trackedDownload); + + AssertDownloadFailed(); + } + + [Test] + public void should_mark_failed_if_download_item_is_failed() + { + _trackedDownload.DownloadItem.Status = DownloadItemStatus.Failed; + + Subject.ProcessFailed(_trackedDownload); + + AssertDownloadFailed(); + } + + [Test] + public void should_include_tracked_download_in_message() + { + _trackedDownload.DownloadItem.Status = DownloadItemStatus.Failed; + + Subject.ProcessFailed(_trackedDownload); + + Mocker.GetMock() + .Verify(v => v.PublishEvent(It.Is(c => c.TrackedDownload != null)), Times.Once()); + + AssertDownloadFailed(); + } + + private void AssertDownloadNotFailed() + { + Mocker.GetMock() + .Verify(v => v.PublishEvent(It.IsAny()), Times.Never()); + + _trackedDownload.State.Should().NotBe(TrackedDownloadState.Failed); + } + + private void AssertDownloadFailed() + { + Mocker.GetMock() + .Verify(v => v.PublishEvent(It.IsAny()), Times.Once()); + + _trackedDownload.State.Should().Be(TrackedDownloadState.Failed); + } + } +} diff --git a/src/NzbDrone.Core.Test/Download/FailedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/FailedDownloadServiceTests/ProcessFixture.cs similarity index 69% rename from src/NzbDrone.Core.Test/Download/FailedDownloadServiceFixture.cs rename to src/NzbDrone.Core.Test/Download/FailedDownloadServiceTests/ProcessFixture.cs index 14487be50..009383586 100644 --- a/src/NzbDrone.Core.Test/Download/FailedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/FailedDownloadServiceTests/ProcessFixture.cs @@ -13,10 +13,10 @@ using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; using NzbDrone.Test.Common; -namespace NzbDrone.Core.Test.Download +namespace NzbDrone.Core.Test.Download.FailedDownloadServiceTests { [TestFixture] - public class FailedDownloadServiceFixture : CoreTest + public class ProcessFixture : CoreTest { private TrackedDownload _trackedDownload; private List _grabHistory; @@ -32,15 +32,15 @@ namespace NzbDrone.Core.Test.Download _grabHistory = Builder.CreateListOfSize(2).BuildList(); - var remoteEpisode = new RemoteMovie + var remoteMovie = new RemoteMovie { Movie = new Movie(), }; _trackedDownload = Builder.CreateNew() - .With(c => c.State = TrackedDownloadStage.Downloading) + .With(c => c.State = TrackedDownloadState.Downloading) .With(c => c.DownloadItem = completed) - .With(c => c.RemoteMovie = remoteEpisode) + .With(c => c.RemoteMovie = remoteMovie) .Build(); Mocker.GetMock() @@ -60,7 +60,7 @@ namespace NzbDrone.Core.Test.Download { GivenNoGrabbedHistory(); - Subject.Process(_trackedDownload); + Subject.Check(_trackedDownload); AssertDownloadNotFailed(); } @@ -71,7 +71,7 @@ namespace NzbDrone.Core.Test.Download _trackedDownload.DownloadItem.Status = DownloadItemStatus.Failed; GivenNoGrabbedHistory(); - Subject.Process(_trackedDownload); + Subject.Check(_trackedDownload); _trackedDownload.StatusMessages.Should().NotBeEmpty(); } @@ -82,50 +82,17 @@ namespace NzbDrone.Core.Test.Download _trackedDownload.DownloadItem.Status = DownloadItemStatus.Failed; GivenNoGrabbedHistory(); - Subject.Process(_trackedDownload); + Subject.Check(_trackedDownload); _trackedDownload.StatusMessages.Should().NotBeEmpty(); } - [Test] - public void should_mark_failed_if_encrypted() - { - _trackedDownload.DownloadItem.IsEncrypted = true; - - Subject.Process(_trackedDownload); - - AssertDownloadFailed(); - } - - [Test] - public void should_mark_failed_if_download_item_is_failed() - { - _trackedDownload.DownloadItem.Status = DownloadItemStatus.Failed; - - Subject.Process(_trackedDownload); - - AssertDownloadFailed(); - } - - [Test] - public void should_include_tracked_download_in_message() - { - _trackedDownload.DownloadItem.Status = DownloadItemStatus.Failed; - - Subject.Process(_trackedDownload); - - Mocker.GetMock() - .Verify(v => v.PublishEvent(It.Is(c => c.TrackedDownload != null)), Times.Once()); - - AssertDownloadFailed(); - } - private void AssertDownloadNotFailed() { Mocker.GetMock() .Verify(v => v.PublishEvent(It.IsAny()), Times.Never()); - _trackedDownload.State.Should().NotBe(TrackedDownloadStage.DownloadFailed); + _trackedDownload.State.Should().NotBe(TrackedDownloadState.Failed); } private void AssertDownloadFailed() @@ -133,7 +100,7 @@ namespace NzbDrone.Core.Test.Download Mocker.GetMock() .Verify(v => v.PublishEvent(It.IsAny()), Times.Once()); - _trackedDownload.State.Should().Be(TrackedDownloadStage.DownloadFailed); + _trackedDownload.State.Should().Be(TrackedDownloadState.Failed); } } } diff --git a/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadAlreadyImportedFixture.cs b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadAlreadyImportedFixture.cs new file mode 100644 index 000000000..a571da5fb --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadAlreadyImportedFixture.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.History; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Download.TrackedDownloads +{ + [TestFixture] + public class TrackedDownloadAlreadyImportedFixture : CoreTest + { + private Movie _movie; + private TrackedDownload _trackedDownload; + private List _historyItems; + + [SetUp] + public void Setup() + { + _movie = Builder.CreateNew().Build(); + + var remoteMovie = Builder.CreateNew() + .With(r => r.Movie = _movie) + .Build(); + + _trackedDownload = Builder.CreateNew() + .With(t => t.RemoteMovie = remoteMovie) + .Build(); + + _historyItems = new List(); + } + + public void GivenHistoryForMovie(Movie movie, params HistoryEventType[] eventTypes) + { + foreach (var eventType in eventTypes) + { + _historyItems.Add( + Builder.CreateNew() + .With(h => h.MovieId = movie.Id) + .With(h => h.EventType = eventType) + .Build()); + } + } + + [Test] + public void should_return_false_if_there_is_no_history() + { + Subject.IsImported(_trackedDownload, _historyItems) + .Should() + .BeFalse(); + } + + [Test] + public void should_return_false_if_single_movie_download_is_not_imported() + { + GivenHistoryForMovie(_movie, HistoryEventType.Grabbed); + + Subject.IsImported(_trackedDownload, _historyItems) + .Should() + .BeFalse(); + } + + [Test] + public void should_return_true_if_single_movie_download_is_imported() + { + GivenHistoryForMovie(_movie, HistoryEventType.DownloadFolderImported, HistoryEventType.Grabbed); + + Subject.IsImported(_trackedDownload, _historyItems) + .Should() + .BeTrue(); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedMoviesCommandServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedMoviesCommandServiceFixture.cs index 943ce4f2f..aa8c017fe 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DownloadedMoviesCommandServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DownloadedMoviesCommandServiceFixture.cs @@ -48,7 +48,7 @@ namespace NzbDrone.Core.Test.MediaFiles { DownloadItem = downloadItem, RemoteMovie = remoteMovie, - State = TrackedDownloadStage.Downloading + State = TrackedDownloadState.Downloading }; } diff --git a/src/NzbDrone.Core.Test/Messaging/Commands/CommandQueueManagerFixture.cs b/src/NzbDrone.Core.Test/Messaging/Commands/CommandQueueManagerFixture.cs index 68ec47951..d0bf4a26e 100644 --- a/src/NzbDrone.Core.Test/Messaging/Commands/CommandQueueManagerFixture.cs +++ b/src/NzbDrone.Core.Test/Messaging/Commands/CommandQueueManagerFixture.cs @@ -41,7 +41,7 @@ namespace NzbDrone.Core.Test.Messaging.Commands [Test] public void should_not_remove_commands_for_five_minutes_after_they_end() { - var command = Subject.Push(new CheckForFinishedDownloadCommand()); + var command = Subject.Push(new RefreshMonitoredDownloadsCommand()); // Start the command to mimic CommandQueue's behaviour command.StartedAt = DateTime.Now; diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs index b65e6ee25..be362d03e 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs @@ -2,6 +2,7 @@ using System.Linq; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Queue; @@ -41,6 +42,14 @@ namespace NzbDrone.Core.DecisionEngine.Specifications var remoteMovie = queueItem.RemoteMovie; var qualityProfile = subject.Movie.Profile; + // To avoid a race make sure it's not FailedPending (failed awaiting removal/search). + // Failed items (already searching for a replacement) won't be part of the queue since + // it's a copy, of the tracked download, not a reference. + if (queueItem.TrackedDownloadState == TrackedDownloadState.FailedPending) + { + continue; + } + var customFormats = _formatService.ParseCustomFormat(remoteMovie.ParsedMovieInfo); _logger.Debug("Checking if existing release in queue meets cutoff. Queued quality is: {0} - {1}", diff --git a/src/NzbDrone.Core/Download/CheckForFinishedDownloadCommand.cs b/src/NzbDrone.Core/Download/CheckForFinishedDownloadCommand.cs index 71f7f3d5e..a038f1a97 100644 --- a/src/NzbDrone.Core/Download/CheckForFinishedDownloadCommand.cs +++ b/src/NzbDrone.Core/Download/CheckForFinishedDownloadCommand.cs @@ -4,6 +4,5 @@ namespace NzbDrone.Core.Download { public class CheckForFinishedDownloadCommand : Command { - public override bool RequiresDiskAccess => true; } } diff --git a/src/NzbDrone.Core/Download/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index d23559703..ec16784e7 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -16,103 +16,131 @@ namespace NzbDrone.Core.Download { public interface ICompletedDownloadService { - void Process(TrackedDownload trackedDownload, bool ignoreWarnings = false); + void Check(TrackedDownload trackedDownload); + void Import(TrackedDownload trackedDownload); } public class CompletedDownloadService : ICompletedDownloadService { - private readonly IConfigService _configService; private readonly IEventAggregator _eventAggregator; private readonly IHistoryService _historyService; private readonly IDownloadedMovieImportService _downloadedMovieImportService; private readonly IParsingService _parsingService; private readonly IMovieService _movieService; - private readonly Logger _logger; + private readonly ITrackedDownloadAlreadyImported _trackedDownloadAlreadyImported; - public CompletedDownloadService(IConfigService configService, - IEventAggregator eventAggregator, + public CompletedDownloadService(IEventAggregator eventAggregator, IHistoryService historyService, IDownloadedMovieImportService downloadedMovieImportService, IParsingService parsingService, IMovieService movieService, - Logger logger) + ITrackedDownloadAlreadyImported trackedDownloadAlreadyImported) { - _configService = configService; _eventAggregator = eventAggregator; _historyService = historyService; _downloadedMovieImportService = downloadedMovieImportService; _parsingService = parsingService; _movieService = movieService; - _logger = logger; + _trackedDownloadAlreadyImported = trackedDownloadAlreadyImported; } - public void Process(TrackedDownload trackedDownload, bool ignoreWarnings = false) + public void Check(TrackedDownload trackedDownload) { if (trackedDownload.DownloadItem.Status != DownloadItemStatus.Completed) { return; } - if (!ignoreWarnings) + // Only process tracked downloads that are still downloading + if (trackedDownload.State != TrackedDownloadState.Downloading) { - var historyItem = _historyService.MostRecentForDownloadId(trackedDownload.DownloadItem.DownloadId); + return; + } - if (historyItem == null && trackedDownload.DownloadItem.Category.IsNullOrWhiteSpace()) - { - trackedDownload.Warn("Download wasn't grabbed by Radarr and not in a category, Skipping."); - return; - } + var historyItem = _historyService.MostRecentForDownloadId(trackedDownload.DownloadItem.DownloadId); - var downloadItemOutputPath = trackedDownload.DownloadItem.OutputPath; + if (historyItem == null && trackedDownload.DownloadItem.Category.IsNullOrWhiteSpace()) + { + trackedDownload.Warn("Download wasn't grabbed by Sonarr and not in a category, Skipping."); + return; + } - if (downloadItemOutputPath.IsEmpty) - { - trackedDownload.Warn("Download doesn't contain intermediate path, Skipping."); - return; - } + var downloadItemOutputPath = trackedDownload.DownloadItem.OutputPath; - if ((OsInfo.IsWindows && !downloadItemOutputPath.IsWindowsPath) || - (OsInfo.IsNotWindows && !downloadItemOutputPath.IsUnixPath)) + if (downloadItemOutputPath.IsEmpty) + { + trackedDownload.Warn("Download doesn't contain intermediate path, Skipping."); + return; + } + + if ((OsInfo.IsWindows && !downloadItemOutputPath.IsWindowsPath) || + (OsInfo.IsNotWindows && !downloadItemOutputPath.IsUnixPath)) + { + trackedDownload.Warn("[{0}] is not a valid local path. You may need a Remote Path Mapping.", downloadItemOutputPath); + return; + } + + var movie = _parsingService.GetMovie(trackedDownload.DownloadItem.Title); + + if (movie == null) + { + if (historyItem != null) { - trackedDownload.Warn("[{0}] is not a valid local path. You may need a Remote Path Mapping.", downloadItemOutputPath); - return; + movie = _movieService.GetMovie(historyItem.MovieId); } - var movie = _parsingService.GetMovie(trackedDownload.DownloadItem.Title); if (movie == null) { - if (historyItem != null) - { - movie = _movieService.GetMovie(historyItem.MovieId); - } - - if (movie == null) - { - trackedDownload.Warn("Movie title mismatch, automatic import is not possible."); - return; - } + trackedDownload.Warn("Series title mismatch, automatic import is not possible."); + return; } } - Import(trackedDownload); + trackedDownload.State = TrackedDownloadState.ImportPending; } - private void Import(TrackedDownload trackedDownload) + public void Import(TrackedDownload trackedDownload) { + trackedDownload.State = TrackedDownloadState.Importing; + var outputPath = trackedDownload.DownloadItem.OutputPath.FullPath; var importResults = _downloadedMovieImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteMovie.Movie, trackedDownload.DownloadItem); - if (importResults.Empty()) + var allMoviesImported = importResults.Where(c => c.Result == ImportResultType.Imported) + .Select(c => c.ImportDecision.LocalMovie.Movie) + .Count() >= 1; + + if (allMoviesImported) { - trackedDownload.Warn("No files found are eligible for import in {0}", outputPath); + trackedDownload.State = TrackedDownloadState.Imported; + _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); return; } - if (importResults.Count(c => c.Result == ImportResultType.Imported) >= 1) + // Double check if all episodes were imported by checking the history if at least one + // file was imported. This will allow the decision engine to reject already imported + // episode files and still mark the download complete when all files are imported. + if (importResults.Any(c => c.Result == ImportResultType.Imported)) { - trackedDownload.State = TrackedDownloadStage.Imported; - _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); - return; + var historyItems = _historyService.FindByDownloadId(trackedDownload.DownloadItem.DownloadId) + .OrderByDescending(h => h.Date) + .ToList(); + + var allEpisodesImportedInHistory = _trackedDownloadAlreadyImported.IsImported(trackedDownload, historyItems); + + if (allEpisodesImportedInHistory) + { + trackedDownload.State = TrackedDownloadState.Imported; + _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); + return; + } + } + + trackedDownload.State = TrackedDownloadState.ImportPending; + + if (importResults.Empty()) + { + trackedDownload.Warn("No files found are eligible for import in {0}", outputPath); } if (importResults.Any(c => c.Result != ImportResultType.Imported)) diff --git a/src/NzbDrone.Core/Download/DownloadIgnoredEvent.cs b/src/NzbDrone.Core/Download/DownloadIgnoredEvent.cs new file mode 100644 index 000000000..023218336 --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadIgnoredEvent.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Languages; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.Download +{ + public class DownloadIgnoredEvent : IEvent + { + public int MovieId { get; set; } + public List Languages { get; set; } + public QualityModel Quality { get; set; } + public string SourceTitle { get; set; } + public string DownloadClient { get; set; } + public string DownloadId { get; set; } + public string Message { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadProcessingService.cs b/src/NzbDrone.Core/Download/DownloadProcessingService.cs new file mode 100644 index 000000000..fd96a49a0 --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadProcessingService.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Download +{ + public class DownloadProcessingService : IExecute + { + private readonly IConfigService _configService; + private readonly ICompletedDownloadService _completedDownloadService; + private readonly IFailedDownloadService _failedDownloadService; + private readonly ITrackedDownloadService _trackedDownloadService; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + public DownloadProcessingService(IConfigService configService, + ICompletedDownloadService completedDownloadService, + IFailedDownloadService failedDownloadService, + ITrackedDownloadService trackedDownloadService, + IEventAggregator eventAggregator, + Logger logger) + { + _configService = configService; + _completedDownloadService = completedDownloadService; + _failedDownloadService = failedDownloadService; + _trackedDownloadService = trackedDownloadService; + _eventAggregator = eventAggregator; + _logger = logger; + } + + private void RemoveCompletedDownloads(List trackedDownloads) + { + foreach (var trackedDownload in trackedDownloads.Where(c => c.DownloadItem.CanBeRemoved && c.State == TrackedDownloadState.Imported)) + { + _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); + } + } + + public void Execute(ProcessMonitoredDownloadsCommand message) + { + var enableCompletedDownloadHandling = _configService.EnableCompletedDownloadHandling; + var trackedDownloads = _trackedDownloadService.GetTrackedDownloads(); + + foreach (var trackedDownload in trackedDownloads) + { + try + { + if (trackedDownload.State == TrackedDownloadState.FailedPending) + { + _failedDownloadService.ProcessFailed(trackedDownload); + } + + if (enableCompletedDownloadHandling && trackedDownload.State == TrackedDownloadState.ImportPending) + { + _completedDownloadService.Import(trackedDownload); + } + } + catch (Exception e) + { + _logger.Debug(e, "Failed to process download: {0}", trackedDownload.DownloadItem.Title); + } + } + + if (enableCompletedDownloadHandling && _configService.RemoveCompletedDownloads) + { + // Remove tracked downloads that are now complete + RemoveCompletedDownloads(trackedDownloads); + } + + _eventAggregator.PublishEvent(new DownloadsProcessedEvent()); + } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadsProcessedEvent.cs b/src/NzbDrone.Core/Download/DownloadsProcessedEvent.cs new file mode 100644 index 000000000..2dea12b8d --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadsProcessedEvent.cs @@ -0,0 +1,11 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Download +{ + public class DownloadsProcessedEvent : IEvent + { + public DownloadsProcessedEvent() + { + } + } +} diff --git a/src/NzbDrone.Core/Download/FailedDownloadService.cs b/src/NzbDrone.Core/Download/FailedDownloadService.cs index b983cdde7..a1d9cb271 100644 --- a/src/NzbDrone.Core/Download/FailedDownloadService.cs +++ b/src/NzbDrone.Core/Download/FailedDownloadService.cs @@ -11,7 +11,8 @@ namespace NzbDrone.Core.Download { void MarkAsFailed(int historyId); void MarkAsFailed(string downloadId); - void Process(TrackedDownload trackedDownload); + void Check(TrackedDownload trackedDownload); + void ProcessFailed(TrackedDownload trackedDownload); } public class FailedDownloadService : IFailedDownloadService @@ -52,23 +53,20 @@ namespace NzbDrone.Core.Download } } - public void Process(TrackedDownload trackedDownload) + public void Check(TrackedDownload trackedDownload) { - string failure = null; - - if (trackedDownload.DownloadItem.IsEncrypted) - { - failure = "Encrypted download detected"; - } - else if (trackedDownload.DownloadItem.Status == DownloadItemStatus.Failed) + // Only process tracked downloads that are still downloading + if (trackedDownload.State != TrackedDownloadState.Downloading) { - failure = trackedDownload.DownloadItem.Message ?? "Failed download detected"; + return; } - if (failure != null) + if (trackedDownload.DownloadItem.IsEncrypted || + trackedDownload.DownloadItem.Status == DownloadItemStatus.Failed) { - var grabbedItems = _historyService.Find(trackedDownload.DownloadItem.DownloadId, HistoryEventType.Grabbed) - .ToList(); + var grabbedItems = _historyService + .Find(trackedDownload.DownloadItem.DownloadId, HistoryEventType.Grabbed) + .ToList(); if (grabbedItems.Empty()) { @@ -76,9 +74,39 @@ namespace NzbDrone.Core.Download return; } - trackedDownload.State = TrackedDownloadStage.DownloadFailed; - PublishDownloadFailedEvent(grabbedItems, failure, trackedDownload); + trackedDownload.State = TrackedDownloadState.FailedPending; + } + } + + public void ProcessFailed(TrackedDownload trackedDownload) + { + if (trackedDownload.State != TrackedDownloadState.FailedPending) + { + return; } + + var grabbedItems = _historyService + .Find(trackedDownload.DownloadItem.DownloadId, HistoryEventType.Grabbed) + .ToList(); + + if (grabbedItems.Empty()) + { + return; + } + + var failure = "Failed download detected"; + + if (trackedDownload.DownloadItem.IsEncrypted) + { + failure = "Encrypted download detected"; + } + else if (trackedDownload.DownloadItem.Status == DownloadItemStatus.Failed && trackedDownload.DownloadItem.Message.IsNotNullOrWhiteSpace()) + { + failure = trackedDownload.DownloadItem.Message; + } + + trackedDownload.State = TrackedDownloadState.Failed; + PublishDownloadFailedEvent(grabbedItems, failure, trackedDownload); } private void PublishDownloadFailedEvent(List historyItems, string message, TrackedDownload trackedDownload = null) diff --git a/src/NzbDrone.Core/Download/IgnoredDownloadService.cs b/src/NzbDrone.Core/Download/IgnoredDownloadService.cs new file mode 100644 index 000000000..efedd0896 --- /dev/null +++ b/src/NzbDrone.Core/Download/IgnoredDownloadService.cs @@ -0,0 +1,49 @@ +using NLog; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Download +{ + public interface IIgnoredDownloadService + { + bool IgnoreDownload(TrackedDownload trackedDownload); + } + + public class IgnoredDownloadService : IIgnoredDownloadService + { + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + public IgnoredDownloadService(IEventAggregator eventAggregator, + Logger logger) + { + _eventAggregator = eventAggregator; + _logger = logger; + } + + public bool IgnoreDownload(TrackedDownload trackedDownload) + { + var movie = trackedDownload.RemoteMovie.Movie; + + if (movie == null) + { + _logger.Warn("Unable to ignore download for unknown movie"); + return false; + } + + var downloadIgnoredEvent = new DownloadIgnoredEvent + { + MovieId = movie.Id, + Languages = trackedDownload.RemoteMovie.ParsedMovieInfo.Languages, + Quality = trackedDownload.RemoteMovie.ParsedMovieInfo.Quality, + SourceTitle = trackedDownload.DownloadItem.Title, + DownloadClient = trackedDownload.DownloadItem.DownloadClient, + DownloadId = trackedDownload.DownloadItem.DownloadId, + Message = "Manually ignored" + }; + + _eventAggregator.PublishEvent(downloadIgnoredEvent); + return true; + } + } +} diff --git a/src/NzbDrone.Core/Download/ProcessMonitoredDownloadsCommand.cs b/src/NzbDrone.Core/Download/ProcessMonitoredDownloadsCommand.cs new file mode 100644 index 000000000..c3c934031 --- /dev/null +++ b/src/NzbDrone.Core/Download/ProcessMonitoredDownloadsCommand.cs @@ -0,0 +1,9 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Download +{ + public class ProcessMonitoredDownloadsCommand : Command + { + public override bool RequiresDiskAccess => true; + } +} diff --git a/src/NzbDrone.Core/Download/RedownloadFailedDownloadService.cs b/src/NzbDrone.Core/Download/RedownloadFailedDownloadService.cs index d73963114..9f8565f7c 100644 --- a/src/NzbDrone.Core/Download/RedownloadFailedDownloadService.cs +++ b/src/NzbDrone.Core/Download/RedownloadFailedDownloadService.cs @@ -2,31 +2,30 @@ using System.Collections.Generic; using NLog; using NzbDrone.Core.Configuration; using NzbDrone.Core.IndexerSearch; +using NzbDrone.Core.Messaging; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Movies; namespace NzbDrone.Core.Download { - public class RedownloadFailedDownloadService : IHandleAsync + public class RedownloadFailedDownloadService : IHandle { private readonly IConfigService _configService; - private readonly IMovieService _movieService; private readonly IManageCommandQueue _commandQueueManager; private readonly Logger _logger; public RedownloadFailedDownloadService(IConfigService configService, - IMovieService movieService, IManageCommandQueue commandQueueManager, Logger logger) { _configService = configService; - _movieService = movieService; _commandQueueManager = commandQueueManager; _logger = logger; } - public void HandleAsync(DownloadFailedEvent message) + [EventHandleOrder(EventHandleOrder.Last)] + public void Handle(DownloadFailedEvent message) { if (!_configService.AutoRedownloadFailed) { diff --git a/src/NzbDrone.Core/Download/RefreshDownloadsCommand.cs b/src/NzbDrone.Core/Download/RefreshDownloadsCommand.cs new file mode 100644 index 000000000..b4b516b61 --- /dev/null +++ b/src/NzbDrone.Core/Download/RefreshDownloadsCommand.cs @@ -0,0 +1,8 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Download +{ + public class RefreshMonitoredDownloadsCommand : Command + { + } +} diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs b/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs index 7d4998dc3..6e41c24ab 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs @@ -11,9 +11,11 @@ using NzbDrone.Core.Messaging.Events; namespace NzbDrone.Core.Download.TrackedDownloads { - public class DownloadMonitoringService : IExecute, + public class DownloadMonitoringService : IExecute, + IExecute, IHandle, IHandle, + IHandle, IHandle { private readonly IProvideDownloadClient _downloadClientProvider; @@ -49,7 +51,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads private void QueueRefresh() { - _manageCommandQueue.Push(new CheckForFinishedDownloadCommand()); + _manageCommandQueue.Push(new RefreshMonitoredDownloadsCommand()); } private void Refresh() @@ -70,6 +72,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads _trackedDownloadService.UpdateTrackable(trackedDownloads); _eventAggregator.PublishEvent(new TrackedDownloadRefreshedEvent(trackedDownloads)); + _manageCommandQueue.Push(new ProcessMonitoredDownloadsCommand()); } finally { @@ -79,70 +82,54 @@ namespace NzbDrone.Core.Download.TrackedDownloads private List ProcessClientDownloads(IDownloadClient downloadClient) { - List downloadClientHistory = new List(); + var downloadClientItems = new List(); var trackedDownloads = new List(); try { - downloadClientHistory = downloadClient.GetItems().ToList(); + downloadClientItems = downloadClient.GetItems().ToList(); } catch (Exception ex) { _logger.Warn(ex, "Unable to retrieve queue and history items from " + downloadClient.Definition.Name); } - foreach (var downloadItem in downloadClientHistory) + foreach (var downloadItem in downloadClientItems) { - var newItems = ProcessClientItems(downloadClient, downloadItem); - trackedDownloads.AddRange(newItems); - } - - if (_configService.EnableCompletedDownloadHandling && _configService.RemoveCompletedDownloads) - { - RemoveCompletedDownloads(trackedDownloads); + var item = ProcessClientItem(downloadClient, downloadItem); + trackedDownloads.AddIfNotNull(item); } return trackedDownloads; } - private void RemoveCompletedDownloads(List trackedDownloads) + private TrackedDownload ProcessClientItem(IDownloadClient downloadClient, DownloadClientItem downloadItem) { - foreach (var trackedDownload in trackedDownloads.Where(c => c.DownloadItem.CanBeRemoved && c.State == TrackedDownloadStage.Imported)) - { - _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); - } - } - - private List ProcessClientItems(IDownloadClient downloadClient, DownloadClientItem downloadItem) - { - var trackedDownloads = new List(); try { var trackedDownload = _trackedDownloadService.TrackDownload((DownloadClientDefinition)downloadClient.Definition, downloadItem); - if (trackedDownload != null && trackedDownload.State == TrackedDownloadStage.Downloading) + if (trackedDownload != null && trackedDownload.State == TrackedDownloadState.Downloading) { - _failedDownloadService.Process(trackedDownload); - - if (_configService.EnableCompletedDownloadHandling) - { - _completedDownloadService.Process(trackedDownload); - } + _failedDownloadService.Check(trackedDownload); + _completedDownloadService.Check(trackedDownload); } - trackedDownloads.AddIfNotNull(trackedDownload); + return trackedDownload; } catch (Exception e) { _logger.Error(e, "Couldn't process tracked download {0}", downloadItem.Title); } - return trackedDownloads; + return null; } private bool DownloadIsTrackable(TrackedDownload trackedDownload) { - // If the download has already been imported or failed don't track it - if (trackedDownload.State != TrackedDownloadStage.Downloading) + // If the download has already been imported, failed or the user ignored it don't track it + if (trackedDownload.State == TrackedDownloadState.Imported || + trackedDownload.State == TrackedDownloadState.Failed || + trackedDownload.State == TrackedDownloadState.Ignored) { return false; } @@ -156,8 +143,14 @@ namespace NzbDrone.Core.Download.TrackedDownloads return true; } + public void Execute(RefreshMonitoredDownloadsCommand message) + { + Refresh(); + } + public void Execute(CheckForFinishedDownloadCommand message) { + _logger.Warn("A third party app used the deprecated CheckForFinishedDownload command, it should be updated RefreshMonitoredDownloads instead"); Refresh(); } @@ -171,6 +164,13 @@ namespace NzbDrone.Core.Download.TrackedDownloads _refreshDebounce.Execute(); } + public void Handle(DownloadsProcessedEvent message) + { + var trackedDownloads = _trackedDownloadService.GetTrackedDownloads().Where(t => t.IsTrackable && DownloadIsTrackable(t)).ToList(); + + _eventAggregator.PublishEvent(new TrackedDownloadRefreshedEvent(trackedDownloads)); + } + public void Handle(TrackedDownloadsRemovedEvent message) { var trackedDownloads = _trackedDownloadService.GetTrackedDownloads().Where(t => t.IsTrackable && DownloadIsTrackable(t)).ToList(); diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs index 7b1a78aa8..7cb021983 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs @@ -7,7 +7,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads { public int DownloadClient { get; set; } public DownloadClientItem DownloadItem { get; set; } - public TrackedDownloadStage State { get; set; } + public TrackedDownloadState State { get; set; } public TrackedDownloadStatus Status { get; private set; } public RemoteMovie RemoteMovie { get; set; } public TrackedDownloadStatusMessage[] StatusMessages { get; private set; } @@ -33,16 +33,21 @@ namespace NzbDrone.Core.Download.TrackedDownloads } } - public enum TrackedDownloadStage + public enum TrackedDownloadState { Downloading, + ImportPending, + Importing, Imported, - DownloadFailed + FailedPending, + Failed, + Ignored } public enum TrackedDownloadStatus { Ok, - Warning + Warning, + Error } } diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadAlreadyImported.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadAlreadyImported.cs new file mode 100644 index 000000000..be3903a38 --- /dev/null +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadAlreadyImported.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.History; + +namespace NzbDrone.Core.Download.TrackedDownloads +{ + public interface ITrackedDownloadAlreadyImported + { + bool IsImported(TrackedDownload trackedDownload, List historyItems); + } + + public class TrackedDownloadAlreadyImported : ITrackedDownloadAlreadyImported + { + public bool IsImported(TrackedDownload trackedDownload, List historyItems) + { + if (historyItems.Empty()) + { + return false; + } + + var movie = trackedDownload.RemoteMovie.Movie; + + var lastHistoryItem = historyItems.FirstOrDefault(h => h.MovieId == movie.Id); + + if (lastHistoryItem == null) + { + return false; + } + + var allEpisodesImportedInHistory = lastHistoryItem.EventType == HistoryEventType.DownloadFolderImported; + + return allEpisodesImportedInHistory; + } + } +} diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs index 572c5b863..f016531aa 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs @@ -27,6 +27,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads private readonly IParsingService _parsingService; private readonly IHistoryService _historyService; private readonly IEventAggregator _eventAggregator; + private readonly ITrackedDownloadAlreadyImported _trackedDownloadAlreadyImported; private readonly IConfigService _config; private readonly ICustomFormatCalculationService _formatCalculator; private readonly Logger _logger; @@ -38,6 +39,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads IConfigService config, ICustomFormatCalculationService formatCalculator, IEventAggregator eventAggregator, + ITrackedDownloadAlreadyImported trackedDownloadAlreadyImported, Logger logger) { _parsingService = parsingService; @@ -46,6 +48,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads _config = config; _formatCalculator = formatCalculator; _eventAggregator = eventAggregator; + _trackedDownloadAlreadyImported = trackedDownloadAlreadyImported; _logger = logger; } @@ -81,7 +84,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads { var existingItem = Find(downloadItem.DownloadId); - if (existingItem != null && existingItem.State != TrackedDownloadStage.Downloading) + if (existingItem != null && existingItem.State != TrackedDownloadState.Downloading) { LogItemChange(existingItem, existingItem.DownloadItem, downloadItem); @@ -101,9 +104,10 @@ namespace NzbDrone.Core.Download.TrackedDownloads try { - var historyItems = _historyService.FindByDownloadId(downloadItem.DownloadId); - var grabbedHistoryItem = historyItems.OrderByDescending(h => h.Date).FirstOrDefault(h => h.EventType == HistoryEventType.Grabbed); - var firstHistoryItem = historyItems.OrderByDescending(h => h.Date).FirstOrDefault(); + var historyItems = _historyService.FindByDownloadId(downloadItem.DownloadId) + .OrderByDescending(h => h.Date) + .ToList(); + var grabbedHistoryItem = historyItems.FirstOrDefault(h => h.EventType == HistoryEventType.Grabbed); //TODO: Create release info from history and use that here, so we don't loose indexer flags! var parsedMovieInfo = _parsingService.ParseMovieInfo(trackedDownload.DownloadItem.Title, new List { grabbedHistoryItem }); @@ -113,10 +117,27 @@ namespace NzbDrone.Core.Download.TrackedDownloads trackedDownload.RemoteMovie = _parsingService.Map(parsedMovieInfo, "", null).RemoteMovie; } - if (firstHistoryItem != null) + if (historyItems.Any()) { - trackedDownload.State = GetStateFromHistory(firstHistoryItem.EventType); + var firstHistoryItem = historyItems.FirstOrDefault(); + var state = GetStateFromHistory(firstHistoryItem.EventType); + trackedDownload.State = state; + + // TODO: Restore check to confirm all files were imported + // This will treat partially imported downloads as imported (as it was before), which means a partially imported download after a + // restart will get marked as imported without importing the restart of the files. + + //if (state == TrackedDownloadState.Imported) + //{ + // var allImported = _trackedDownloadAlreadyImported.IsImported(trackedDownload, historyItems); + + // trackedDownload.State = allImported ? TrackedDownloadState.Imported : TrackedDownloadState.Downloading; + //} + //else + //{ + // trackedDownload.State = state; + //} var grabbedEvent = historyItems.FirstOrDefault(v => v.EventType == HistoryEventType.Grabbed); trackedDownload.Indexer = grabbedEvent?.Data["indexer"]; @@ -190,16 +211,18 @@ namespace NzbDrone.Core.Download.TrackedDownloads } } - private static TrackedDownloadStage GetStateFromHistory(HistoryEventType eventType) + private static TrackedDownloadState GetStateFromHistory(HistoryEventType eventType) { switch (eventType) { case HistoryEventType.DownloadFolderImported: - return TrackedDownloadStage.Imported; + return TrackedDownloadState.Imported; case HistoryEventType.DownloadFailed: - return TrackedDownloadStage.DownloadFailed; + return TrackedDownloadState.Failed; + case HistoryEventType.DownloadIgnored: + return TrackedDownloadState.Ignored; default: - return TrackedDownloadStage.Downloading; + return TrackedDownloadState.Downloading; } } } diff --git a/src/NzbDrone.Core/History/History.cs b/src/NzbDrone.Core/History/History.cs index fe58239c3..5e0856513 100644 --- a/src/NzbDrone.Core/History/History.cs +++ b/src/NzbDrone.Core/History/History.cs @@ -40,6 +40,7 @@ namespace NzbDrone.Core.History // EpisodeFileDeleted = 5, // deprecated MovieFileDeleted = 6, MovieFolderImported = 7, // not used yet - MovieFileRenamed = 8 + MovieFileRenamed = 8, + DownloadIgnored = 9 } } diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index ba259ac46..d417ac9bc 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -36,7 +36,8 @@ namespace NzbDrone.Core.History IHandle, IHandle, IHandle, - IHandle + IHandle, + IHandle { private readonly IHistoryRepository _historyRepository; private readonly Logger _logger; @@ -223,6 +224,25 @@ namespace NzbDrone.Core.History _historyRepository.Insert(history); } + public void Handle(DownloadIgnoredEvent message) + { + var history = new History + { + EventType = HistoryEventType.DownloadIgnored, + Date = DateTime.UtcNow, + Quality = message.Quality, + SourceTitle = message.SourceTitle, + MovieId = message.MovieId, + DownloadId = message.DownloadId, + Languages = message.Languages + }; + + history.Data.Add("DownloadClient", message.DownloadClient); + history.Data.Add("Message", message.Message); + + _historyRepository.Insert(history); + } + public void Handle(MovieDeletedEvent message) { _historyRepository.DeleteForMovie(message.Movie.Id); diff --git a/src/NzbDrone.Core/Jobs/TaskManager.cs b/src/NzbDrone.Core/Jobs/TaskManager.cs index 35b5b6d7d..ef93a6042 100644 --- a/src/NzbDrone.Core/Jobs/TaskManager.cs +++ b/src/NzbDrone.Core/Jobs/TaskManager.cs @@ -102,7 +102,7 @@ namespace NzbDrone.Core.Jobs new ScheduledTask { Interval = Math.Max(_configService.CheckForFinishedDownloadInterval, 1), - TypeName = typeof(CheckForFinishedDownloadCommand).FullName + TypeName = typeof(RefreshMonitoredDownloadsCommand).FullName } }; diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedMovieImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedMovieImportService.cs index dcd44ca5b..adf464edb 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedMovieImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedMovieImportService.cs @@ -83,6 +83,8 @@ namespace NzbDrone.Core.MediaFiles public List ProcessPath(string path, ImportMode importMode = ImportMode.Auto, Movie movie = null, DownloadClientItem downloadClientItem = null) { + _logger.Debug("Processing path: {0}", path); + if (_diskProvider.FolderExists(path)) { var directoryInfo = new DirectoryInfo(path); @@ -113,34 +115,42 @@ namespace NzbDrone.Core.MediaFiles public bool ShouldDeleteFolder(DirectoryInfo directoryInfo, Movie movie) { - var videoFiles = _diskScanService.GetVideoFiles(directoryInfo.FullName); - var rarFiles = _diskProvider.GetFiles(directoryInfo.FullName, SearchOption.AllDirectories).Where(f => Path.GetExtension(f).Equals(".rar", StringComparison.OrdinalIgnoreCase)); - - foreach (var videoFile in videoFiles) - { - var episodeParseResult = - Parser.Parser.ParseMovieTitle(Path.GetFileName(videoFile), _config.ParsingLeniency > 0); + try + { + var videoFiles = _diskScanService.GetVideoFiles(directoryInfo.FullName); + var rarFiles = _diskProvider.GetFiles(directoryInfo.FullName, SearchOption.AllDirectories).Where(f => Path.GetExtension(f).Equals(".rar", StringComparison.OrdinalIgnoreCase)); - if (episodeParseResult == null) + foreach (var videoFile in videoFiles) { - _logger.Warn("Unable to parse file on import: [{0}]", videoFile); - return false; + var movieParseResult = + Parser.Parser.ParseMovieTitle(Path.GetFileName(videoFile), _config.ParsingLeniency > 0); + + if (movieParseResult == null) + { + _logger.Warn("Unable to parse file on import: [{0}]", videoFile); + return false; + } + + if (_detectSample.IsSample(movie, videoFile, false) != DetectSampleResult.Sample) + { + _logger.Warn("Non-sample file detected: [{0}]", videoFile); + return false; + } } - if (_detectSample.IsSample(movie, videoFile, false) != DetectSampleResult.Sample) + if (rarFiles.Any(f => _diskProvider.GetFileSize(f) > 10.Megabytes())) { - _logger.Warn("Non-sample file detected: [{0}]", videoFile); + _logger.Warn("RAR file detected, will require manual cleanup"); return false; } - } - if (rarFiles.Any(f => _diskProvider.GetFileSize(f) > 10.Megabytes())) + return true; + } + catch (DirectoryNotFoundException e) { - _logger.Warn("RAR file detected, will require manual cleanup"); + _logger.Debug(e, "Folder {0} has already been removed", directoryInfo.FullName); return false; } - - return true; } private List ProcessFolder(DirectoryInfo directoryInfo, ImportMode importMode, DownloadClientItem downloadClientItem) @@ -282,6 +292,12 @@ namespace NzbDrone.Core.MediaFiles var mounts = _diskProvider.GetMounts(); var mount = mounts.FirstOrDefault(m => m.RootDirectory == Path.GetPathRoot(path)); + if (mount == null) + { + _logger.Error("Import failed, path does not exist or is not accessible by Radarr: {0}. Unable to find a volume mounted for the path. If you're using a mapped network drive see the FAQ for more info", path); + return; + } + if (mount.DriveType == DriveType.Network) { _logger.Error("Import failed, path does not exist or is not accessible by Radarr: {0}. It's recommended to avoid mapped network drives when running as a Windows service. See the FAQ for more info", path); diff --git a/src/NzbDrone.Core/MediaFiles/MovieImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/MovieImport/Manual/ManualImportService.cs index 9047f4d4a..10fee3e86 100644 --- a/src/NzbDrone.Core/MediaFiles/MovieImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/MovieImport/Manual/ManualImportService.cs @@ -314,7 +314,7 @@ namespace NzbDrone.Core.MediaFiles.MovieImport.Manual //TODO: trackedDownload.RemoteMovie.Movie.Count is always 1? if (groupedTrackedDownload.Select(c => c.ImportResult).Count(c => c.Result == ImportResultType.Imported) >= Math.Max(1, 1)) { - trackedDownload.State = TrackedDownloadStage.Imported; + trackedDownload.State = TrackedDownloadState.Imported; _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); } } diff --git a/src/NzbDrone.Core/MediaFiles/MovieImport/Specifications/AlreadyImportedSpecification.cs b/src/NzbDrone.Core/MediaFiles/MovieImport/Specifications/AlreadyImportedSpecification.cs new file mode 100644 index 000000000..d58f61857 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MovieImport/Specifications/AlreadyImportedSpecification.cs @@ -0,0 +1,65 @@ +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.History; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.MovieImport.Specifications +{ + public class AlreadyImportedSpecification : IImportDecisionEngineSpecification + { + private readonly IHistoryService _historyService; + private readonly Logger _logger; + + public AlreadyImportedSpecification(IHistoryService historyService, + Logger logger) + { + _historyService = historyService; + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Database; + + public Decision IsSatisfiedBy(LocalMovie localMovie, DownloadClientItem downloadClientItem) + { + if (downloadClientItem == null) + { + _logger.Debug("No download client information is available, skipping"); + return Decision.Accept(); + } + + var movie = localMovie.Movie; + + if (!movie.HasFile) + { + _logger.Debug("Skipping already imported check for movie without file"); + return Decision.Accept(); + } + + var movieImportedHistory = _historyService.GetByMovieId(movie.Id, null); + var lastImported = movieImportedHistory.FirstOrDefault(h => h.EventType == HistoryEventType.DownloadFolderImported); + var lastGrabbed = movieImportedHistory.FirstOrDefault(h => h.EventType == HistoryEventType.Grabbed); + + if (lastImported == null) + { + return Decision.Accept(); + } + + // If the release was grabbed again after importing don't reject it + if (lastGrabbed != null && lastGrabbed.Date.After(lastImported.Date)) + { + return Decision.Accept(); + } + + if (lastImported.DownloadId == downloadClientItem.DownloadId) + { + _logger.Debug("Movie file previously imported at {0}", lastImported.Date); + return Decision.Reject("Movie file already imported at {0}", lastImported.Date); + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs b/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs index f660cd790..78bc5f15c 100644 --- a/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs +++ b/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs @@ -20,16 +20,12 @@ namespace NzbDrone.Core.Messaging.Events private class EventSubscribers where TEvent : class, IEvent { - private IServiceFactory _serviceFactory; - public IHandle[] _syncHandlers; public IHandleAsync[] _asyncHandlers; public IHandleAsync[] _globalHandlers; public EventSubscribers(IServiceFactory serviceFactory) { - _serviceFactory = serviceFactory; - _syncHandlers = serviceFactory.BuildAll>() .OrderBy(GetEventHandleOrder) .ToArray(); @@ -142,8 +138,7 @@ namespace NzbDrone.Core.Messaging.Events internal static int GetEventHandleOrder(IHandle eventHandler) where TEvent : class, IEvent { - // TODO: Convert "Handle" to nameof(eventHandler.Handle) after .net 4.5 - var method = eventHandler.GetType().GetMethod("Handle", new Type[] { typeof(TEvent) }); + var method = eventHandler.GetType().GetMethod(nameof(eventHandler.Handle), new Type[] { typeof(TEvent) }); if (method == null) { diff --git a/src/NzbDrone.Core/Queue/Queue.cs b/src/NzbDrone.Core/Queue/Queue.cs index a5f7463e2..22723bd63 100644 --- a/src/NzbDrone.Core/Queue/Queue.cs +++ b/src/NzbDrone.Core/Queue/Queue.cs @@ -21,7 +21,8 @@ namespace NzbDrone.Core.Queue public TimeSpan? Timeleft { get; set; } public DateTime? EstimatedCompletionTime { get; set; } public string Status { get; set; } - public string TrackedDownloadStatus { get; set; } + public TrackedDownloadStatus? TrackedDownloadStatus { get; set; } + public TrackedDownloadState? TrackedDownloadState { get; set; } public List StatusMessages { get; set; } public string DownloadId { get; set; } public RemoteMovie RemoteMovie { get; set; } diff --git a/src/NzbDrone.Core/Queue/QueueService.cs b/src/NzbDrone.Core/Queue/QueueService.cs index 9d2931407..53b8acd0e 100644 --- a/src/NzbDrone.Core/Queue/QueueService.cs +++ b/src/NzbDrone.Core/Queue/QueueService.cs @@ -65,7 +65,8 @@ namespace NzbDrone.Core.Queue Sizeleft = trackedDownload.DownloadItem.RemainingSize, Timeleft = trackedDownload.DownloadItem.RemainingTime, Status = trackedDownload.DownloadItem.Status.ToString(), - TrackedDownloadStatus = trackedDownload.Status.ToString(), + TrackedDownloadStatus = trackedDownload.Status, + TrackedDownloadState = trackedDownload.State, StatusMessages = trackedDownload.StatusMessages.ToList(), ErrorMessage = trackedDownload.DownloadItem.Message, RemoteMovie = trackedDownload.RemoteMovie, diff --git a/src/NzbDrone.Host.Test/ContainerFixture.cs b/src/NzbDrone.Host.Test/ContainerFixture.cs index 8a7f8e021..3e41b5ff8 100644 --- a/src/NzbDrone.Host.Test/ContainerFixture.cs +++ b/src/NzbDrone.Host.Test/ContainerFixture.cs @@ -93,7 +93,7 @@ namespace NzbDrone.App.Test public void should_return_same_instance_of_singletons_by_different_interfaces() { var first = _container.ResolveAll>().OfType().Single(); - var second = (DownloadMonitoringService)_container.Resolve>(); + var second = (DownloadMonitoringService)_container.Resolve>(); first.Should().BeSameAs(second); } diff --git a/src/Radarr.Api.V3/Queue/QueueActionModule.cs b/src/Radarr.Api.V3/Queue/QueueActionModule.cs index 5d48c6e7f..05cdb142f 100644 --- a/src/Radarr.Api.V3/Queue/QueueActionModule.cs +++ b/src/Radarr.Api.V3/Queue/QueueActionModule.cs @@ -15,6 +15,7 @@ namespace Radarr.Api.V3.Queue private readonly IQueueService _queueService; private readonly ITrackedDownloadService _trackedDownloadService; private readonly IFailedDownloadService _failedDownloadService; + private readonly IIgnoredDownloadService _ignoredDownloadService; private readonly IProvideDownloadClient _downloadClientProvider; private readonly IPendingReleaseService _pendingReleaseService; private readonly IDownloadService _downloadService; @@ -22,6 +23,7 @@ namespace Radarr.Api.V3.Queue public QueueActionModule(IQueueService queueService, ITrackedDownloadService trackedDownloadService, IFailedDownloadService failedDownloadService, + IIgnoredDownloadService ignoredDownloadService, IProvideDownloadClient downloadClientProvider, IPendingReleaseService pendingReleaseService, IDownloadService downloadService) @@ -29,6 +31,7 @@ namespace Radarr.Api.V3.Queue _queueService = queueService; _trackedDownloadService = trackedDownloadService; _failedDownloadService = failedDownloadService; + _ignoredDownloadService = ignoredDownloadService; _downloadClientProvider = downloadClientProvider; _pendingReleaseService = pendingReleaseService; _downloadService = downloadService; @@ -75,9 +78,10 @@ namespace Radarr.Api.V3.Queue private object Remove(int id) { + var removeFromClient = Request.GetBooleanQueryParameter("removeFromClient", true); var blacklist = Request.GetBooleanQueryParameter("blacklist"); - var trackedDownload = Remove(id, blacklist); + var trackedDownload = Remove(id, removeFromClient, blacklist); if (trackedDownload != null) { @@ -89,6 +93,7 @@ namespace Radarr.Api.V3.Queue private object Remove() { + var removeFromClient = Request.GetBooleanQueryParameter("removeFromClient", true); var blacklist = Request.GetBooleanQueryParameter("blacklist"); var resource = Request.Body.FromJson(); @@ -96,7 +101,7 @@ namespace Radarr.Api.V3.Queue foreach (var id in resource.Ids) { - var trackedDownload = Remove(id, blacklist); + var trackedDownload = Remove(id, removeFromClient, blacklist); if (trackedDownload != null) { @@ -109,7 +114,7 @@ namespace Radarr.Api.V3.Queue return new object(); } - private TrackedDownload Remove(int id, bool blacklist) + private TrackedDownload Remove(int id, bool removeFromClient, bool blacklist) { var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); @@ -127,20 +132,31 @@ namespace Radarr.Api.V3.Queue throw new NotFoundException(); } - var downloadClient = _downloadClientProvider.Get(trackedDownload.DownloadClient); - - if (downloadClient == null) + if (removeFromClient) { - throw new BadRequestException(); - } + var downloadClient = _downloadClientProvider.Get(trackedDownload.DownloadClient); + + if (downloadClient == null) + { + throw new BadRequestException(); + } - downloadClient.RemoveItem(trackedDownload.DownloadItem.DownloadId, true); + downloadClient.RemoveItem(trackedDownload.DownloadItem.DownloadId, true); + } if (blacklist) { _failedDownloadService.MarkAsFailed(trackedDownload.DownloadItem.DownloadId); } + if (!removeFromClient && !blacklist) + { + if (!_ignoredDownloadService.IgnoreDownload(trackedDownload)) + { + return null; + } + } + return trackedDownload; } diff --git a/src/Radarr.Api.V3/Queue/QueueResource.cs b/src/Radarr.Api.V3/Queue/QueueResource.cs index 1c0c52550..dbadcdab5 100644 --- a/src/Radarr.Api.V3/Queue/QueueResource.cs +++ b/src/Radarr.Api.V3/Queue/QueueResource.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.Indexers; using NzbDrone.Core.Languages; @@ -24,7 +25,8 @@ namespace Radarr.Api.V3.Queue public TimeSpan? Timeleft { get; set; } public DateTime? EstimatedCompletionTime { get; set; } public string Status { get; set; } - public string TrackedDownloadStatus { get; set; } + public TrackedDownloadStatus? TrackedDownloadStatus { get; set; } + public TrackedDownloadState? TrackedDownloadState { get; set; } public List StatusMessages { get; set; } public string ErrorMessage { get; set; } public string DownloadId { get; set; } @@ -56,8 +58,9 @@ namespace Radarr.Api.V3.Queue Sizeleft = model.Sizeleft, Timeleft = model.Timeleft, EstimatedCompletionTime = model.EstimatedCompletionTime, - Status = model.Status, + Status = model.Status.FirstCharToLower(), TrackedDownloadStatus = model.TrackedDownloadStatus, + TrackedDownloadState = model.TrackedDownloadState, StatusMessages = model.StatusMessages, ErrorMessage = model.ErrorMessage, DownloadId = model.DownloadId, diff --git a/src/Radarr.Api.V3/Queue/QueueStatusModule.cs b/src/Radarr.Api.V3/Queue/QueueStatusModule.cs index cd7196122..8d3e9f077 100644 --- a/src/Radarr.Api.V3/Queue/QueueStatusModule.cs +++ b/src/Radarr.Api.V3/Queue/QueueStatusModule.cs @@ -3,6 +3,7 @@ using System.Linq; using NzbDrone.Common.TPL; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Queue; using NzbDrone.SignalR; @@ -45,10 +46,10 @@ namespace Radarr.Api.V3.Queue TotalCount = queue.Count + pending.Count, Count = queue.Count(q => q.Movie != null) + pending.Count, UnknownCount = queue.Count(q => q.Movie == null), - Errors = queue.Any(q => q.Movie != null && q.TrackedDownloadStatus.Equals("Error", StringComparison.InvariantCultureIgnoreCase)), - Warnings = queue.Any(q => q.Movie != null && q.TrackedDownloadStatus.Equals("Warning", StringComparison.InvariantCultureIgnoreCase)), - UnknownErrors = queue.Any(q => q.Movie == null && q.TrackedDownloadStatus.Equals("Error", StringComparison.InvariantCultureIgnoreCase)), - UnknownWarnings = queue.Any(q => q.Movie == null && q.TrackedDownloadStatus.Equals("Warning", StringComparison.InvariantCultureIgnoreCase)) + Errors = queue.Any(q => q.Movie != null && q.TrackedDownloadStatus == TrackedDownloadStatus.Error), + Warnings = queue.Any(q => q.Movie != null && q.TrackedDownloadStatus == TrackedDownloadStatus.Warning), + UnknownErrors = queue.Any(q => q.Movie == null && q.TrackedDownloadStatus == TrackedDownloadStatus.Error), + UnknownWarnings = queue.Any(q => q.Movie == null && q.TrackedDownloadStatus == TrackedDownloadStatus.Warning) }; _broadcastDebounce.Resume();