diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js index 0fa1505cd..1030e9ebb 100644 --- a/frontend/src/Activity/Queue/Queue.js +++ b/frontend/src/Activity/Queue/Queue.js @@ -132,7 +132,7 @@ class Queue extends Component { totalRecords, isGrabbing, isRemoving, - isCheckForFinishedDownloadExecuting, + isRefreshMonitoredDownloadsExecuting, onRefreshPress, ...otherProps } = this.props; @@ -145,7 +145,7 @@ class Queue extends Component { isPendingSelected } = this.state; - const isRefreshing = isFetching || isEpisodesFetching || isCheckForFinishedDownloadExecuting; + const isRefreshing = isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting; const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId)); const hasError = error || episodesError; const selectedCount = this.getSelectedIds().length; @@ -279,7 +279,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 73d874075..5bb174333 100644 --- a/frontend/src/Activity/Queue/QueueConnector.js +++ b/frontend/src/Activity/Queue/QueueConnector.js @@ -18,13 +18,13 @@ function createMapStateToProps() { (state) => state.episodes, (state) => state.queue.options, (state) => state.queue.paged, - createCommandExecutingSelector(commandNames.CHECK_FOR_FINISHED_DOWNLOAD), - (episodes, options, queue, isCheckForFinishedDownloadExecuting) => { + createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS), + (episodes, options, queue, isRefreshMonitoredDownloadsExecuting) => { return { isEpisodesFetching: episodes.isFetching, isEpisodesPopulated: episodes.isPopulated, episodesError: episodes.error, - isCheckForFinishedDownloadExecuting, + isRefreshMonitoredDownloadsExecuting, ...options, ...queue }; @@ -129,7 +129,7 @@ class QueueConnector extends Component { onRefreshPress = () => { this.props.executeCommand({ - name: commandNames.CHECK_FOR_FINISHED_DOWNLOAD + name: commandNames.REFRESH_MONITORED_DOWNLOADS }); } diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js index 1b5bb0059..5a6f06b88 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, series, @@ -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} /> @@ -365,6 +367,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, series: PropTypes.object, diff --git a/frontend/src/Activity/Queue/QueueStatusCell.js b/frontend/src/Activity/Queue/QueueStatusCell.js index 552fa1444..4d1262214 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,46 +55,58 @@ 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'; } - if (status === 'DownloadClientUnavailable') { + if (status === 'downloadClientUnavailable') { iconName = icons.PENDING; iconKind = kinds.WARNING; 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/Commands/commandNames.js b/frontend/src/Commands/commandNames.js index a48c59157..6a9e1a92d 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_EPISODE_SEARCH = 'CutoffUnmetEpisodeSearch'; diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index 911f95f64..6f26b423d 100644 --- a/frontend/src/Components/SignalRConnector.js +++ b/frontend/src/Components/SignalRConnector.js @@ -262,7 +262,7 @@ class SignalRConnector extends Component { } handleSystemTask = () => { - // No-op for now, we may want this later + this.props.dispatchFetchCommands(); } handleRootfolder = () => { 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 78094f1d3..55762eb07 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 88fd05f43..8b93cd6d9 100644 --- a/src/NzbDrone.Api/Queue/QueueResource.cs +++ b/src/NzbDrone.Api/Queue/QueueResource.cs @@ -46,7 +46,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.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs index 7dedad263..94fd60f9e 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs @@ -4,6 +4,7 @@ using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.Profiles.Languages; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles.Qualities; @@ -79,11 +80,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests .Returns(new List()); } - private void GivenQueue(IEnumerable remoteEpisodes) + private void GivenQueue(IEnumerable remoteEpisodes, TrackedDownloadState trackedDownloadState = TrackedDownloadState.Downloading) { var queue = remoteEpisodes.Select(remoteEpisode => new Queue.Queue { - RemoteEpisode = remoteEpisode + RemoteEpisode = remoteEpisode, + TrackedDownloadState = trackedDownloadState }); Mocker.GetMock() @@ -432,5 +434,27 @@ namespace NzbDrone.Core.Test.DecisionEngineTests GivenQueue(new List { remoteEpisode }); Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); } + + [Test] + public void should_return_true_if_everything_is_the_same_for_failed_pending() + { + _series.QualityProfile.Value.Cutoff = Quality.Bluray1080p.Id; + + var remoteEpisode = Builder.CreateNew() + .With(r => r.Series = _series) + .With(r => r.Episodes = new List { _episode }) + .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo + { + Quality = new QualityModel(Quality.DVD), + Language = Language.Spanish + }) + .With(r => r.Release = _releaseInfo) + .Build(); + + GivenQueue(new List { remoteEpisode }, TrackedDownloadState.FailedPending); + + + Subject.IsSatisfiedBy(_remoteEpisode, 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 64% rename from src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs rename to src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs index 4818c4748..8e061eb47 100644 --- a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs @@ -4,7 +4,6 @@ using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Disk; -using NzbDrone.Core.Configuration; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.Download.TrackedDownloads; @@ -18,10 +17,10 @@ using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; using NzbDrone.Test.Common; -namespace NzbDrone.Core.Test.Download +namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests { [TestFixture] - public class CompletedDownloadServiceFixture : CoreTest + public class ImportFixture : CoreTest { private TrackedDownload _trackedDownload; private Episode _episode1; @@ -44,7 +43,7 @@ namespace NzbDrone.Core.Test.Download var remoteEpisode = BuildRemoteEpisode(); _trackedDownload = Builder.CreateNew() - .With(c => c.State = TrackedDownloadStage.Downloading) + .With(c => c.State = TrackedDownloadState.Downloading) .With(c => c.DownloadItem = completed) .With(c => c.RemoteEpisode = remoteEpisode) .Build(); @@ -79,25 +78,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 LocalEpisode() { Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = { _episode1 } })) - }); - } - - private void GivenABadlyNamedDownload() { _trackedDownload.DownloadItem.DownloadId = "1234"; @@ -122,94 +102,6 @@ namespace NzbDrone.Core.Test.Download .Returns(_trackedDownload.RemoteEpisode.Series); } - [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 = new OsPath(); - - 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 LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = { _episode1 }})), - - new ImportResult( - new ImportDecision( - new LocalEpisode {Path = @"C:\TestPath\Droned.S01E02.mkv", Episodes = { _episode2 }})) - }); - - Subject.Process(_trackedDownload); - - AssertCompletedDownload(); - } - - [Test] - public void should_mark_as_imported_if_all_multi_episodes_were_imported() - { - _trackedDownload.RemoteEpisode.Episodes.Add(new Episode { Id = 2 }); - - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new List - { - new ImportResult( - new ImportDecision( - new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01E02.mkv", Episodes = { _episode1, _episode2 }})) - }); - - Subject.Process(_trackedDownload); - - AssertCompletedDownload(); - } - [Test] public void should_not_mark_as_imported_if_all_files_were_rejected() { @@ -226,12 +118,12 @@ namespace NzbDrone.Core.Test.Download new LocalEpisode {Path = @"C:\TestPath\Droned.S01E02.mkv", Episodes = { _episode2 }},new Rejection("Rejected!")), "Test Failure") }); - Subject.Process(_trackedDownload); + Subject.Import(_trackedDownload); Mocker.GetMock() .Verify(v => v.PublishEvent(It.IsAny()), Times.Never()); - AssertNoCompletedDownload(); + AssertNotImported(); } [Test] @@ -252,9 +144,9 @@ namespace NzbDrone.Core.Test.Download _trackedDownload.RemoteEpisode.Episodes.Clear(); - Subject.Process(_trackedDownload); + Subject.Import(_trackedDownload); - AssertNoCompletedDownload(); + AssertNotImported(); } [Test] @@ -269,9 +161,154 @@ namespace NzbDrone.Core.Test.Download }); - Subject.Process(_trackedDownload); + Subject.Import(_trackedDownload); - AssertNoCompletedDownload(); + AssertNotImported(); + } + + [Test] + public void should_not_mark_as_imported_if_some_of_episodes_were_not_imported() + { + _trackedDownload.RemoteEpisode.Episodes = new List + { + new Episode(), + new Episode(), + new Episode() + }; + + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List + { + new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv"})), + new ImportResult(new ImportDecision(new LocalEpisode{Path = @"C:\TestPath\Droned.S01E01.mkv"}),"Test Failure"), + new ImportResult(new ImportDecision(new LocalEpisode{Path = @"C:\TestPath\Droned.S01E01.mkv"}),"Test Failure") + }); + + Mocker.GetMock() + .Setup(s => s.FindByDownloadId(It.IsAny())) + .Returns(new List()); + + Subject.Import(_trackedDownload); + + AssertNotImported(); + } + + [Test] + public void should_not_mark_as_imported_if_some_of_episodes_were_not_imported_including_history() + { + _trackedDownload.RemoteEpisode.Episodes = new List + { + new Episode(), + new Episode(), + new Episode() + }; + + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List + { + new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv"})), + new ImportResult(new ImportDecision(new LocalEpisode{Path = @"C:\TestPath\Droned.S01E01.mkv"}),"Test Failure"), + new ImportResult(new ImportDecision(new LocalEpisode{Path = @"C:\TestPath\Droned.S01E01.mkv"}),"Test Failure") + }); + + var history = Builder.CreateListOfSize(2) + .BuildList(); + + Mocker.GetMock() + .Setup(s => s.FindByDownloadId(It.IsAny())) + .Returns(history); + + Mocker.GetMock() + .Setup(s => s.IsImported(_trackedDownload, history)) + .Returns(true); + + Subject.Import(_trackedDownload); + + AssertNotImported(); + } + + [Test] + public void should_mark_as_imported_if_all_episodes_were_imported() + { + var episode1 = new Episode {Id = 1}; + var episode2 = new Episode {Id = 2}; + _trackedDownload.RemoteEpisode.Episodes = new List { episode1, episode2 }; + + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List + { + new ImportResult( + new ImportDecision( + new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = new List { episode1 } })), + + new ImportResult( + new ImportDecision( + new LocalEpisode {Path = @"C:\TestPath\Droned.S01E02.mkv", Episodes = new List { episode2 } })) + }); + + Subject.Import(_trackedDownload); + + AssertImported(); + } + + [Test] + public void should_mark_as_imported_if_all_episodes_were_imported_including_history() + { + var episode1 = new Episode { Id = 1 }; + var episode2 = new Episode { Id = 2 }; + _trackedDownload.RemoteEpisode.Episodes = new List { episode1, episode2 }; + + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List + { + new ImportResult( + new ImportDecision( + new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = new List { episode1 } })), + + new ImportResult( + new ImportDecision( + new LocalEpisode {Path = @"C:\TestPath\Droned.S01E02.mkv", Episodes = new List { episode2 } }),"Test Failure") + }); + + var history = Builder.CreateListOfSize(2) + .BuildList(); + + Mocker.GetMock() + .Setup(s => s.FindByDownloadId(It.IsAny())) + .Returns(history); + + Mocker.GetMock() + .Setup(s => s.IsImported(It.IsAny(), It.IsAny>())) + .Returns(true); + + Subject.Import(_trackedDownload); + + AssertImported(); + } + + [Test] + public void should_mark_as_imported_if_double_episode_file_is_imported() + { + var episode1 = new Episode { Id = 1 }; + var episode2 = new Episode { Id = 2 }; + _trackedDownload.RemoteEpisode.Episodes = new List { episode1, episode2 }; + + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List + { + new ImportResult( + new ImportDecision( + new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01-E02.mkv", Episodes = new List { episode1, episode2 } })) + }); + + Subject.Import(_trackedDownload); + + AssertImported(); } [Test] @@ -280,46 +317,21 @@ namespace NzbDrone.Core.Test.Download GivenSeriesMatch(); _trackedDownload.RemoteEpisode.Episodes = new List - { - new Episode() - }; + { + new Episode() + }; Mocker.GetMock() .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = { _episode1 }})), - new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E02.mkv", Episodes = { _episode2 }}),"Test Failure") + new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = _trackedDownload.RemoteEpisode.Episodes})), + new ImportResult(new ImportDecision(new LocalEpisode{Path = @"C:\TestPath\Droned.S01E01.mkv"}),"Test Failure") }); - Subject.Process(_trackedDownload); + Subject.Import(_trackedDownload); - AssertCompletedDownload(); - } - - [Test] - public void should_mark_as_failed_if_some_of_episodes_were_not_imported() - { - _trackedDownload.RemoteEpisode.Episodes = new List - { - _episode1, - _episode2, - _episode3 - }; - - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new List - { - new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = { _episode1 }})), - new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E02.mkv", Episodes = { _episode2 }}),"Test Failure"), - new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E03.mkv", Episodes = { _episode3 }}),"Test Failure") - }); - - - Subject.Process(_trackedDownload); - - AssertNoCompletedDownload(); + AssertImported(); } [Test] @@ -331,111 +343,27 @@ namespace NzbDrone.Core.Test.Download .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = { _episode1 }})) + new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = _trackedDownload.RemoteEpisode.Episodes})) }); Mocker.GetMock() .Setup(v => v.GetSeries(It.IsAny())) .Returns(BuildRemoteEpisode().Series); - Subject.Process(_trackedDownload); + Subject.Import(_trackedDownload); - AssertCompletedDownload(); + AssertImported(); } - [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 LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = { _episode1 }})) - }); - - 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.GetSeries("Drone.S01E01.HDTV")) - .Returns((Series)null); - - Subject.Process(_trackedDownload); - - AssertNoCompletedDownload(); - } - - [Test] - public void should_mark_as_import_title_mismatch_if_ignore_warnings_is_true() - { - _trackedDownload.RemoteEpisode.Episodes = new List - { - new Episode() - }; - - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new List - { - new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = { _episode1 }})) - }); - - 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() - { - MonoOnly(); - - _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()); - - AssertNoCompletedDownload(); - } - - 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.RemoteEpisode.Series, _trackedDownload.DownloadItem), Times.Once()); @@ -443,7 +371,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..003fbb992 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ProcessFixture.cs @@ -0,0 +1,192 @@ +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.EpisodeImport; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +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 remoteEpisode = BuildRemoteEpisode(); + + _trackedDownload = Builder.CreateNew() + .With(c => c.State = TrackedDownloadState.Downloading) + .With(c => c.DownloadItem = completed) + .With(c => c.RemoteEpisode = remoteEpisode) + .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.GetSeries("Drone.S01E01.HDTV")) + .Returns(remoteEpisode.Series); + + } + + private RemoteEpisode BuildRemoteEpisode() + { + return new RemoteEpisode + { + Series = new Series(), + Episodes = new List { new Episode { Id = 1 } } + }; + } + + private void GivenNoGrabbedHistory() + { + Mocker.GetMock() + .Setup(s => s.MostRecentForDownloadId(_trackedDownload.DownloadItem.DownloadId)) + .Returns((History.History)null); + } + + private void GivenSeriesMatch() + { + Mocker.GetMock() + .Setup(s => s.GetSeries(It.IsAny())) + .Returns(_trackedDownload.RemoteEpisode.Series); + } + + 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.GetSeries(It.IsAny())) + .Returns((Series)null); + + Mocker.GetMock() + .Setup(s => s.GetSeries("Droned S01E01")) + .Returns(BuildRemoteEpisode().Series); + } + + [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(); + GivenSeriesMatch(); + + Subject.Check(_trackedDownload); + + AssertReadyToImport(); + } + + [Test] + public void should_not_process_if_output_path_is_empty() + { + _trackedDownload.DownloadItem.OutputPath = new 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 LocalEpisode {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.GetSeries("Drone.S01E01.HDTV")) + .Returns((Series)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..8b2395435 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/FailedDownloadServiceTests/ProcessFailedFixture.cs @@ -0,0 +1,104 @@ +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.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +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 remoteEpisode = new RemoteEpisode + { + Series = new Series(), + Episodes = new List { new Episode { Id = 1 } } + }; + + _trackedDownload = Builder.CreateNew() + .With(c => c.State = TrackedDownloadState.FailedPending) + .With(c => c.DownloadItem = completed) + .With(c => c.RemoteEpisode = remoteEpisode) + .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 72% rename from src/NzbDrone.Core.Test/Download/FailedDownloadServiceFixture.cs rename to src/NzbDrone.Core.Test/Download/FailedDownloadServiceTests/ProcessFixture.cs index 42b589e6b..e54d16859 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.Test.Framework; using NzbDrone.Core.Tv; 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; @@ -39,7 +39,7 @@ namespace NzbDrone.Core.Test.Download }; _trackedDownload = Builder.CreateNew() - .With(c => c.State = TrackedDownloadStage.Downloading) + .With(c => c.State = TrackedDownloadState.Downloading) .With(c => c.DownloadItem = completed) .With(c => c.RemoteEpisode = remoteEpisode) .Build(); @@ -63,7 +63,7 @@ namespace NzbDrone.Core.Test.Download { GivenNoGrabbedHistory(); - Subject.Process(_trackedDownload); + Subject.Check(_trackedDownload); AssertDownloadNotFailed(); } @@ -74,7 +74,7 @@ namespace NzbDrone.Core.Test.Download _trackedDownload.DownloadItem.Status = DownloadItemStatus.Failed; GivenNoGrabbedHistory(); - Subject.Process(_trackedDownload); + Subject.Check(_trackedDownload); _trackedDownload.StatusMessages.Should().NotBeEmpty(); } @@ -85,50 +85,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); } @@ -137,7 +104,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..aaad9640a --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadAlreadyImportedFixture.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.History; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using FizzWare.NBuilder; + +namespace NzbDrone.Core.Test.Download.TrackedDownloads +{ + [TestFixture] + public class TrackedDownloadAlreadyImportedFixture : CoreTest + { + private List _episodes; + private TrackedDownload _trackedDownload; + private List _historyItems; + + [SetUp] + public void Setup() + { + _episodes = new List(); + + var remoteEpisode = Builder.CreateNew() + .With(r => r.Episodes = _episodes) + .Build(); + + _trackedDownload = Builder.CreateNew() + .With(t => t.RemoteEpisode = remoteEpisode) + .Build(); + + _historyItems = new List(); + } + + public void GivenEpisodes(int count) + { + _episodes.AddRange(Builder.CreateListOfSize(count) + .BuildList()); + } + + public void GivenHistoryForEpisode(Episode episode, params HistoryEventType[] eventTypes) + { + foreach (var eventType in eventTypes) + { + _historyItems.Add( + Builder.CreateNew() + .With(h => h.EpisodeId = episode.Id) + .With(h => h.EventType = eventType) + .Build() + ); + } + } + + [Test] + public void should_return_false_if_there_is_no_history() + { + GivenEpisodes(1); + + Subject.IsImported(_trackedDownload, _historyItems) + .Should() + .BeFalse(); + } + + [Test] + public void should_return_false_if_single_episode_download_is_not_imported() + { + GivenEpisodes(1); + + GivenHistoryForEpisode(_episodes[0], HistoryEventType.Grabbed); + + Subject.IsImported(_trackedDownload, _historyItems) + .Should() + .BeFalse(); + } + + [Test] + public void should_return_false_if_no_episode_in_multi_episode_download_is_imported() + { + GivenEpisodes(2); + + GivenHistoryForEpisode(_episodes[0], HistoryEventType.Grabbed); + GivenHistoryForEpisode(_episodes[1], HistoryEventType.Grabbed); + + Subject.IsImported(_trackedDownload, _historyItems) + .Should() + .BeFalse(); + } + + [Test] + public void should_should_return_false_if_only_one_episode_in_multi_episode_download_is_imported() + { + GivenEpisodes(2); + + GivenHistoryForEpisode(_episodes[0], HistoryEventType.DownloadFolderImported, HistoryEventType.Grabbed); + GivenHistoryForEpisode(_episodes[1], HistoryEventType.Grabbed); + + Subject.IsImported(_trackedDownload, _historyItems) + .Should() + .BeFalse(); + } + + [Test] + public void should_return_true_if_single_episode_download_is_imported() + { + GivenEpisodes(1); + + GivenHistoryForEpisode(_episodes[0], HistoryEventType.DownloadFolderImported, HistoryEventType.Grabbed); + + Subject.IsImported(_trackedDownload, _historyItems) + .Should() + .BeTrue(); + } + + [Test] + public void should_return_true_if_multi_episode_download_is_imported() + { + GivenEpisodes(2); + + GivenHistoryForEpisode(_episodes[0], HistoryEventType.DownloadFolderImported, HistoryEventType.Grabbed); + GivenHistoryForEpisode(_episodes[1], HistoryEventType.DownloadFolderImported, HistoryEventType.Grabbed); + + Subject.IsImported(_trackedDownload, _historyItems) + .Should() + .BeTrue(); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesCommandServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesCommandServiceFixture.cs index 7c614ff74..4e28d572a 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesCommandServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesCommandServiceFixture.cs @@ -49,7 +49,7 @@ namespace NzbDrone.Core.Test.MediaFiles { DownloadItem = downloadItem, RemoteEpisode = remoteEpisode, - State = TrackedDownloadStage.Downloading + State = TrackedDownloadState.Downloading }; } diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs index a81bc9215..1e45cae8e 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs @@ -61,7 +61,7 @@ namespace NzbDrone.Core.Test.MediaFiles { DownloadItem = downloadItem, RemoteEpisode = remoteEpisode, - 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 1934f73a8..e32745a22 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs @@ -1,5 +1,6 @@ using System.Linq; using NLog; +using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles.Releases; @@ -42,6 +43,15 @@ namespace NzbDrone.Core.DecisionEngine.Specifications var qualityProfile = subject.Series.QualityProfile.Value; var languageProfile = subject.Series.LanguageProfile.Value; + // 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; + } + _logger.Debug("Checking if existing release in queue meets cutoff. Queued: {0} - {1}", remoteEpisode.ParsedEpisodeInfo.Quality, remoteEpisode.ParsedEpisodeInfo.Language); var queuedItemPreferredWordScore = _preferredWordServiceCalculator.Calculate(subject.Series, queueItem.Title); 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 c35d27a82..07171cefb 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -1,11 +1,8 @@ using System; using System.IO; using System.Linq; -using NLog; -using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; -using NzbDrone.Core.Configuration; using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.History; using NzbDrone.Core.MediaFiles; @@ -18,106 +15,134 @@ 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 IDownloadedEpisodesImportService _downloadedEpisodesImportService; private readonly IParsingService _parsingService; - private readonly Logger _logger; private readonly ISeriesService _seriesService; + private readonly ITrackedDownloadAlreadyImported _trackedDownloadAlreadyImported; - public CompletedDownloadService(IConfigService configService, - IEventAggregator eventAggregator, + public CompletedDownloadService(IEventAggregator eventAggregator, IHistoryService historyService, IDownloadedEpisodesImportService downloadedEpisodesImportService, IParsingService parsingService, ISeriesService seriesService, - Logger logger) + ITrackedDownloadAlreadyImported trackedDownloadAlreadyImported) { - _configService = configService; _eventAggregator = eventAggregator; _historyService = historyService; _downloadedEpisodesImportService = downloadedEpisodesImportService; _parsingService = parsingService; - _logger = logger; _seriesService = seriesService; + _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()) + var historyItem = _historyService.MostRecentForDownloadId(trackedDownload.DownloadItem.DownloadId); + + if (historyItem == null && trackedDownload.DownloadItem.Category.IsNullOrWhiteSpace()) + { + trackedDownload.Warn("Download wasn't grabbed by Sonarr and not in a category, Skipping."); + return; + } + + var downloadItemOutputPath = trackedDownload.DownloadItem.OutputPath; + + 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 series = _parsingService.GetSeries(trackedDownload.DownloadItem.Title); + + if (series == null) + { + if (historyItem != null) { - trackedDownload.Warn("Download wasn't grabbed by Sonarr and not in a category, Skipping."); - return; + series = _seriesService.GetSeries(historyItem.SeriesId); } - var downloadItemOutputPath = trackedDownload.DownloadItem.OutputPath; - - 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 series = _parsingService.GetSeries(trackedDownload.DownloadItem.Title); - if (series == null) { - if (historyItem != null) - { - series = _seriesService.GetSeries(historyItem.SeriesId); - } - - if (series == null) - { - trackedDownload.Warn("Series 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 = _downloadedEpisodesImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteEpisode.Series, trackedDownload.DownloadItem); + var importResults = _downloadedEpisodesImportService.ProcessPath(outputPath, ImportMode.Auto, + trackedDownload.RemoteEpisode.Series, trackedDownload.DownloadItem); + + var allEpisodesImported = importResults.Where(c => c.Result == ImportResultType.Imported) + .SelectMany(c => c.ImportDecision.LocalEpisode.Episodes) + .Count() >= Math.Max(1, + trackedDownload.RemoteEpisode.Episodes.Count); + + if (allEpisodesImported) + { + trackedDownload.State = TrackedDownloadState.Imported; + _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); + return; + } + + // 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)) + { + 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); - return; - } - - var importedEpisodes = importResults.Where(c => c.Result == ImportResultType.Imported).SelectMany(c => c.ImportDecision.LocalEpisode.Episodes).ToList(); - - if (importedEpisodes.Count() >= Math.Max(1, trackedDownload.RemoteEpisode.Episodes.Count)) - { - trackedDownload.State = TrackedDownloadStage.Imported; - _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); - return; } if (importResults.Any(c => c.Result != ImportResultType.Imported)) diff --git a/src/NzbDrone.Core/Download/DownloadProcessingService.cs b/src/NzbDrone.Core/Download/DownloadProcessingService.cs new file mode 100644 index 000000000..960194d59 --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadProcessingService.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Linq; +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; + + public DownloadProcessingService(IConfigService configService, + ICompletedDownloadService completedDownloadService, + IFailedDownloadService failedDownloadService, + ITrackedDownloadService trackedDownloadService, + IEventAggregator eventAggregator) + { + _configService = configService; + _completedDownloadService = completedDownloadService; + _failedDownloadService = failedDownloadService; + _trackedDownloadService = trackedDownloadService; + _eventAggregator = eventAggregator; + } + + 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) + { + if (trackedDownload.State == TrackedDownloadState.FailedPending) + { + _failedDownloadService.ProcessFailed(trackedDownload); + } + + if (enableCompletedDownloadHandling && trackedDownload.State == TrackedDownloadState.ImportPending) + { + _completedDownloadService.Import(trackedDownload); + } + } + + 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..4ebe4fd40 --- /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() + { + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/FailedDownloadService.cs b/src/NzbDrone.Core/Download/FailedDownloadService.cs index 2158a5d5c..c04abeae6 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,35 +53,62 @@ namespace NzbDrone.Core.Download } } - public void Process(TrackedDownload trackedDownload) + public void Check(TrackedDownload trackedDownload) { - string failure = null; - - if (trackedDownload.DownloadItem.IsEncrypted) + // Only process tracked downloads that are still downloading + if (trackedDownload.State != TrackedDownloadState.Downloading) { - failure = "Encrypted download detected"; - } - else if (trackedDownload.DownloadItem.Status == DownloadItemStatus.Failed) - { - 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()) { trackedDownload.Warn("Download wasn't grabbed by sonarr, skipping"); 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) { var historyItem = historyItems.First(); 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 d85729775..ed23f021b 100644 --- a/src/NzbDrone.Core/Download/RedownloadFailedDownloadService.cs +++ b/src/NzbDrone.Core/Download/RedownloadFailedDownloadService.cs @@ -2,13 +2,14 @@ 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.Tv; namespace NzbDrone.Core.Download { - public class RedownloadFailedDownloadService : IHandleAsync + public class RedownloadFailedDownloadService : IHandle { private readonly IConfigService _configService; private readonly IEpisodeService _episodeService; @@ -26,7 +27,8 @@ namespace NzbDrone.Core.Download _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/RefreshMonitoredDownloadsCommand.cs b/src/NzbDrone.Core/Download/RefreshMonitoredDownloadsCommand.cs new file mode 100644 index 000000000..b4b516b61 --- /dev/null +++ b/src/NzbDrone.Core/Download/RefreshMonitoredDownloadsCommand.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 9823bb2cc..0ec687ec2 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs @@ -11,9 +11,10 @@ using NzbDrone.Core.Messaging.Events; namespace NzbDrone.Core.Download.TrackedDownloads { - public class DownloadMonitoringService : IExecute, + public class DownloadMonitoringService : IExecute, IHandle, IHandle, + IHandle, IHandle { private readonly IDownloadClientStatusService _downloadClientStatusService; @@ -52,7 +53,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads private void QueueRefresh() { - _manageCommandQueue.Push(new CheckForFinishedDownloadCommand()); + _manageCommandQueue.Push(new RefreshMonitoredDownloadsCommand()); } private void Refresh() @@ -73,6 +74,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads _trackedDownloadService.UpdateTrackable(trackedDownloads); _eventAggregator.PublishEvent(new TrackedDownloadRefreshedEvent(trackedDownloads)); + _manageCommandQueue.Push(new ProcessMonitoredDownloadsCommand()); } finally { @@ -82,12 +84,12 @@ 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(); _downloadClientStatusService.RecordSuccess(downloadClient.Definition.Id); } @@ -99,59 +101,40 @@ namespace NzbDrone.Core.Download.TrackedDownloads _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 (trackedDownload.State == TrackedDownloadState.Imported || trackedDownload.State == TrackedDownloadState.Failed) { return false; } @@ -165,8 +148,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(); } @@ -180,6 +169,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 b5497e509..2a93f8789 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 RemoteEpisode RemoteEpisode { get; set; } public TrackedDownloadStatusMessage[] StatusMessages { get; private set; } @@ -33,16 +33,20 @@ namespace NzbDrone.Core.Download.TrackedDownloads } } - public enum TrackedDownloadStage + public enum TrackedDownloadState { Downloading, + ImportPending, + Importing, Imported, - DownloadFailed + FailedPending, + Failed } 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..6375d064e --- /dev/null +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadAlreadyImported.cs @@ -0,0 +1,37 @@ +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 allEpisodesImportedInHistory = trackedDownload.RemoteEpisode.Episodes.All(e => + { + var lastHistoryItem = historyItems.FirstOrDefault(h => h.EpisodeId == e.Id); + + if (lastHistoryItem == null) + { + return false; + } + + return 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 be8208772..5525f6adb 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs @@ -25,6 +25,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads private readonly IParsingService _parsingService; private readonly IHistoryService _historyService; private readonly IEventAggregator _eventAggregator; + private readonly ITrackedDownloadAlreadyImported _trackedDownloadAlreadyImported; private readonly Logger _logger; private readonly ICached _cache; @@ -32,11 +33,13 @@ namespace NzbDrone.Core.Download.TrackedDownloads ICacheManager cacheManager, IHistoryService historyService, IEventAggregator eventAggregator, + ITrackedDownloadAlreadyImported trackedDownloadAlreadyImported, Logger logger) { _parsingService = parsingService; _historyService = historyService; _eventAggregator = eventAggregator; + _trackedDownloadAlreadyImported = trackedDownloadAlreadyImported; _cache = cacheManager.GetCache(GetType()); _logger = logger; } @@ -72,7 +75,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); @@ -93,7 +96,9 @@ namespace NzbDrone.Core.Download.TrackedDownloads try { var parsedEpisodeInfo = Parser.Parser.ParseTitle(trackedDownload.DownloadItem.Title); - var historyItems = _historyService.FindByDownloadId(downloadItem.DownloadId); + var historyItems = _historyService.FindByDownloadId(downloadItem.DownloadId) + .OrderByDescending(h => h.Date) + .ToList(); if (parsedEpisodeInfo != null) { @@ -102,8 +107,22 @@ namespace NzbDrone.Core.Download.TrackedDownloads if (historyItems.Any()) { - var firstHistoryItem = historyItems.OrderByDescending(h => h.Date).First(); - trackedDownload.State = GetStateFromHistory(firstHistoryItem.EventType); + var firstHistoryItem = historyItems.First(); + var state = GetStateFromHistory(firstHistoryItem.EventType); + + // One potential issue here is if the latest is imported, but other episodes are ignored or never imported. + // It's unlikely that will happen, but could happen if additional episodes are added to season after it's already imported. + + 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"]; @@ -158,7 +177,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads } private void LogItemChange(TrackedDownload trackedDownload, DownloadClientItem existingItem, DownloadClientItem downloadItem) - { + { if (existingItem == null || existingItem.Status != downloadItem.Status || existingItem.CanBeRemoved != downloadItem.CanBeRemoved || @@ -174,16 +193,16 @@ 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; default: - return TrackedDownloadStage.Downloading; + return TrackedDownloadState.Downloading; } } } diff --git a/src/NzbDrone.Core/Jobs/TaskManager.cs b/src/NzbDrone.Core/Jobs/TaskManager.cs index 72d27f92b..d80aa136b 100644 --- a/src/NzbDrone.Core/Jobs/TaskManager.cs +++ b/src/NzbDrone.Core/Jobs/TaskManager.cs @@ -61,7 +61,7 @@ namespace NzbDrone.Core.Jobs { var defaultTasks = new[] { - new ScheduledTask{ Interval = 1, TypeName = typeof(CheckForFinishedDownloadCommand).FullName}, + new ScheduledTask{ Interval = 1, TypeName = typeof(RefreshMonitoredDownloadsCommand).FullName}, new ScheduledTask{ Interval = 5, TypeName = typeof(MessagingCleanupCommand).FullName}, new ScheduledTask{ Interval = 6*60, TypeName = typeof(ApplicationUpdateCommand).FullName}, new ScheduledTask{ Interval = 3*60, TypeName = typeof(UpdateSceneMappingCommand).FullName}, diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs index 70b41ecc1..36985b738 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs @@ -348,7 +348,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual if (groupedTrackedDownload.Select(c => c.ImportResult).Count(c => c.Result == ImportResultType.Imported) >= Math.Max(1, trackedDownload.RemoteEpisode.Episodes.Count)) { - trackedDownload.State = TrackedDownloadStage.Imported; + trackedDownload.State = TrackedDownloadState.Imported; _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/AlreadyImportedSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/AlreadyImportedSpecification.cs new file mode 100644 index 000000000..ebcbe1aa2 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/AlreadyImportedSpecification.cs @@ -0,0 +1,61 @@ +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.History; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.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(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) + { + if (downloadClientItem == null) + { + _logger.Debug("No download client information is available, skipping"); + return Decision.Accept(); + } + + foreach (var episode in localEpisode.Episodes) + { + if (!episode.HasFile) + { + _logger.Debug("Skipping already imported check for episode without file"); + continue; + } + + var episodeHistory = _historyService.FindByEpisodeId(episode.Id); + var lastImported = episodeHistory.FirstOrDefault(h => h.EventType == HistoryEventType.DownloadFolderImported); + + if (lastImported == null) + { + continue; + } + + // TODO: Ignore last imported check if the same release was grabbed again + // See: https://github.com/Sonarr/Sonarr/issues/2393 + + if (lastImported.DownloadId == downloadClientItem.DownloadId) + { + _logger.Debug("Episode file previously imported at {0}", lastImported.Date); + return Decision.Reject("Episode 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 4f49324a9..6c9491e0d 100644 --- a/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs +++ b/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs @@ -19,16 +19,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(); @@ -139,8 +135,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 e4bd0e2b2..48bd27b4a 100644 --- a/src/NzbDrone.Core/Queue/Queue.cs +++ b/src/NzbDrone.Core/Queue/Queue.cs @@ -22,7 +22,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 RemoteEpisode RemoteEpisode { get; set; } diff --git a/src/NzbDrone.Core/Queue/QueueService.cs b/src/NzbDrone.Core/Queue/QueueService.cs index 8857f4660..66edf83dc 100644 --- a/src/NzbDrone.Core/Queue/QueueService.cs +++ b/src/NzbDrone.Core/Queue/QueueService.cs @@ -70,7 +70,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, RemoteEpisode = trackedDownload.RemoteEpisode, diff --git a/src/NzbDrone.Host.Test/ContainerFixture.cs b/src/NzbDrone.Host.Test/ContainerFixture.cs index 0179b5323..32f95f263 100644 --- a/src/NzbDrone.Host.Test/ContainerFixture.cs +++ b/src/NzbDrone.Host.Test/ContainerFixture.cs @@ -86,7 +86,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/Sonarr.Api.V3/Queue/QueueResource.cs b/src/Sonarr.Api.V3/Queue/QueueResource.cs index 975a16644..29cd64fd2 100644 --- a/src/Sonarr.Api.V3/Queue/QueueResource.cs +++ b/src/Sonarr.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; @@ -25,7 +26,8 @@ namespace Sonarr.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; } @@ -55,8 +57,9 @@ namespace Sonarr.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/Sonarr.Api.V3/Queue/QueueStatusModule.cs b/src/Sonarr.Api.V3/Queue/QueueStatusModule.cs index 9431777f1..b86826554 100644 --- a/src/Sonarr.Api.V3/Queue/QueueStatusModule.cs +++ b/src/Sonarr.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; @@ -47,10 +48,10 @@ namespace Sonarr.Api.V3.Queue TotalCount = queue.Count + pending.Count, Count = queue.Count(q => q.Series != null) + pending.Count, UnknownCount = queue.Count(q => q.Series == null), - Errors = queue.Any(q => q.Series != null && q.TrackedDownloadStatus.Equals("Error", StringComparison.InvariantCultureIgnoreCase)), - Warnings = queue.Any(q => q.Series != null && q.TrackedDownloadStatus.Equals("Warning", StringComparison.InvariantCultureIgnoreCase)), - UnknownErrors = queue.Any(q => q.Series == null && q.TrackedDownloadStatus.Equals("Error", StringComparison.InvariantCultureIgnoreCase)), - UnknownWarnings = queue.Any(q => q.Series == null && q.TrackedDownloadStatus.Equals("Warning", StringComparison.InvariantCultureIgnoreCase)) + Errors = queue.Any(q => q.Series != null && q.TrackedDownloadStatus == TrackedDownloadStatus.Error), + Warnings = queue.Any(q => q.Series != null && q.TrackedDownloadStatus == TrackedDownloadStatus.Warning), + UnknownErrors = queue.Any(q => q.Series == null && q.TrackedDownloadStatus == TrackedDownloadStatus.Error), + UnknownWarnings = queue.Any(q => q.Series == null && q.TrackedDownloadStatus == TrackedDownloadStatus.Warning) }; _broadcastDebounce.Resume();