diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index 4dbb4aadd..3c8ae994c 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -230,6 +230,7 @@ + @@ -278,4 +279,4 @@ --> - + \ No newline at end of file diff --git a/src/NzbDrone.Api/System/Statistics/StatisticsModule.cs b/src/NzbDrone.Api/System/Statistics/StatisticsModule.cs new file mode 100644 index 000000000..b31187c38 --- /dev/null +++ b/src/NzbDrone.Api/System/Statistics/StatisticsModule.cs @@ -0,0 +1,62 @@ +using System; +using System.Linq; +using Nancy; +using Nancy.Routing; +using NLog; +using NzbDrone.Api.Extensions; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.History; +using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Statistics; + +namespace NzbDrone.Api.System +{ + public class StatisticsModule : NzbDroneApiModule + { + private readonly IStatisticsService _statisticsService; + private readonly Logger _logger; + + public StatisticsModule(IStatisticsService statisticsService, Logger logger) + : base("system/statistics") + { + _statisticsService = statisticsService; + _logger = logger; + + Get["/"] = x => GetGlobalStatistics(); + Get["/indexer"] = x => GetIndexerStatistics(); + } + + private Response GetGlobalStatistics() + { + return new + { + Generated = DateTime.UtcNow, + Uptime = GetUpTime(), + History = _statisticsService.GetGlobalStatistics() + }.AsResponse(); + } + + private Response GetIndexerStatistics() + { + var stats = _statisticsService.GetIndexerStatistics(); + + return stats.AsResponse(); + } + + private TimeSpan? GetUpTime() + { + try + { + return DateTime.Now - global::System.Diagnostics.Process.GetCurrentProcess().StartTime; + } + catch (Exception ex) + { + _logger.DebugException("Failed to get uptime", ex); + return null; + } + } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 397c8a21f..d72e546c7 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -954,6 +954,9 @@ + + + @@ -1118,4 +1121,4 @@ --> - + \ No newline at end of file diff --git a/src/NzbDrone.Core/Statistics/HistoryStatistics.cs b/src/NzbDrone.Core/Statistics/HistoryStatistics.cs new file mode 100644 index 000000000..9d198fb42 --- /dev/null +++ b/src/NzbDrone.Core/Statistics/HistoryStatistics.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Statistics +{ + public class HistoryStatistics + { + public int Grabs { get; set; } + public int Replaced { get; set; } + public int Failed { get; set; } + public int Imported { get; set; } + } +} diff --git a/src/NzbDrone.Core/Statistics/StatisticsGrouping.cs b/src/NzbDrone.Core/Statistics/StatisticsGrouping.cs new file mode 100644 index 000000000..802a1173f --- /dev/null +++ b/src/NzbDrone.Core/Statistics/StatisticsGrouping.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Statistics +{ + public class StatisticsGrouping where T : new() + { + public T LastWeek { get; set; } + public T LastMonth { get; set; } + public T AllTime { get; set; } + + public StatisticsGrouping() + { + LastWeek = new T(); + LastMonth = new T(); + AllTime = new T(); + } + + public void Apply(DateTime date, Action applyAction) + { + var elapsed = DateTime.UtcNow - date.ToUniversalTime(); + + if (elapsed < TimeSpan.FromDays(7)) + { + applyAction(LastWeek); + } + + if (elapsed < TimeSpan.FromDays(7 * 4)) + { + applyAction(LastMonth); + } + + applyAction(AllTime); + } + } +} diff --git a/src/NzbDrone.Core/Statistics/StatisticsService.cs b/src/NzbDrone.Core/Statistics/StatisticsService.cs new file mode 100644 index 000000000..df2d52541 --- /dev/null +++ b/src/NzbDrone.Core/Statistics/StatisticsService.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.History; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Statistics +{ + public interface IStatisticsService + { + StatisticsGrouping GetGlobalStatistics(); + Dictionary> GetIndexerStatistics(); + } + + public class StatisticsService : IStatisticsService + { + private readonly IHistoryRepository _historyRepository; + + public StatisticsService(IHistoryRepository historyRepository) + { + _historyRepository = historyRepository; + } + + public StatisticsGrouping GetGlobalStatistics() + { + var stats = new StatisticsGrouping(); + + foreach (var group in GetIndexerStatistics().Values) + { + stats.LastWeek.Grabs += group.LastWeek.Grabs; + stats.LastWeek.Replaced += group.LastWeek.Replaced; + stats.LastWeek.Failed += group.LastWeek.Failed; + stats.LastWeek.Imported += group.LastWeek.Imported; + + stats.LastMonth.Grabs += group.LastMonth.Grabs; + stats.LastMonth.Replaced += group.LastMonth.Replaced; + stats.LastMonth.Failed += group.LastMonth.Failed; + stats.LastMonth.Imported += group.LastMonth.Imported; + + stats.AllTime.Grabs += group.AllTime.Grabs; + stats.AllTime.Replaced += group.AllTime.Replaced; + stats.AllTime.Failed += group.AllTime.Failed; + stats.AllTime.Imported += group.AllTime.Imported; + } + + return stats; + } + + public Dictionary> GetIndexerStatistics() + { + var all = _historyRepository.All().ToArray(); + + var stats = new Dictionary>(); + stats[string.Empty] = new StatisticsGrouping(); + + var groupedByEpisode = all.GroupBy(v => v.EpisodeId).ToArray(); + + foreach (var episode in groupedByEpisode) + { + var sortedEvents = episode.OrderBy(v => v.DownloadId) + .ThenBy(v => v.Date) + .ThenBy(v => v.Id) + .ToArray(); + + var lastEvent = HistoryEventType.Unknown; + string grabIndexer = null; + string importIndexer = null; + + foreach (var historyEvent in sortedEvents) + { + switch (historyEvent.EventType) + { + // Episode got grabbed from a specific indexer. Attribute anything that happens to that indexer. + case History.HistoryEventType.Grabbed: + grabIndexer = historyEvent.Data.GetValueOrDefault("indexer") ?? string.Empty; + Apply(stats, grabIndexer, historyEvent.Date, s => s.Grabs++); + lastEvent = HistoryEventType.Grabbed; + break; + + // Episodes got imported, only attribute the import if we grabbed it from an indexer. + // Try attribute the deletion/replacement to the previous indexer. + case History.HistoryEventType.SeriesFolderImported: + case History.HistoryEventType.DownloadFolderImported: + if (lastEvent == HistoryEventType.Grabbed) + { + if (importIndexer != null) + { + Apply(stats, importIndexer, historyEvent.Date, s => s.Replaced++); + } + importIndexer = grabIndexer; + grabIndexer = null; + Apply(stats, importIndexer, historyEvent.Date, s => s.Imported++); + lastEvent = HistoryEventType.DownloadFolderImported; + } + else + { + lastEvent = HistoryEventType.Unknown; + } + break; + + // Attribute the failure to the indexer if we haven't imported yet. + case History.HistoryEventType.DownloadFailed: + if (lastEvent == HistoryEventType.Grabbed) + { + Apply(stats, grabIndexer, historyEvent.Date, s => s.Failed++); + grabIndexer = null; + lastEvent = HistoryEventType.Unknown; + } + break; + + case History.HistoryEventType.EpisodeFileDeleted: + lastEvent = HistoryEventType.Unknown; + break; + + case History.HistoryEventType.Unknown: + default: + break; + } + } + } + + return stats; + } + + private void Apply(Dictionary> stats, string key, DateTime date, Action applyAction) where T : new() + { + StatisticsGrouping group; + + if (!stats.TryGetValue(key, out group)) + { + stats[key] = group = new StatisticsGrouping(); + } + + group.Apply(date, applyAction); + } + } +} \ No newline at end of file