using System.Text;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Providers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Stats;
///
/// Service for aggregating application statistics
///
public class StatsService : IStatsService
{
private readonly ILogger _logger;
private readonly EventsContext _eventsContext;
private readonly IHealthCheckService _healthCheckService;
private readonly IJobManagementService _jobManagementService;
private readonly IDatabaseProvider _databaseProvider;
public StatsService(
ILogger logger,
EventsContext eventsContext,
IHealthCheckService healthCheckService,
IJobManagementService jobManagementService,
IDatabaseProvider databaseProvider)
{
_logger = logger;
_eventsContext = eventsContext;
_healthCheckService = healthCheckService;
_jobManagementService = jobManagementService;
_databaseProvider = databaseProvider;
}
private static readonly Dictionary StrikeEventToType = new()
{
[EventType.StalledStrike] = StrikeType.Stalled,
[EventType.DownloadingMetadataStrike] = StrikeType.DownloadingMetadata,
[EventType.FailedImportStrike] = StrikeType.FailedImport,
[EventType.SlowSpeedStrike] = StrikeType.SlowSpeed,
[EventType.SlowTimeStrike] = StrikeType.SlowTime,
[EventType.DeadTorrentStrike] = StrikeType.DeadTorrent,
};
private static readonly EventType[] StrikeEventTypes = [.. StrikeEventToType.Keys];
private static readonly DeleteReason[] MalwareReasons =
[
DeleteReason.AllFilesBlocked,
DeleteReason.AtLeastOneFileBlocked,
];
///
public async Task GetStatsV2Async(int hours, bool includeDryRun = false)
{
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddHours(-hours);
Dictionary byType = await MergedCountsAsync(cutoff, e => e.EventType, includeDryRun);
Dictionary bySeverity = await MergedCountsAsync(cutoff, e => e.Severity, includeDryRun);
Dictionary strikesByType = [];
foreach ((EventType eventType, StrikeType strikeType) in StrikeEventToType)
{
int count = byType.GetValueOrDefault(eventType.ToString(), 0);
if (count > 0)
{
strikesByType[strikeType.ToString()] = count;
}
}
return new StatsV2Response
{
Events = new EventV2Stats
{
Total = byType.Values.Sum(),
ByType = byType,
BySeverity = bySeverity,
},
Strikes = new StrikeV2Stats
{
Total = strikesByType.Values.Sum(),
ByType = strikesByType,
Recovered = byType.GetValueOrDefault(EventType.StrikeReset.ToString(), 0),
},
Removals = new RemovalsV2Stats
{
Total = byType.GetValueOrDefault(EventType.QueueItemDeleted.ToString(), 0),
ByReason = await RemovalsByReasonAsync(cutoff, includeDryRun),
},
Cleaned = new CleanedV2Stats
{
Total = byType.GetValueOrDefault(EventType.DownloadCleaned.ToString(), 0),
ByReason = await CleanedByReasonAsync(cutoff, includeDryRun),
},
Searches = await GetSearchStatsAsync(cutoff, byType, includeDryRun),
Jobs = await GetJobV2StatsAsync(cutoff),
Health = GetHealthStats(),
TimeframeHours = hours,
GeneratedAt = DateTimeOffset.UtcNow,
};
}
///
public async Task> GetTimelineAsync(string metric, int hours, TimelineBucketSize? bucket = null, bool includeDryRun = false)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
DateTimeOffset cutoff = now.AddHours(-hours);
TimelineBucketSize size = bucket ?? TimelineBucketing.DefaultFor(hours);
Dictionary counts = await MetricCountsAsync(cutoff, metric, size, includeDryRun);
List series = [];
foreach (DateTimeOffset point in TimelineBucketing.Buckets(cutoff, now, size))
{
series.Add(new TimelineBucketDto { Date = point, Count = counts.GetValueOrDefault(point) });
}
return series;
}
private async Task> MergedCountsAsync(
DateTimeOffset cutoff,
System.Linq.Expressions.Expression> selector,
bool includeDryRun)
where TKey : notnull
{
var grouped = await _eventsContext.Events
.Where(e => e.Timestamp >= cutoff && (includeDryRun || !e.IsDryRun))
.GroupBy(selector)
.Select(g => new { g.Key, Count = g.Count() })
.ToListAsync();
Dictionary counts = [];
foreach (var entry in grouped)
{
string key = entry.Key.ToString() ?? string.Empty;
counts[key] = counts.GetValueOrDefault(key) + entry.Count;
}
return counts;
}
private async Task> RemovalsByReasonAsync(DateTimeOffset cutoff, bool includeDryRun)
{
var grouped = await _eventsContext.Events
.Where(e => e.Timestamp >= cutoff && (includeDryRun || !e.IsDryRun)
&& e.EventType == EventType.QueueItemDeleted
&& e.DeleteReason != null && e.DeleteReason != DeleteReason.None)
.GroupBy(e => e.DeleteReason!.Value)
.Select(g => new { Reason = g.Key, Count = g.Count() })
.ToListAsync();
return grouped.ToDictionary(x => x.Reason.ToString(), x => x.Count);
}
private async Task> CleanedByReasonAsync(DateTimeOffset cutoff, bool includeDryRun)
{
var grouped = await _eventsContext.Events
.Where(e => e.Timestamp >= cutoff && (includeDryRun || !e.IsDryRun)
&& e.EventType == EventType.DownloadCleaned
&& e.CleanReason != null && e.CleanReason != CleanReason.None)
.GroupBy(e => e.CleanReason!.Value)
.Select(g => new { Reason = g.Key, Count = g.Count() })
.ToListAsync();
return grouped.ToDictionary(x => x.Reason.ToString(), x => x.Count);
}
private async Task GetSearchStatsAsync(DateTimeOffset cutoff, Dictionary byType, bool includeDryRun)
{
var rows = await _eventsContext.Events
.Where(e => e.Timestamp >= cutoff && (includeDryRun || !e.IsDryRun)
&& e.EventType == EventType.SearchTriggered)
.Select(e => new { e.SearchStatus, e.SearchReason, GrabbedCount = e.GrabbedItems.Count })
.ToListAsync();
Dictionary statusCounts = rows
.Where(r => r.SearchStatus != null)
.GroupBy(r => r.SearchStatus!.Value)
.ToDictionary(g => g.Key, g => g.Count());
Dictionary byReason = rows
.Where(r => r.SearchReason != null)
.GroupBy(r => r.SearchReason!.Value)
.ToDictionary(g => g.Key.ToString(), g => g.Count());
return new SearchesV2Stats
{
Total = byType.GetValueOrDefault(EventType.SearchTriggered.ToString(), 0),
Completed = statusCounts.GetValueOrDefault(SearchCommandStatus.Completed, 0),
Failed = statusCounts.GetValueOrDefault(SearchCommandStatus.Failed, 0)
+ statusCounts.GetValueOrDefault(SearchCommandStatus.TimedOut, 0),
Grabbed = rows.Sum(r => r.GrabbedCount),
ByReason = byReason,
};
}
private async Task> MetricCountsAsync(DateTimeOffset cutoff, string metric, TimelineBucketSize size, bool includeDryRun)
{
EventType[]? types = metric switch
{
"strikesIssued" => StrikeEventTypes,
"recovered" => [EventType.StrikeReset],
"removed" => [EventType.QueueItemDeleted],
"malwareBlocked" => [EventType.QueueItemDeleted],
_ => null, // "events" or unknown → all types
};
bool malwareOnly = metric == "malwareBlocked";
List