diff --git a/src/NzbDrone.Core.Test/Download/FailedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/FailedDownloadServiceFixture.cs new file mode 100644 index 000000000..478515d6a --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/FailedDownloadServiceFixture.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Download; +using NzbDrone.Core.History; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.Download +{ + [TestFixture] + public class FailedDownloadServiceFixture : CoreTest + { + private Series _series; + private Episode _episode; + private List _completed; + private List _failed; + + [SetUp] + public void Setup() + { + _series = Builder.CreateNew().Build(); + _episode = Builder.CreateNew().Build(); + + _completed = Builder.CreateListOfSize(5) + .All() + .With(h => h.Status = HistoryStatus.Completed) + .Build() + .ToList(); + + _failed = Builder.CreateListOfSize(1) + .All() + .With(h => h.Status = HistoryStatus.Failed) + .Build() + .ToList(); + + Mocker.GetMock() + .Setup(c => c.GetDownloadClient()).Returns(Mocker.GetMock().Object); + } + + private void GivenNoRecentHistory() + { + Mocker.GetMock() + .Setup(s => s.BetweenDates(It.IsAny(), It.IsAny(), HistoryEventType.Grabbed)) + .Returns(new List()); + } + + private void GivenRecentHistory(List history) + { + Mocker.GetMock() + .Setup(s => s.BetweenDates(It.IsAny(), It.IsAny(), HistoryEventType.Grabbed)) + .Returns(history); + } + + private void GivenNoFailedHistory() + { + Mocker.GetMock() + .Setup(s => s.Failed()) + .Returns(new List()); + } + + private void GivenFailedHistory(List failedHistory) + { + Mocker.GetMock() + .Setup(s => s.Failed()) + .Returns(failedHistory); + } + + private void GivenFailedDownloadClientHistory() + { + Mocker.GetMock() + .Setup(s => s.GetHistory(0, 20)) + .Returns(_failed); + } + + private void VerifyNoFailedDownloads() + { + Mocker.GetMock() + .Verify(v => v.PublishEvent(It.IsAny()), Times.Never()); + } + + private void VerifyFailedDownloads(int count = 1) + { + Mocker.GetMock() + .Verify(v => v.PublishEvent(It.IsAny()), Times.Exactly(count)); + } + + [Test] + public void should_not_process_if_no_download_client_history() + { + Mocker.GetMock() + .Setup(s => s.GetHistory(0, 20)) + .Returns(new List()); + + Subject.Execute(new FailedDownloadCommand()); + + Mocker.GetMock() + .Verify(s => s.BetweenDates(It.IsAny(), It.IsAny(), HistoryEventType.Grabbed), + Times.Never()); + + VerifyNoFailedDownloads(); + } + + [Test] + public void should_not_process_if_no_failed_items_in_download_client_history() + { + Mocker.GetMock() + .Setup(s => s.GetHistory(0, 20)) + .Returns(_completed); + + Subject.Execute(new FailedDownloadCommand()); + + Mocker.GetMock() + .Verify(s => s.BetweenDates(It.IsAny(), It.IsAny(), HistoryEventType.Grabbed), + Times.Never()); + + VerifyNoFailedDownloads(); + } + + [Test] + public void should_not_process_if_matching_history_is_not_found() + { + GivenNoRecentHistory(); + GivenFailedDownloadClientHistory(); + + Subject.Execute(new FailedDownloadCommand()); + + VerifyNoFailedDownloads(); + } + + [Test] + public void should_not_process_if_already_added_to_history_as_failed() + { + GivenFailedDownloadClientHistory(); + + var history = Builder.CreateListOfSize(1) + .Build() + .ToList(); + + GivenRecentHistory(history); + GivenFailedHistory(history); + + history.First().Data.Add("downloadClient", "SabnzbdClient"); + history.First().Data.Add("downloadClientId", _failed.First().Id); + + Subject.Execute(new FailedDownloadCommand()); + + VerifyNoFailedDownloads(); + } + + [Test] + public void should_process_if_not_already_in_failed_history() + { + GivenFailedDownloadClientHistory(); + + var history = Builder.CreateListOfSize(1) + .Build() + .ToList(); + + GivenRecentHistory(history); + GivenNoFailedHistory(); + + history.First().Data.Add("downloadClient", "SabnzbdClient"); + history.First().Data.Add("downloadClientId", _failed.First().Id); + + Subject.Execute(new FailedDownloadCommand()); + + VerifyFailedDownloads(); + } + + [Test] + public void should_process_for_each_failed_episode() + { + GivenFailedDownloadClientHistory(); + + var history = Builder.CreateListOfSize(2) + .Build() + .ToList(); + + GivenRecentHistory(history); + GivenNoFailedHistory(); + + history.ForEach(h => + { + h.Data.Add("downloadClient", "SabnzbdClient"); + h.Data.Add("downloadClientId", _failed.First().Id); + }); + + Subject.Execute(new FailedDownloadCommand()); + + VerifyFailedDownloads(2); + } + } +} diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index e75b4d0ac..f0a409e97 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -124,6 +124,7 @@ + diff --git a/src/NzbDrone.Core/Download/Clients/BlackholeProvider.cs b/src/NzbDrone.Core/Download/Clients/BlackholeProvider.cs index 2fe37fe78..a5d8e5fe2 100644 --- a/src/NzbDrone.Core/Download/Clients/BlackholeProvider.cs +++ b/src/NzbDrone.Core/Download/Clients/BlackholeProvider.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using NLog; using NzbDrone.Common; @@ -51,5 +52,10 @@ namespace NzbDrone.Core.Download.Clients { return new QueueItem[0]; } + + public IEnumerable GetHistory(int start = 0, int limit = 0) + { + return new HistoryItem[0]; + } } } diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetClient.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetClient.cs index 9e1d7d7b4..94f83ed9d 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetClient.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetClient.cs @@ -91,6 +91,11 @@ namespace NzbDrone.Core.Download.Clients.Nzbget } } + public IEnumerable GetHistory(int start = 0, int limit = 0) + { + return new HistoryItem[0]; + } + public virtual VersionModel GetVersion(string host = null, int port = 0, string username = null, string password = null) { //Get saved values if any of these are defaults diff --git a/src/NzbDrone.Core/Download/Clients/PneumaticClient.cs b/src/NzbDrone.Core/Download/Clients/PneumaticClient.cs index 3cef9b226..1036f7d9b 100644 --- a/src/NzbDrone.Core/Download/Clients/PneumaticClient.cs +++ b/src/NzbDrone.Core/Download/Clients/PneumaticClient.cs @@ -65,6 +65,11 @@ namespace NzbDrone.Core.Download.Clients return new QueueItem[0]; } + public IEnumerable GetHistory(int start = 0, int limit = 0) + { + return new HistoryItem[0]; + } + public virtual bool IsInQueue(RemoteEpisode newEpisode) { return false; diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdClient.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdClient.cs index 0eddaea99..106e5fc71 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdClient.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdClient.cs @@ -38,6 +38,15 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd _logger = logger; } + public bool IsConfigured + { + get + { + return !string.IsNullOrWhiteSpace(_configService.SabHost) + && _configService.SabPort != 0; + } + } + public string DownloadNzb(RemoteEpisode remoteEpisode) { var url = remoteEpisode.Release.DownloadUrl; @@ -56,15 +65,6 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd } } - public bool IsConfigured - { - get - { - return !string.IsNullOrWhiteSpace(_configService.SabHost) - && _configService.SabPort != 0; - } - } - public IEnumerable GetQueue() { return _queueCache.Get("queue", () => @@ -104,7 +104,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd }, TimeSpan.FromSeconds(10)); } - public virtual List GetHistory(int start = 0, int limit = 0) + public IEnumerable GetHistory(int start = 0, int limit = 0) { string action = String.Format("mode=history&output=json&start={0}&limit={1}", start, limit); string request = GetSabRequest(action); @@ -113,7 +113,23 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd CheckForError(response); var items = Json.Deserialize(JObject.Parse(response).SelectToken("history").ToString()).Items; - return items ?? new List(); + var historyItems = new List(); + + foreach (var sabHistoryItem in items) + { + var historyItem = new HistoryItem(); + historyItem.Id = sabHistoryItem.Id; + historyItem.Title = sabHistoryItem.Title; + historyItem.Size = sabHistoryItem.Size; + historyItem.DownloadTime = sabHistoryItem.DownloadTime; + historyItem.Storage = sabHistoryItem.Storage; + historyItem.Category = sabHistoryItem.Category; + historyItem.Status = sabHistoryItem.Status == "Failed" ? HistoryStatus.Failed : HistoryStatus.Completed; + + historyItems.Add(historyItem); + } + + return historyItems; } public virtual SabCategoryModel GetCategories(string host = null, int port = 0, string apiKey = null, string username = null, string password = null) diff --git a/src/NzbDrone.Core/Download/DownloadFailedEvent.cs b/src/NzbDrone.Core/Download/DownloadFailedEvent.cs new file mode 100644 index 000000000..f83444bb2 --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadFailedEvent.cs @@ -0,0 +1,16 @@ +using System; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Download +{ + public class DownloadFailedEvent : IEvent + { + public Series Series { get; set; } + public Episode Episode { get; set; } + public QualityModel Quality { get; set; } + public String SourceTitle { get; set; } + public String DownloadClient { get; set; } + public String DownloadClientId { get; set; } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/FailedDownloadCommand.cs b/src/NzbDrone.Core/Download/FailedDownloadCommand.cs new file mode 100644 index 000000000..864921ba1 --- /dev/null +++ b/src/NzbDrone.Core/Download/FailedDownloadCommand.cs @@ -0,0 +1,9 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Download +{ + public class FailedDownloadCommand : Command + { + + } +} diff --git a/src/NzbDrone.Core/Download/FailedDownloadService.cs b/src/NzbDrone.Core/Download/FailedDownloadService.cs new file mode 100644 index 000000000..79431acf1 --- /dev/null +++ b/src/NzbDrone.Core/Download/FailedDownloadService.cs @@ -0,0 +1,87 @@ +using System; +using System.Linq; +using NLog; +using NzbDrone.Core.History; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Download +{ + public class FailedDownloadService : IExecute + { + private readonly IProvideDownloadClient _downloadClientProvider; + private readonly IHistoryService _historyService; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + private static string DOWNLOAD_CLIENT = "downloadClient"; + private static string DOWNLOAD_CLIENT_ID = "downloadClientId"; + + public FailedDownloadService(IProvideDownloadClient downloadClientProvider, + IHistoryService historyService, + IEventAggregator eventAggregator, + Logger logger) + { + _downloadClientProvider = downloadClientProvider; + _historyService = historyService; + _eventAggregator = eventAggregator; + _logger = logger; + } + + private void CheckForFailedDownloads() + { + var downloadClient = _downloadClientProvider.GetDownloadClient(); + var downloadClientHistory = downloadClient.GetHistory(0, 20).ToList(); + + var failedItems = downloadClientHistory.Where(h => h.Status == HistoryStatus.Failed).ToList(); + + if (!failedItems.Any()) + { + _logger.Trace("Yay! No failed downloads"); + return; + } + + var recentHistory = _historyService.BetweenDates(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, HistoryEventType.Grabbed); + var failedHistory = _historyService.Failed(); + + foreach (var failedItem in failedItems) + { + var failedLocal = failedItem; + var historyItems = recentHistory.Where(h => h.Data.ContainsKey(DOWNLOAD_CLIENT) && + h.Data[DOWNLOAD_CLIENT_ID].Equals(failedLocal.Id)) + .ToList(); + + if (!historyItems.Any()) + { + _logger.Trace("Unable to find matching history item"); + continue; + } + + if (failedHistory.Any(h => h.Data.ContainsKey(DOWNLOAD_CLIENT_ID) && + h.Data[DOWNLOAD_CLIENT_ID].Equals(failedLocal.Id))) + { + _logger.Trace("Already added to history as failed"); + continue; + } + + foreach (var historyItem in historyItems) + { + _eventAggregator.PublishEvent(new DownloadFailedEvent + { + Series = historyItem.Series, + Episode = historyItem.Episode, + Quality = historyItem.Quality, + SourceTitle = historyItem.SourceTitle, + DownloadClient = historyItem.Data[DOWNLOAD_CLIENT], + DownloadClientId = historyItem.Data[DOWNLOAD_CLIENT_ID] + }); + } + } + } + + public void Execute(FailedDownloadCommand message) + { + CheckForFailedDownloads(); + } + } +} diff --git a/src/NzbDrone.Core/Download/HistoryItem.cs b/src/NzbDrone.Core/Download/HistoryItem.cs new file mode 100644 index 000000000..094270107 --- /dev/null +++ b/src/NzbDrone.Core/Download/HistoryItem.cs @@ -0,0 +1,22 @@ +using System; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Download +{ + public class HistoryItem + { + public String Id { get; set; } + public String Title { get; set; } + public String Size { get; set; } + public String Category { get; set; } + public Int32 DownloadTime { get; set; } + public String Storage { get; set; } + public HistoryStatus Status { get; set; } + } + + public enum HistoryStatus + { + Completed = 0, + Failed = 1 + } +} diff --git a/src/NzbDrone.Core/Download/IDownloadClient.cs b/src/NzbDrone.Core/Download/IDownloadClient.cs index 5270b62b4..f330dbc86 100644 --- a/src/NzbDrone.Core/Download/IDownloadClient.cs +++ b/src/NzbDrone.Core/Download/IDownloadClient.cs @@ -8,5 +8,6 @@ namespace NzbDrone.Core.Download string DownloadNzb(RemoteEpisode remoteEpisode); bool IsConfigured { get; } IEnumerable GetQueue(); + IEnumerable GetHistory(int start = 0, int limit = 0); } } diff --git a/src/NzbDrone.Core/History/History.cs b/src/NzbDrone.Core/History/History.cs index 886ae9c4e..6be5f97a9 100644 --- a/src/NzbDrone.Core/History/History.cs +++ b/src/NzbDrone.Core/History/History.cs @@ -17,12 +17,9 @@ namespace NzbDrone.Core.History public string SourceTitle { get; set; } public QualityModel Quality { get; set; } public DateTime Date { get; set; } - public Episode Episode { get; set; } public Series Series { get; set; } - public HistoryEventType EventType { get; set; } - public Dictionary Data { get; set; } } @@ -32,7 +29,8 @@ namespace NzbDrone.Core.History Unknown = 0, Grabbed = 1, SeriesFolderImported = 2, - DownloadFolderImported = 3 + DownloadFolderImported = 3, + DownloadFailed = 4 } } \ No newline at end of file diff --git a/src/NzbDrone.Core/History/HistoryRepository.cs b/src/NzbDrone.Core/History/HistoryRepository.cs index 5a6cf5244..1c3c7807f 100644 --- a/src/NzbDrone.Core/History/HistoryRepository.cs +++ b/src/NzbDrone.Core/History/HistoryRepository.cs @@ -13,6 +13,8 @@ namespace NzbDrone.Core.History { void Trim(); List GetBestQualityInHistory(int episodeId); + List BetweenDates(DateTime startDate, DateTime endDate, HistoryEventType eventType); + List Failed(); } public class HistoryRepository : BasicRepository, IHistoryRepository @@ -38,6 +40,20 @@ namespace NzbDrone.Core.History return history.Select(h => h.Quality).ToList(); } + public List BetweenDates(DateTime startDate, DateTime endDate, HistoryEventType eventType) + { + return Query.Join(JoinType.Inner, h => h.Series, (h, s) => h.SeriesId == s.Id) + .Join(JoinType.Inner, h => h.Episode, (h, e) => h.EpisodeId == e.Id) + .Where(h => h.Date >= startDate) + .AndWhere(h => h.Date <= endDate) + .AndWhere(h => h.EventType == eventType); + } + + public List Failed() + { + return Query.Where(h => h.EventType == HistoryEventType.DownloadFailed); + } + public override PagingSpec GetPaged(PagingSpec pagingSpec) { pagingSpec.Records = GetPagedQuery(pagingSpec).ToList(); diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index 568cc3058..0244d1180 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -18,9 +18,11 @@ namespace NzbDrone.Core.History void Trim(); QualityModel GetBestQualityInHistory(int episodeId); PagingSpec Paged(PagingSpec pagingSpec); + List BetweenDates(DateTime startDate, DateTime endDate, HistoryEventType eventType); + List Failed(); } - public class HistoryService : IHistoryService, IHandle, IHandle + public class HistoryService : IHistoryService, IHandle, IHandle, IHandle { private readonly IHistoryRepository _historyRepository; private readonly Logger _logger; @@ -41,6 +43,16 @@ namespace NzbDrone.Core.History return _historyRepository.GetPaged(pagingSpec); } + public List BetweenDates(DateTime startDate, DateTime endDate, HistoryEventType eventType) + { + return _historyRepository.BetweenDates(startDate, endDate, eventType); + } + + public List Failed() + { + return _historyRepository.Failed(); + } + public void Purge() { _historyRepository.Purge(); @@ -51,7 +63,7 @@ namespace NzbDrone.Core.History _historyRepository.Trim(); } - public virtual QualityModel GetBestQualityInHistory(int episodeId) + public QualityModel GetBestQualityInHistory(int episodeId) { return _historyRepository.GetBestQualityInHistory(episodeId).OrderByDescending(q => q).FirstOrDefault(); } @@ -107,5 +119,23 @@ namespace NzbDrone.Core.History _historyRepository.Insert(history); } } + + public void Handle(DownloadFailedEvent message) + { + var history = new History + { + EventType = HistoryEventType.DownloadFailed, + Date = DateTime.UtcNow, + Quality = message.Quality, + SourceTitle = message.SourceTitle, + SeriesId = message.Series.Id, + EpisodeId = message.Episode.Id, + }; + + history.Data.Add("DownloadClient", message.DownloadClient); + history.Data.Add("DownloadClientId", message.DownloadClientId); + + _historyRepository.Insert(history); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Jobs/TaskManager.cs b/src/NzbDrone.Core/Jobs/TaskManager.cs index e2bc22da2..c7d28b727 100644 --- a/src/NzbDrone.Core/Jobs/TaskManager.cs +++ b/src/NzbDrone.Core/Jobs/TaskManager.cs @@ -7,6 +7,7 @@ using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.DataAugmentation; using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.Core.DataAugmentation.Xem; +using NzbDrone.Core.Download; using NzbDrone.Core.Housekeeping; using NzbDrone.Core.Indexers; using NzbDrone.Core.Instrumentation.Commands; @@ -54,7 +55,8 @@ namespace NzbDrone.Core.Jobs new ScheduledTask{ Interval = 1*60, TypeName = typeof(TrimLogCommand).FullName}, new ScheduledTask{ Interval = 3*60, TypeName = typeof(UpdateSceneMappingCommand).FullName}, new ScheduledTask{ Interval = 1, TypeName = typeof(TrackedCommandCleanupCommand).FullName}, - new ScheduledTask{ Interval = 24*60, TypeName = typeof(HousekeepingCommand).FullName} + new ScheduledTask{ Interval = 24*60, TypeName = typeof(HousekeepingCommand).FullName}, + new ScheduledTask{ Interval = 1, TypeName = typeof(FailedDownloadCommand).FullName} }; var currentTasks = _scheduledTaskRepository.All(); diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index b7f1607c4..c73a36f09 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -226,9 +226,13 @@ + + + + diff --git a/src/UI/Cells/EventTypeCell.js b/src/UI/Cells/EventTypeCell.js index 3b9bb8b0a..47c179061 100644 --- a/src/UI/Cells/EventTypeCell.js +++ b/src/UI/Cells/EventTypeCell.js @@ -29,6 +29,10 @@ define( icon = 'icon-nd-imported'; toolTip = 'Episode downloaded successfully and picked up from download client'; break; + case 'downloadFailed': + icon = 'icon-nd-download-failed'; + toolTip = 'Episode download failed'; + break; default : icon = 'icon-question'; toolTip = 'unknown event'; diff --git a/src/UI/Content/icons.less b/src/UI/Content/icons.less index 131e82b07..14f35d6dd 100644 --- a/src/UI/Content/icons.less +++ b/src/UI/Content/icons.less @@ -156,4 +156,9 @@ .icon-fatal:before { .icon(@remove-sign); color : purple; +} + +.icon-nd-download-failed:before { + .icon(@cloud-download); + color: @errorText; } \ No newline at end of file