diff --git a/Gruntfile.js b/Gruntfile.js index fc75499ec..35deb38bb 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -37,6 +37,7 @@ module.exports = function (grunt) { 'Content/theme.less', 'Content/overrides.less', 'Series/series.less', + 'History/history.less', 'AddSeries/addSeries.less', 'Calendar/calendar.less', 'Cells/cells.less', diff --git a/src/NzbDrone.Api/Queue/QueueResource.cs b/src/NzbDrone.Api/Queue/QueueResource.cs index d47dbbd8f..72df87f84 100644 --- a/src/NzbDrone.Api/Queue/QueueResource.cs +++ b/src/NzbDrone.Api/Queue/QueueResource.cs @@ -17,5 +17,6 @@ namespace NzbDrone.Api.Queue public Decimal Sizeleft { get; set; } public TimeSpan? Timeleft { get; set; } public String Status { get; set; } + public String ErrorMessage { get; set; } } } diff --git a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs index 0d7b6d1e9..043aa6f55 100644 --- a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs @@ -12,6 +12,7 @@ using NzbDrone.Core.History; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Test.Common; using NzbDrone.Core.Tv; using NzbDrone.Core.Parser.Model; @@ -103,14 +104,20 @@ namespace NzbDrone.Core.Test.Download { Mocker.GetMock() .Setup(v => v.ProcessFolder(It.IsAny(), It.IsAny())) - .Returns(new List() { new Core.MediaFiles.EpisodeImport.ImportDecision(null) }); + .Returns(new List() + { + new ImportDecision(null) + }); } private void GivenFailedImport() { Mocker.GetMock() .Setup(v => v.ProcessFolder(It.IsAny(), It.IsAny())) - .Returns(new List()); + .Returns(new List() + { + new ImportDecision(new LocalEpisode() { Path = @"C:\TestPath\Droned.S01E01.mkv" }, "Test Failure") + }); } private void VerifyNoImports() @@ -265,6 +272,8 @@ namespace NzbDrone.Core.Test.Download Subject.Execute(new CheckForFinishedDownloadCommand()); VerifyNoImports(); + + ExceptionVerification.IgnoreErrors(); } [Test] @@ -289,6 +298,8 @@ namespace NzbDrone.Core.Test.Download Subject.Execute(new CheckForFinishedDownloadCommand()); VerifyNoImports(); + + ExceptionVerification.IgnoreWarns(); } [Test] @@ -412,6 +423,8 @@ namespace NzbDrone.Core.Test.Download Mocker.GetMock() .Verify(c => c.DeleteFolder(It.IsAny(), true), Times.Never()); + + ExceptionVerification.IgnoreErrors(); } [Test] diff --git a/src/NzbDrone.Core/Download/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index 37c77f290..81e3a2b10 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -67,7 +67,7 @@ namespace NzbDrone.Core.Download if (!grabbedItems.Any() && trackedDownload.DownloadItem.Category.IsNullOrWhiteSpace()) { - _logger.Trace("Ignoring download that wasn't grabbed by drone: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Download wasn't grabbed by drone or not in a category, ignoring download."); return; } @@ -77,7 +77,7 @@ namespace NzbDrone.Core.Download { trackedDownload.State = TrackedDownloadState.Imported; - _logger.Trace("Already added to history as imported: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Already added to history as imported."); } else { @@ -85,13 +85,13 @@ namespace NzbDrone.Core.Download string downloadItemOutputPath = trackedDownload.DownloadItem.OutputPath; if (downloadItemOutputPath.IsNullOrWhiteSpace()) { - _logger.Trace("Storage path not specified: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(trackedDownload, LogLevel.Warn, "Download doesn't contain intermediate path, ignoring download."); return; } if (!downloadedEpisodesFolder.IsNullOrWhiteSpace() && (downloadedEpisodesFolder.PathEquals(downloadItemOutputPath) || downloadedEpisodesFolder.IsParentPath(downloadItemOutputPath))) { - _logger.Trace("Storage path inside drone factory, ignoring download: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(trackedDownload, LogLevel.Warn, "Intermediate Download path inside drone factory, ignoring download."); return; } @@ -99,19 +99,49 @@ namespace NzbDrone.Core.Download { var decisions = _downloadedEpisodesImportService.ProcessFolder(new DirectoryInfo(trackedDownload.DownloadItem.OutputPath), trackedDownload.DownloadItem); - if (decisions.Any()) + if (!decisions.Any()) { + UpdateStatusMessage(trackedDownload, LogLevel.Error, "No files found eligible for import in {0}", trackedDownload.DownloadItem.OutputPath); + } + else if (decisions.Any(v => v.Approved)) + { + UpdateStatusMessage(trackedDownload, LogLevel.Info, "Imported {0} files.", decisions.Count(v => v.Approved)); + trackedDownload.State = TrackedDownloadState.Imported; } + else + { + var rejections = decisions + .Where(v => !v.Approved) + .Select(v => v.Rejections.Aggregate(Path.GetFileName(v.LocalEpisode.Path), (a, r) => a + "\r\n- " + r)) + .Aggregate("Failed to import:", (a, r) => a + "\r\n" + r); + + UpdateStatusMessage(trackedDownload, LogLevel.Error, rejections); + } } else if (_diskProvider.FileExists(trackedDownload.DownloadItem.OutputPath)) { var decisions = _downloadedEpisodesImportService.ProcessFile(new FileInfo(trackedDownload.DownloadItem.OutputPath), trackedDownload.DownloadItem); - if (decisions.Any()) + if (!decisions.Any()) { + UpdateStatusMessage(trackedDownload, LogLevel.Error, "No files found eligible for import in {0}", trackedDownload.DownloadItem.OutputPath); + } + else if (decisions.Any(v => v.Approved)) + { + UpdateStatusMessage(trackedDownload, LogLevel.Info, "Imported {0} files.", decisions.Count(v => v.Approved)); + trackedDownload.State = TrackedDownloadState.Imported; } + else + { + var rejections = decisions + .Where(v => !v.Approved) + .Select(v => v.Rejections.Aggregate(Path.GetFileName(v.LocalEpisode.Path), (a, r) => a + "\r\n- " + r)) + .Aggregate("Failed to import:", (a, r) => a + "\r\n" + r); + + UpdateStatusMessage(trackedDownload, LogLevel.Error, rejections); + } } else { @@ -137,13 +167,13 @@ namespace NzbDrone.Core.Download importedItems.First().Data[DownloadTrackingService.DOWNLOAD_CLIENT_ID] = grabbedItems.First().Data[DownloadTrackingService.DOWNLOAD_CLIENT_ID]; _historyService.UpdateHistoryData(importedItems.First().Id, importedItems.First().Data); - _logger.Trace("Storage path does not exist, but found probable drone factory ImportEvent: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Intermediate Download path does not exist, but found probable drone factory ImportEvent."); return; } } } - _logger.Trace("Storage path does not exist: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(trackedDownload, LogLevel.Error, "Intermediate Download path does not exist: {0}", trackedDownload.DownloadItem.OutputPath); return; } } @@ -153,17 +183,17 @@ namespace NzbDrone.Core.Download { try { - _logger.Info("Removing completed download from history: {0}", trackedDownload.DownloadItem.Title); + _logger.Debug("[{0}] Removing completed download from history.", trackedDownload.DownloadItem.Title); downloadClient.RemoveItem(trackedDownload.DownloadItem.DownloadClientId); if (_diskProvider.FolderExists(trackedDownload.DownloadItem.OutputPath)) { - _logger.Info("Removing completed download directory: {0}", trackedDownload.DownloadItem.OutputPath); + _logger.Debug("Removing completed download directory: {0}", trackedDownload.DownloadItem.OutputPath); _diskProvider.DeleteFolder(trackedDownload.DownloadItem.OutputPath, true); } else if (_diskProvider.FileExists(trackedDownload.DownloadItem.OutputPath)) { - _logger.Info("Removing completed download file: {0}", trackedDownload.DownloadItem.OutputPath); + _logger.Debug("Removing completed download file: {0}", trackedDownload.DownloadItem.OutputPath); _diskProvider.DeleteFile(trackedDownload.DownloadItem.OutputPath); } @@ -171,9 +201,26 @@ namespace NzbDrone.Core.Download } catch (NotSupportedException) { - _logger.Debug("Removing item not supported by your download client"); + UpdateStatusMessage(trackedDownload, LogLevel.Debug, "Removing item not supported by your download client."); } } } + + private void UpdateStatusMessage(TrackedDownload trackedDownload, LogLevel logLevel, String message, params object[] args) + { + var statusMessage = String.Format(message, args); + var logMessage = String.Format("[{0}] {1}", trackedDownload.DownloadItem.Title, statusMessage); + + if (trackedDownload.StatusMessage != statusMessage) + { + trackedDownload.HasError = logLevel >= LogLevel.Warn; + trackedDownload.StatusMessage = statusMessage; + _logger.Log(logLevel, logMessage); + } + else + { + _logger.Debug(logMessage); + } + } } } diff --git a/src/NzbDrone.Core/Download/DownloadTrackingService.cs b/src/NzbDrone.Core/Download/DownloadTrackingService.cs index 463c06d97..eef90e755 100644 --- a/src/NzbDrone.Core/Download/DownloadTrackingService.cs +++ b/src/NzbDrone.Core/Download/DownloadTrackingService.cs @@ -20,7 +20,7 @@ namespace NzbDrone.Core.Download TrackedDownload[] GetQueuedDownloads(); } - public class DownloadTrackingService : IDownloadTrackingService, IExecute, IHandle, IHandle + public class DownloadTrackingService : IDownloadTrackingService, IExecute, IHandleAsync, IHandle { private readonly IProvideDownloadClient _downloadClientProvider; private readonly IHistoryService _historyService; @@ -201,7 +201,7 @@ namespace NzbDrone.Core.Download ProcessTrackedDownloads(); } - public void Handle(ApplicationStartedEvent message) + public void HandleAsync(ApplicationStartedEvent message) { ProcessTrackedDownloads(); } diff --git a/src/NzbDrone.Core/Download/FailedDownloadService.cs b/src/NzbDrone.Core/Download/FailedDownloadService.cs index a883b2e63..fc56fbe06 100644 --- a/src/NzbDrone.Core/Download/FailedDownloadService.cs +++ b/src/NzbDrone.Core/Download/FailedDownloadService.cs @@ -54,7 +54,7 @@ namespace NzbDrone.Core.Download if (!grabbedItems.Any()) { - _logger.Trace("Download was not grabbed by drone, ignoring download: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(LogLevel.Debug, trackedDownload, "Download was not grabbed by drone, ignoring download"); return; } @@ -64,7 +64,7 @@ namespace NzbDrone.Core.Download if (failedItems.Any()) { - _logger.Trace("Already added to history as failed: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(LogLevel.Debug, trackedDownload, "Already added to history as failed"); } else { @@ -78,7 +78,7 @@ namespace NzbDrone.Core.Download if (!grabbedItems.Any()) { - _logger.Trace("Download was not grabbed by drone, ignoring download: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(LogLevel.Debug, trackedDownload, "Download wasn't grabbed by drone or not in a category, ignoring download."); return; } @@ -86,13 +86,13 @@ namespace NzbDrone.Core.Download if (trackedDownload.DownloadItem.Message.Equals("Unpacking failed, write error or disk is full?", StringComparison.InvariantCultureIgnoreCase)) { - _logger.Trace("Failed due to lack of disk space, do not blacklist: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(LogLevel.Error, trackedDownload, "Download failed due to lack of disk space, not blacklisting."); return; } if (FailedDownloadForRecentRelease(downloadClient, trackedDownload, grabbedItems)) { - _logger.Trace("Recent release Failed, do not blacklist: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(LogLevel.Debug, trackedDownload, "Recent release Failed, do not blacklist."); return; } @@ -102,7 +102,7 @@ namespace NzbDrone.Core.Download if (failedItems.Any()) { - _logger.Trace("Already added to history as failed: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(LogLevel.Debug, trackedDownload, "Already added to history as failed."); } else { @@ -117,7 +117,7 @@ namespace NzbDrone.Core.Download if (grabbedItems.Any() && failedItems.Any()) { - _logger.Trace("Already added to history as failed, updating tracked state: " + trackedDownload.DownloadItem.Title); + UpdateStatusMessage(LogLevel.Debug, trackedDownload, "Already added to history as failed, updating tracked state."); trackedDownload.State = TrackedDownloadState.DownloadFailed; } } @@ -126,14 +126,14 @@ namespace NzbDrone.Core.Download { try { - _logger.Info("Removing failed download from client: {0}", trackedDownload.DownloadItem.Title); + _logger.Debug("[{0}] Removing failed download from client", trackedDownload.DownloadItem.Title); downloadClient.RemoveItem(trackedDownload.DownloadItem.DownloadClientId); trackedDownload.State = TrackedDownloadState.Removed; } catch (NotSupportedException) { - _logger.Trace("Removing item not supported by your download client"); + UpdateStatusMessage(LogLevel.Debug, trackedDownload, "Removing item not supported by your download client."); } } } @@ -144,25 +144,25 @@ namespace NzbDrone.Core.Download if (!Double.TryParse(matchingHistoryItems.First().Data.GetValueOrDefault("ageHours"), out ageHours)) { - _logger.Debug("Unable to determine age of failed download: " + trackedDownload.DownloadItem.Title); + _logger.Info("[{0}] Unable to determine age of failed download.", trackedDownload.DownloadItem.Title); return false; } if (ageHours > _configService.BlacklistGracePeriod) { - _logger.Debug("Failed download is older than the grace period: " + trackedDownload.DownloadItem.Title); + _logger.Info("[{0}] Failed download is older than the grace period.", trackedDownload.DownloadItem.Title); return false; } if (trackedDownload.RetryCount >= _configService.BlacklistRetryLimit) { - _logger.Debug("Retry limit reached: " + trackedDownload.DownloadItem.Title); + _logger.Info("[{0}] Retry limit reached.", trackedDownload.DownloadItem.Title); return false; } if (trackedDownload.RetryCount == 0 || trackedDownload.LastRetry.AddMinutes(_configService.BlacklistRetryInterval) < DateTime.UtcNow) { - _logger.Debug("Retrying failed release: " + trackedDownload.DownloadItem.Title); + _logger.Info("[{0}] Retrying failed release.", trackedDownload.DownloadItem.Title); trackedDownload.LastRetry = DateTime.UtcNow; trackedDownload.RetryCount++; @@ -205,5 +205,22 @@ namespace NzbDrone.Core.Download _eventAggregator.PublishEvent(downloadFailedEvent); } + + private void UpdateStatusMessage(LogLevel logLevel, TrackedDownload trackedDownload, String message, params object[] args) + { + var statusMessage = String.Format(message, args); + var logMessage = String.Format("[{0}] {1}", trackedDownload.DownloadItem.Title, message); + + if (trackedDownload.StatusMessage != statusMessage) + { + trackedDownload.HasError = logLevel >= LogLevel.Warn; + trackedDownload.StatusMessage = statusMessage; + _logger.Log(logLevel, statusMessage); + } + else + { + _logger.Debug(logMessage); + } + } } } diff --git a/src/NzbDrone.Core/Download/TrackedDownload.cs b/src/NzbDrone.Core/Download/TrackedDownload.cs index 9d490c51e..841e803a5 100644 --- a/src/NzbDrone.Core/Download/TrackedDownload.cs +++ b/src/NzbDrone.Core/Download/TrackedDownload.cs @@ -12,6 +12,8 @@ namespace NzbDrone.Core.Download public DateTime StartedTracking { get; set; } public DateTime LastRetry { get; set; } public Int32 RetryCount { get; set; } + public Boolean HasError { get; set; } + public String StatusMessage { get; set; } } public enum TrackedDownloadState diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs index f73e9555c..b7d0637b3 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs @@ -82,7 +82,7 @@ namespace NzbDrone.Core.MediaFiles _diskProvider.DeleteFolder(directoryInfo.FullName, true); } - return importedDecisions; + return importedDecisions.Union(decisions).ToList(); } public List ProcessFile(FileInfo fileInfo, DownloadClientItem downloadClientItem) @@ -99,7 +99,7 @@ namespace NzbDrone.Core.MediaFiles var importedDecisions = _importApprovedEpisodes.Import(decisions, true, downloadClientItem); - return importedDecisions; + return importedDecisions.Union(decisions).ToList(); } private void ProcessDownloadedEpisodesFolder() diff --git a/src/NzbDrone.Core/Queue/Queue.cs b/src/NzbDrone.Core/Queue/Queue.cs index c8cde78d4..0cc67c2dc 100644 --- a/src/NzbDrone.Core/Queue/Queue.cs +++ b/src/NzbDrone.Core/Queue/Queue.cs @@ -16,6 +16,7 @@ namespace NzbDrone.Core.Queue public Decimal Sizeleft { get; set; } public TimeSpan? Timeleft { get; set; } public String Status { get; set; } + public String ErrorMessage { get; set; } public RemoteEpisode RemoteEpisode { get; set; } } } diff --git a/src/NzbDrone.Core/Queue/QueueService.cs b/src/NzbDrone.Core/Queue/QueueService.cs index ead4f083c..20b57db4a 100644 --- a/src/NzbDrone.Core/Queue/QueueService.cs +++ b/src/NzbDrone.Core/Queue/QueueService.cs @@ -25,32 +25,37 @@ namespace NzbDrone.Core.Queue public List GetQueue() { var queueItems = _downloadTrackingService.GetQueuedDownloads() - .Select(v => v.DownloadItem) - .OrderBy(v => v.RemainingTime) + .OrderBy(v => v.DownloadItem.RemainingTime) .ToList(); return MapQueue(queueItems); } - private List MapQueue(IEnumerable queueItems) + private List MapQueue(IEnumerable queueItems) { var queued = new List(); foreach (var queueItem in queueItems) { - foreach (var episode in queueItem.RemoteEpisode.Episodes) + foreach (var episode in queueItem.DownloadItem.RemoteEpisode.Episodes) { var queue = new Queue(); - queue.Id = queueItem.DownloadClientId.GetHashCode() + episode.Id; - queue.Series = queueItem.RemoteEpisode.Series; + queue.Id = queueItem.DownloadItem.DownloadClientId.GetHashCode() + episode.Id; + queue.Series = queueItem.DownloadItem.RemoteEpisode.Series; queue.Episode = episode; - queue.Quality = queueItem.RemoteEpisode.ParsedEpisodeInfo.Quality; - queue.Title = queueItem.Title; - queue.Size = queueItem.TotalSize; - queue.Sizeleft = queueItem.RemainingSize; - queue.Timeleft = queueItem.RemainingTime; - queue.Status = queueItem.Status.ToString(); - queue.RemoteEpisode = queueItem.RemoteEpisode; + queue.Quality = queueItem.DownloadItem.RemoteEpisode.ParsedEpisodeInfo.Quality; + queue.Title = queueItem.DownloadItem.Title; + queue.Size = queueItem.DownloadItem.TotalSize; + queue.Sizeleft = queueItem.DownloadItem.RemainingSize; + queue.Timeleft = queueItem.DownloadItem.RemainingTime; + queue.Status = queueItem.DownloadItem.Status.ToString(); + queue.RemoteEpisode = queueItem.DownloadItem.RemoteEpisode; + + if (queueItem.HasError) + { + queue.ErrorMessage = queueItem.StatusMessage; + } + queued.Add(queue); } } diff --git a/src/UI/Content/icons.less b/src/UI/Content/icons.less index 1a6ca1c24..0468bd894 100644 --- a/src/UI/Content/icons.less +++ b/src/UI/Content/icons.less @@ -163,6 +163,11 @@ color : purple; } +.icon-nd-import-failed:before { + .icon(@download-alt); + color: @brand-danger; +} + .icon-nd-download-failed:before { .icon(@cloud-download); color: @brand-danger; diff --git a/src/UI/History/Queue/QueueStatusCell.js b/src/UI/History/Queue/QueueStatusCell.js index 6a8903deb..580d904ce 100644 --- a/src/UI/History/Queue/QueueStatusCell.js +++ b/src/UI/History/Queue/QueueStatusCell.js @@ -13,6 +13,7 @@ define( if (this.cellValue) { var status = this.cellValue.get('status').toLowerCase(); + var errorMessage = (this.cellValue.get('errorMessage') || ''); var icon = 'icon-nd-downloading'; var title = 'Downloading'; @@ -31,7 +32,29 @@ define( title = 'Downloaded'; } - this.$el.html(''.format(icon, title)); + if (errorMessage !== '') { + if (status === 'completed') { + icon = 'icon-nd-import-failed'; + title = "Import failed"; + } + else { + icon = 'icon-nd-download-failed'; + title = "Download failed"; + } + this.$el.html(''.format(icon)); + + this.$el.popover({ + content : errorMessage.replace(new RegExp('\r\n', 'g'), '
'), + html : true, + trigger : 'hover', + title : title, + placement: 'right', + container: this.$el + }); + } + else { + this.$el.html(''.format(icon, title)); + } } return this; diff --git a/src/UI/History/Queue/TimeleftCell.js b/src/UI/History/Queue/TimeleftCell.js index a4d6e4544..cea11f12f 100644 --- a/src/UI/History/Queue/TimeleftCell.js +++ b/src/UI/History/Queue/TimeleftCell.js @@ -22,9 +22,8 @@ define( this.$el.html("-"); } else { - this.$el.html(timeleft); + this.$el.html('{0}'.format(timeleft, remainingSize, totalSize)); } - this.$el.attr('title', '{0} / {1}'.format(remainingSize, totalSize)); } return this; diff --git a/src/UI/History/history.less b/src/UI/History/history.less new file mode 100644 index 000000000..5ed1cd2ee --- /dev/null +++ b/src/UI/History/history.less @@ -0,0 +1,4 @@ + +.queue-status-cell .popover { + max-width: 800px; +} diff --git a/src/UI/System/Logs/Table/LogTimeCell.js b/src/UI/System/Logs/Table/LogTimeCell.js index 20ee23641..dbabc1246 100644 --- a/src/UI/System/Logs/Table/LogTimeCell.js +++ b/src/UI/System/Logs/Table/LogTimeCell.js @@ -11,8 +11,7 @@ define( render: function () { var date = Moment(this._getValue()); - this.$el.html(date.format('LT')); - this.$el.attr('title', date.format('LLLL')); + this.$el.html('{0}'.format(date.format('LT'), date.format('LLLL'))); return this; } diff --git a/src/UI/index.html b/src/UI/index.html index 43eb62eb9..2dcdd72c8 100644 --- a/src/UI/index.html +++ b/src/UI/index.html @@ -12,6 +12,7 @@ +