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