Files
Cleanuparr/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs
2026-03-24 10:28:19 +02:00

403 lines
15 KiB
C#

using System.Text.Json;
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Seeker.Controllers;
[ApiController]
[Route("api/seeker/search-stats")]
[Authorize]
public sealed class SearchStatsController : ControllerBase
{
private readonly DataContext _dataContext;
private readonly EventsContext _eventsContext;
public SearchStatsController(DataContext dataContext, EventsContext eventsContext)
{
_dataContext = dataContext;
_eventsContext = eventsContext;
}
/// <summary>
/// Gets aggregate search statistics across all instances.
/// </summary>
[HttpGet("summary")]
public async Task<IActionResult> GetSummary()
{
DateTime sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
DateTime thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
// Event counts from EventsContext
var searchEvents = _eventsContext.Events
.AsNoTracking()
.Where(e => e.EventType == EventType.SearchTriggered);
int totalSearchesAllTime = await searchEvents.CountAsync();
int searchesLast7Days = await searchEvents.CountAsync(e => e.Timestamp >= sevenDaysAgo);
int searchesLast30Days = await searchEvents.CountAsync(e => e.Timestamp >= thirtyDaysAgo);
// History stats from DataContext
int uniqueItemsSearched = await _dataContext.SeekerHistory
.AsNoTracking()
.Select(h => h.ExternalItemId)
.Distinct()
.CountAsync();
int pendingReplacementSearches = await _dataContext.SearchQueue.CountAsync();
// Per-instance stats
List<SeekerInstanceConfig> instanceConfigs = await _dataContext.SeekerInstanceConfigs
.AsNoTracking()
.Include(s => s.ArrInstance)
.ThenInclude(a => a.ArrConfig)
.Where(s => s.Enabled && s.ArrInstance.Enabled)
.ToListAsync();
var historyByInstance = await _dataContext.SeekerHistory
.AsNoTracking()
.GroupBy(h => h.ArrInstanceId)
.Select(g => new
{
InstanceId = g.Key,
ItemsTracked = g.Select(h => h.ExternalItemId).Distinct().Count(),
LastSearchedAt = g.Max(h => h.LastSearchedAt),
TotalSearchCount = g.Sum(h => h.SearchCount),
})
.ToListAsync();
// Count items searched in current cycle per instance
List<Guid> currentRunIds = instanceConfigs.Select(ic => ic.CurrentRunId).ToList();
var cycleItemsByInstance = await _dataContext.SeekerHistory
.AsNoTracking()
.Where(h => currentRunIds.Contains(h.RunId))
.GroupBy(h => h.ArrInstanceId)
.Select(g => new
{
InstanceId = g.Key,
CycleItemsSearched = g.Select(h => h.ExternalItemId).Distinct().Count(),
})
.ToListAsync();
var perInstanceStats = instanceConfigs.Select(ic =>
{
var history = historyByInstance.FirstOrDefault(h => h.InstanceId == ic.ArrInstanceId);
var cycleProgress = cycleItemsByInstance.FirstOrDefault(c => c.InstanceId == ic.ArrInstanceId);
return new InstanceSearchStat
{
InstanceId = ic.ArrInstanceId,
InstanceName = ic.ArrInstance.Name,
InstanceType = ic.ArrInstance.ArrConfig.Type.ToString(),
ItemsTracked = history?.ItemsTracked ?? 0,
TotalSearchCount = history?.TotalSearchCount ?? 0,
LastSearchedAt = history?.LastSearchedAt,
LastProcessedAt = ic.LastProcessedAt,
CurrentRunId = ic.CurrentRunId,
CycleItemsSearched = cycleProgress?.CycleItemsSearched ?? 0,
CycleItemsTotal = ic.TotalEligibleItems,
};
}).ToList();
return Ok(new SearchStatsSummaryResponse
{
TotalSearchesAllTime = totalSearchesAllTime,
SearchesLast7Days = searchesLast7Days,
SearchesLast30Days = searchesLast30Days,
UniqueItemsSearched = uniqueItemsSearched,
PendingReplacementSearches = pendingReplacementSearches,
EnabledInstances = instanceConfigs.Count,
PerInstanceStats = perInstanceStats,
});
}
/// <summary>
/// Gets paginated search history from SeekerHistory.
/// Supports sorting by lastSearched (default) or searchCount.
/// </summary>
[HttpGet("history")]
public async Task<IActionResult> GetHistory(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] Guid? instanceId = null,
[FromQuery] string sortBy = "lastSearched")
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
if (pageSize > 100) pageSize = 100;
var query = _dataContext.SeekerHistory
.AsNoTracking()
.Include(h => h.ArrInstance)
.ThenInclude(a => a.ArrConfig)
.AsQueryable();
if (instanceId.HasValue)
{
query = query.Where(h => h.ArrInstanceId == instanceId.Value);
}
// Group by item across cycles to aggregate search counts
var grouped = query
.GroupBy(h => new { h.ArrInstanceId, h.ExternalItemId, h.ItemType, h.SeasonNumber })
.Select(g => new
{
g.Key.ArrInstanceId,
g.Key.ExternalItemId,
g.Key.SeasonNumber,
TotalSearchCount = g.Sum(h => h.SearchCount),
LastSearchedAt = g.Max(h => h.LastSearchedAt),
// Pick the most recent row's data for display fields
ItemTitle = g.OrderByDescending(h => h.LastSearchedAt).First().ItemTitle,
SearchCount = g.OrderByDescending(h => h.LastSearchedAt).First().SearchCount,
InstanceName = g.OrderByDescending(h => h.LastSearchedAt).First().ArrInstance.Name,
InstanceType = g.OrderByDescending(h => h.LastSearchedAt).First().ArrInstance.ArrConfig.Type,
Id = g.OrderByDescending(h => h.LastSearchedAt).First().Id,
});
int totalCount = await grouped.CountAsync();
var ordered = sortBy switch
{
"searchCount" => grouped.OrderByDescending(g => g.TotalSearchCount),
_ => grouped.OrderByDescending(g => g.LastSearchedAt),
};
var items = await ordered
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(g => new SearchHistoryEntryResponse
{
Id = g.Id,
ArrInstanceId = g.ArrInstanceId,
InstanceName = g.InstanceName,
InstanceType = g.InstanceType.ToString(),
ExternalItemId = g.ExternalItemId,
ItemTitle = g.ItemTitle,
SeasonNumber = g.SeasonNumber,
LastSearchedAt = g.LastSearchedAt,
SearchCount = g.SearchCount,
TotalSearchCount = g.TotalSearchCount,
})
.ToListAsync();
return Ok(new
{
Items = items,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
});
}
/// <summary>
/// Gets paginated search-triggered events with decoded data.
/// </summary>
[HttpGet("events")]
public async Task<IActionResult> GetEvents(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] Guid? instanceId = null,
[FromQuery] Guid? cycleRunId = null)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
if (pageSize > 100) pageSize = 100;
var query = _eventsContext.Events
.AsNoTracking()
.Where(e => e.EventType == EventType.SearchTriggered);
// Filter by instance URL if instanceId provided
if (instanceId.HasValue)
{
var instance = await _dataContext.ArrInstances
.AsNoTracking()
.FirstOrDefaultAsync(a => a.Id == instanceId.Value);
if (instance is not null)
{
string url = (instance.ExternalUrl ?? instance.Url).ToString();
query = query.Where(e => e.InstanceUrl == url);
}
}
// Filter by cycle run ID
if (cycleRunId.HasValue)
{
query = query.Where(e => e.CycleRunId == cycleRunId.Value);
}
int totalCount = await query.CountAsync();
var rawEvents = await query
.OrderByDescending(e => e.Timestamp)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var items = rawEvents.Select(e =>
{
var parsed = ParseEventData(e.Data);
return new SearchEventResponse
{
Id = e.Id,
Timestamp = e.Timestamp,
InstanceName = parsed.InstanceName,
InstanceType = e.InstanceType?.ToString(),
ItemCount = parsed.ItemCount,
Items = parsed.Items,
SearchType = parsed.SearchType,
SearchStatus = e.SearchStatus,
CompletedAt = e.CompletedAt,
GrabbedItems = parsed.GrabbedItems,
CycleRunId = e.CycleRunId,
IsDryRun = e.IsDryRun,
};
}).ToList();
return Ok(new
{
Items = items,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
});
}
/// <summary>
/// Gets individual search events for a specific item.
/// </summary>
[HttpGet("history/{instanceId}/{itemId}/detail")]
public async Task<IActionResult> GetHistoryDetail(
Guid instanceId,
long itemId,
[FromQuery] int seasonNumber = 0)
{
var historyItem = await _dataContext.SeekerHistory
.AsNoTracking()
.Where(h => h.ArrInstanceId == instanceId
&& h.ExternalItemId == itemId
&& h.SeasonNumber == seasonNumber)
.OrderByDescending(h => h.LastSearchedAt)
.FirstOrDefaultAsync();
if (historyItem is null)
{
return Ok(new { Entries = Array.Empty<SearchEventResponse>() });
}
// Build the display name matching the event Items format
string displayName = seasonNumber > 0
? $"{historyItem.ItemTitle} S{seasonNumber:D2}"
: historyItem.ItemTitle;
// Get instance URL for event filtering
var instance = await _dataContext.ArrInstances
.AsNoTracking()
.FirstOrDefaultAsync(a => a.Id == instanceId);
if (instance is null)
{
return Ok(new { Entries = Array.Empty<SearchEventResponse>() });
}
string url = (instance.ExternalUrl ?? instance.Url).ToString();
// Pre-filter events containing the item title, then verify with JSON parsing
var rawEvents = await _eventsContext.Events
.AsNoTracking()
.Where(e => e.EventType == EventType.SearchTriggered
&& e.InstanceUrl == url
&& e.Data != null
&& e.Data.Contains(historyItem.ItemTitle))
.OrderByDescending(e => e.Timestamp)
.Take(100)
.ToListAsync();
var entries = rawEvents
.Select(e =>
{
var parsed = ParseEventData(e.Data);
bool containsItem = parsed.Items.Any(i =>
i.Contains(displayName, StringComparison.OrdinalIgnoreCase)
|| i.Contains(historyItem.ItemTitle, StringComparison.OrdinalIgnoreCase));
return containsItem
? new SearchEventResponse
{
Id = e.Id,
Timestamp = e.Timestamp,
InstanceName = parsed.InstanceName,
InstanceType = e.InstanceType?.ToString(),
ItemCount = parsed.ItemCount,
Items = parsed.Items,
SearchType = parsed.SearchType,
SearchStatus = e.SearchStatus,
CompletedAt = e.CompletedAt,
GrabbedItems = parsed.GrabbedItems,
CycleRunId = e.CycleRunId,
IsDryRun = e.IsDryRun,
}
: null;
})
.Where(e => e is not null)
.ToList();
return Ok(new { Entries = entries });
}
private static (string InstanceName, int ItemCount, List<string> Items, SeekerSearchType SearchType, object? GrabbedItems) ParseEventData(string? data)
{
if (string.IsNullOrWhiteSpace(data))
{
return ("Unknown", 0, [], SeekerSearchType.Proactive, null);
}
try
{
using JsonDocument doc = JsonDocument.Parse(data);
JsonElement root = doc.RootElement;
string instanceName = root.TryGetProperty("InstanceName", out var nameEl)
? nameEl.GetString() ?? "Unknown"
: "Unknown";
int itemCount = root.TryGetProperty("ItemCount", out var countEl)
? countEl.GetInt32()
: 0;
var items = new List<string>();
if (root.TryGetProperty("Items", out var itemsEl) && itemsEl.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in itemsEl.EnumerateArray())
{
string? val = item.GetString();
if (val is not null) items.Add(val);
}
}
SeekerSearchType searchType = root.TryGetProperty("SearchType", out var typeEl)
&& Enum.TryParse<SeekerSearchType>(typeEl.GetString(), out var parsed)
? parsed
: SeekerSearchType.Proactive;
object? grabbedItems = root.TryGetProperty("GrabbedItems", out var grabbedEl)
? JsonSerializer.Deserialize<object>(grabbedEl.GetRawText())
: null;
return (instanceName, itemCount, items, searchType, grabbedItems);
}
catch (JsonException)
{
return ("Unknown", 0, [], SeekerSearchType.Proactive, null);
}
}
}