Add sorting and filters for Seeker stats (#576)

This commit is contained in:
Flaminel
2026-04-25 11:57:38 +03:00
committed by GitHub
parent 41ca55d615
commit 02a07d4fa3
56 changed files with 4284 additions and 333 deletions

View File

@@ -70,7 +70,7 @@ public class CustomFormatScoreControllerTests : IDisposable
AddScoreEntry(radarr.Id, 2, "At Cutoff", currentScore: 500, cutoffScore: 500);
AddScoreEntry(radarr.Id, 3, "Above Cutoff", currentScore: 600, cutoffScore: 500);
var result = await _controller.GetCustomFormatScores(hideMet: true);
var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.Below);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
@@ -85,7 +85,7 @@ public class CustomFormatScoreControllerTests : IDisposable
AddScoreEntry(radarr.Id, 2, "Unmonitored Movie", currentScore: 200, cutoffScore: 500, isMonitored: false);
AddScoreEntry(radarr.Id, 3, "Another Monitored", currentScore: 300, cutoffScore: 500, isMonitored: true);
var result = await _controller.GetCustomFormatScores(hideUnmonitored: true);
var result = await _controller.GetCustomFormatScores(monitoredFilter: MonitoredFilter.Monitored);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
@@ -118,7 +118,7 @@ public class CustomFormatScoreControllerTests : IDisposable
AddScoreEntry(radarr.Id, 2, "Newer", currentScore: 200, cutoffScore: 500,
lastSynced: DateTime.UtcNow.AddHours(-1));
var result = await _controller.GetCustomFormatScores(sortBy: "date");
var result = await _controller.GetCustomFormatScores(sortBy: CfScoresSortBy.LastSyncedAt);
var body = GetResponseBody(result);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Newer");
@@ -158,6 +158,94 @@ public class CustomFormatScoreControllerTests : IDisposable
body.GetProperty("Items").GetArrayLength().ShouldBe(3);
}
[Fact]
public async Task GetCustomFormatScores_WithCutoffFilterMet_ExcludesBelowCutoff()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "At", currentScore: 500, cutoffScore: 500);
AddScoreEntry(radarr.Id, 3, "Above", currentScore: 600, cutoffScore: 500);
var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.Met);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
}
[Fact]
public async Task GetCustomFormatScores_WithCutoffFilterAll_IncludesEverything()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "Above", currentScore: 600, cutoffScore: 500);
var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.All);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
}
[Fact]
public async Task GetCustomFormatScores_WithMonitoredFilterUnmonitored_ReturnsOnlyUnmonitored()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "A", currentScore: 100, cutoffScore: 500, isMonitored: true);
AddScoreEntry(radarr.Id, 2, "B", currentScore: 100, cutoffScore: 500, isMonitored: false);
var result = await _controller.GetCustomFormatScores(monitoredFilter: MonitoredFilter.Unmonitored);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("B");
}
[Fact]
public async Task GetCustomFormatScores_WithQualityProfileFilter_ReturnsOnlyMatchingProfile()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "HD Movie", currentScore: 100, cutoffScore: 500, qualityProfileName: "HD");
AddScoreEntry(radarr.Id, 2, "UHD Movie", currentScore: 200, cutoffScore: 500, qualityProfileName: "UHD");
var result = await _controller.GetCustomFormatScores(qualityProfile: "UHD");
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("UHD Movie");
}
[Fact]
public async Task GetCustomFormatScores_WithExplicitSortDirectionAsc_OverridesDefault()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "A", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "B", currentScore: 300, cutoffScore: 500);
// CurrentScore default is descending; overriding with Asc should flip it.
var result = await _controller.GetCustomFormatScores(
sortBy: CfScoresSortBy.CurrentScore,
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
var body = GetResponseBody(result);
var items = body.GetProperty("Items");
items[0].GetProperty("CurrentScore").GetInt32().ShouldBe(100);
items[1].GetProperty("CurrentScore").GetInt32().ShouldBe(300);
}
[Fact]
public async Task GetCustomFormatScores_WithSortByTitleDescending_OrdersReverseAlphabetically()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddScoreEntry(radarr.Id, 1, "Apple", currentScore: 100, cutoffScore: 500);
AddScoreEntry(radarr.Id, 2, "Banana", currentScore: 200, cutoffScore: 500);
var result = await _controller.GetCustomFormatScores(
sortBy: CfScoresSortBy.Title,
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Desc);
var body = GetResponseBody(result);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Banana");
}
#endregion
#region GetRecentUpgrades Tests
@@ -245,6 +333,77 @@ public class CustomFormatScoreControllerTests : IDisposable
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
}
[Fact]
public async Task GetRecentUpgrades_WithUpgradeCrossingWindowBoundary_IsDetected()
{
// CR2: pre-window baseline must still participate so the first in-window
// row can be recognised as an upgrade.
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-10));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-3));
var result = await _controller.GetRecentUpgrades(days: 7);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
var upgrade = body.GetProperty("Items")[0];
upgrade.GetProperty("PreviousScore").GetInt32().ShouldBe(100);
upgrade.GetProperty("NewScore").GetInt32().ShouldBe(200);
}
[Fact]
public async Task GetRecentUpgrades_WithSortByScoreDeltaDescending_OrdersByLargestDelta()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
// Item 1: +50
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-2));
// Item 2: +400
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 500, recordedAt: DateTime.UtcNow.AddDays(-2));
var result = await _controller.GetRecentUpgrades(sortBy: CfUpgradesSortBy.ScoreDelta);
var body = GetResponseBody(result);
var items = body.GetProperty("Items");
items[0].GetProperty("NewScore").GetInt32().ShouldBe(500);
items[1].GetProperty("NewScore").GetInt32().ShouldBe(150);
}
[Fact]
public async Task GetRecentUpgrades_WithSortByTitleAscending_OrdersAlphabetically()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
var result = await _controller.GetRecentUpgrades(sortBy: CfUpgradesSortBy.Title);
var body = GetResponseBody(result);
var items = body.GetProperty("Items");
items[0].GetProperty("Title").GetString().ShouldBe("Item 1");
items[1].GetProperty("Title").GetString().ShouldBe("Item 2");
}
[Fact]
public async Task GetRecentUpgrades_WithSearchFilter_ReturnsMatchingTitlesOnly()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
// AddHistoryEntry titles as "Item {externalItemId}".
AddHistoryEntry(radarr.Id, externalItemId: 42, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 42, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
AddHistoryEntry(radarr.Id, externalItemId: 99, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
AddHistoryEntry(radarr.Id, externalItemId: 99, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
var result = await _controller.GetRecentUpgrades(search: "42");
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Item 42");
}
[Fact]
public async Task GetRecentUpgrades_ReturnsSortedByMostRecentFirst()
{
@@ -333,7 +492,8 @@ public class CustomFormatScoreControllerTests : IDisposable
int cutoffScore,
InstanceType itemType = InstanceType.Radarr,
DateTime? lastSynced = null,
bool isMonitored = true)
bool isMonitored = true,
string qualityProfileName = "HD")
{
_dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
@@ -345,7 +505,7 @@ public class CustomFormatScoreControllerTests : IDisposable
FileId = externalItemId * 10,
CurrentScore = currentScore,
CutoffScore = cutoffScore,
QualityProfileName = "HD",
QualityProfileName = qualityProfileName,
IsMonitored = isMonitored,
LastSyncedAt = lastSynced ?? DateTime.UtcNow
});

View File

@@ -155,6 +155,105 @@ public class SearchStatsControllerTests : IDisposable
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
}
[Fact]
public async Task GetEvents_WithSortByTitleAscending_OrdersAlphabetically()
{
AddSearchEvent(itemTitle: "Charlie");
AddSearchEvent(itemTitle: "Alpha");
AddSearchEvent(itemTitle: "Bravo");
var result = await _controller.GetEvents(
sortBy: SearchEventsSortBy.Title,
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
var body = GetResponseBody(result);
var items = body.GetProperty("Items");
items[0].GetProperty("ItemTitle").GetString().ShouldBe("Alpha");
items[1].GetProperty("ItemTitle").GetString().ShouldBe("Bravo");
items[2].GetProperty("ItemTitle").GetString().ShouldBe("Charlie");
}
[Fact]
public async Task GetEvents_WithSortByTimestampAscending_OldestFirst()
{
AddSearchEvent(itemTitle: "Newest", timestamp: DateTime.UtcNow);
AddSearchEvent(itemTitle: "Oldest", timestamp: DateTime.UtcNow.AddHours(-2));
AddSearchEvent(itemTitle: "Middle", timestamp: DateTime.UtcNow.AddHours(-1));
var result = await _controller.GetEvents(sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
var body = GetResponseBody(result);
var items = body.GetProperty("Items");
items[0].GetProperty("ItemTitle").GetString().ShouldBe("Oldest");
items[2].GetProperty("ItemTitle").GetString().ShouldBe("Newest");
}
[Fact]
public async Task GetEvents_WithSearchStatusFilter_ReturnsOnlyMatchingStatuses()
{
AddSearchEvent(itemTitle: "A", searchStatus: SearchCommandStatus.Completed);
AddSearchEvent(itemTitle: "B", searchStatus: SearchCommandStatus.Failed);
AddSearchEvent(itemTitle: "C", searchStatus: SearchCommandStatus.TimedOut);
var result = await _controller.GetEvents(
searchStatus: [SearchCommandStatus.Completed, SearchCommandStatus.Failed]);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
}
[Fact]
public async Task GetEvents_WithSearchTypeFilter_ReturnsOnlyMatchingType()
{
AddSearchEvent(itemTitle: "Proactive Movie", searchType: SeekerSearchType.Proactive);
AddSearchEvent(itemTitle: "Replacement Movie", searchType: SeekerSearchType.Replacement);
var result = await _controller.GetEvents(searchType: SeekerSearchType.Replacement);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Replacement Movie");
}
[Fact]
public async Task GetEvents_WithSearchReasonFilter_ReturnsOnlyMatchingReason()
{
AddSearchEvent(itemTitle: "Missing", searchReason: SeekerSearchReason.Missing);
AddSearchEvent(itemTitle: "Cutoff", searchReason: SeekerSearchReason.QualityCutoffNotMet);
var result = await _controller.GetEvents(searchReason: SeekerSearchReason.QualityCutoffNotMet);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Cutoff");
}
[Fact]
public async Task GetEvents_WithGrabbedTrue_KeepsOnlyEventsWithGrabbedItems()
{
AddSearchEvent(itemTitle: "With Grabs", grabbedItems: ["movie (2024)"]);
AddSearchEvent(itemTitle: "No Grabs", grabbedItems: []);
var result = await _controller.GetEvents(grabbed: true);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("With Grabs");
}
[Fact]
public async Task GetEvents_WithGrabbedFalse_KeepsOnlyEventsWithoutGrabbedItems()
{
AddSearchEvent(itemTitle: "With Grabs", grabbedItems: ["movie (2024)"]);
AddSearchEvent(itemTitle: "No Grabs", grabbedItems: []);
var result = await _controller.GetEvents(grabbed: false);
var body = GetResponseBody(result);
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("No Grabs");
}
#endregion
#region Helpers
@@ -166,7 +265,8 @@ public class SearchStatsControllerTests : IDisposable
List<string>? grabbedItems = null,
Guid? arrInstanceId = null,
Guid? cycleId = null,
SearchCommandStatus? searchStatus = null)
SearchCommandStatus? searchStatus = null,
DateTime? timestamp = null)
{
var appEvent = new AppEvent
{
@@ -176,7 +276,7 @@ public class SearchStatsControllerTests : IDisposable
ArrInstanceId = arrInstanceId,
CycleId = cycleId,
SearchStatus = searchStatus,
Timestamp = DateTime.UtcNow
Timestamp = timestamp ?? DateTime.UtcNow
};
_eventsContext.Events.Add(appEvent);

View File

@@ -24,6 +24,8 @@ public static class SeekerTestDataFactory
var options = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(connection)
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention()
.Options;
var context = new DataContext(options);
@@ -40,6 +42,8 @@ public static class SeekerTestDataFactory
var options = new DbContextOptionsBuilder<EventsContext>()
.UseSqlite(connection)
.UseLowerCaseNamingConvention()
.UseSnakeCaseNamingConvention()
.Options;
var context = new EventsContext(options);

View File

@@ -17,4 +17,10 @@ public sealed record CustomFormatScoreEntryResponse
public bool IsBelowCutoff { get; init; }
public bool IsMonitored { get; init; }
public DateTime LastSyncedAt { get; init; }
/// <summary>
/// Timestamp at which this item last saw its custom format score strictly
/// exceed the prior recorded score. Null when no upgrade has been recorded.
/// </summary>
public DateTime? LastUpgradedAt { get; init; }
}

View File

@@ -1,7 +1,10 @@
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Seeker.Controllers;
@@ -27,9 +30,12 @@ public sealed class CustomFormatScoreController : ControllerBase
[FromQuery] int pageSize = 50,
[FromQuery] Guid? instanceId = null,
[FromQuery] string? search = null,
[FromQuery] string sortBy = "title",
[FromQuery] bool hideMet = false,
[FromQuery] bool hideUnmonitored = false)
[FromQuery] CfScoresSortBy sortBy = CfScoresSortBy.Title,
[FromQuery] SortDirection? sortDirection = null,
[FromQuery] string? qualityProfile = null,
[FromQuery] InstanceType? itemType = null,
[FromQuery] CutoffFilter cutoffFilter = CutoffFilter.All,
[FromQuery] MonitoredFilter monitoredFilter = MonitoredFilter.All)
{
if (page < 1)
{
@@ -57,24 +63,71 @@ public sealed class CustomFormatScoreController : ControllerBase
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(e => e.Title.ToLower().Contains(search.ToLower()));
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e => EF.Functions.Like(e.Title, pattern));
}
if (hideMet)
if (!string.IsNullOrWhiteSpace(qualityProfile))
{
query = query.Where(e => e.CurrentScore < e.CutoffScore);
query = query.Where(e => e.QualityProfileName == qualityProfile);
}
if (hideUnmonitored)
if (itemType.HasValue)
{
query = query.Where(e => e.IsMonitored);
InstanceType typeValue = itemType.Value;
query = query.Where(e => e.ItemType == typeValue);
}
switch (cutoffFilter)
{
case CutoffFilter.Below:
query = query.Where(e => e.CurrentScore < e.CutoffScore);
break;
case CutoffFilter.Met:
query = query.Where(e => e.CurrentScore >= e.CutoffScore);
break;
}
switch (monitoredFilter)
{
case MonitoredFilter.Monitored:
query = query.Where(e => e.IsMonitored);
break;
case MonitoredFilter.Unmonitored:
query = query.Where(e => !e.IsMonitored);
break;
}
int totalCount = await query.CountAsync();
var items = await (sortBy == "date"
? query.OrderByDescending(e => e.LastSyncedAt)
: query.OrderBy(e => e.Title))
bool ascending = sortDirection.HasValue
? sortDirection.Value == SortDirection.Asc
: DefaultAscendingForScoreSortBy(sortBy);
IOrderedQueryable<CustomFormatScoreEntry> ordered = sortBy switch
{
CfScoresSortBy.CurrentScore => ascending
? query.OrderBy(e => e.CurrentScore)
: query.OrderByDescending(e => e.CurrentScore),
CfScoresSortBy.CutoffScore => ascending
? query.OrderBy(e => e.CutoffScore)
: query.OrderByDescending(e => e.CutoffScore),
CfScoresSortBy.QualityProfile => ascending
? query.OrderBy(e => e.QualityProfileName)
: query.OrderByDescending(e => e.QualityProfileName),
CfScoresSortBy.LastSyncedAt => ascending
? query.OrderBy(e => e.LastSyncedAt)
: query.OrderByDescending(e => e.LastSyncedAt),
CfScoresSortBy.LastUpgradedAt => ascending
? query.OrderBy(e => e.LastUpgradedAt)
: query.OrderByDescending(e => e.LastUpgradedAt),
_ => ascending
? query.OrderBy(e => e.Title)
: query.OrderByDescending(e => e.Title),
};
var items = await ordered
.ThenBy(e => e.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(e => new CustomFormatScoreEntryResponse
@@ -92,6 +145,7 @@ public sealed class CustomFormatScoreController : ControllerBase
IsBelowCutoff = e.CurrentScore < e.CutoffScore,
IsMonitored = e.IsMonitored,
LastSyncedAt = e.LastSyncedAt,
LastUpgradedAt = e.LastUpgradedAt,
})
.ToListAsync();
@@ -105,73 +159,139 @@ public sealed class CustomFormatScoreController : ControllerBase
});
}
private static bool DefaultAscendingForScoreSortBy(CfScoresSortBy sortBy)
{
// Default directions match user expectations:
// - textual fields sort ascending (A→Z)
// - numeric/date fields sort descending (most recent / highest first)
return sortBy switch
{
CfScoresSortBy.CurrentScore => false,
CfScoresSortBy.CutoffScore => false,
CfScoresSortBy.LastSyncedAt => false,
CfScoresSortBy.LastUpgradedAt => false,
_ => true,
};
}
/// <summary>
/// Gets recent CF score upgrades (where score improved in history).
/// Gets recent CF score upgrades (where score strictly exceeded the prior recorded score).
/// </summary>
/// <remarks>
/// Upgrade detection runs in SQL via <c>LAG()</c> over the full per-item history so
/// an improvement crossing the <paramref name="days"/> window boundary is still
/// detected. Sorting and pagination happen at the database level.
/// </remarks>
[HttpGet("upgrades")]
public async Task<IActionResult> GetRecentUpgrades(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] Guid? instanceId = null,
[FromQuery] int days = 30)
[FromQuery] int days = 30,
[FromQuery] string? search = null,
[FromQuery] CfUpgradesSortBy sortBy = CfUpgradesSortBy.UpgradedAt,
[FromQuery] SortDirection? sortDirection = null)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
if (pageSize > 500) pageSize = 500;
// Find history entries where a newer entry has a higher score than an older one
// We group by item and look for score increases between consecutive records
var query = _dataContext.CustomFormatScoreHistory
.AsNoTracking()
.AsQueryable();
bool ascending = sortDirection.HasValue
? sortDirection.Value == SortDirection.Asc
: DefaultAscendingForUpgradeSortBy(sortBy);
if (instanceId.HasValue)
string orderByClause = BuildUpgradeOrderByClause(sortBy, ascending);
DateTime? cutoff = days > 0 ? DateTime.UtcNow.AddDays(-days) : null;
string? searchPattern = string.IsNullOrWhiteSpace(search)
? null
: EventsContext.GetLikePattern(search);
const string upgradesCte =
"""
WITH scored AS (
SELECT
arr_instance_id,
external_item_id,
episode_id,
item_type,
title,
score,
cutoff_score,
recorded_at,
LAG(score) OVER (
PARTITION BY arr_instance_id, external_item_id, episode_id
ORDER BY recorded_at
) AS prev_score
FROM custom_format_score_history
),
upgrades AS (
SELECT
arr_instance_id AS arr_instance_id,
external_item_id AS external_item_id,
episode_id AS episode_id,
item_type AS item_type,
title AS title,
prev_score AS previous_score,
score AS new_score,
cutoff_score AS cutoff_score,
recorded_at AS upgraded_at
FROM scored
WHERE prev_score IS NOT NULL AND score > prev_score
)
""";
const string filterClause =
"""
WHERE (@instanceId IS NULL OR arr_instance_id = @instanceId)
AND (@search IS NULL OR title LIKE @search ESCAPE '\')
AND (@cutoff IS NULL OR upgraded_at >= @cutoff)
""";
SqliteParameter[] BuildCommonParameters() => new[]
{
query = query.Where(h => h.ArrInstanceId == instanceId.Value);
}
new SqliteParameter("@instanceId", instanceId.HasValue ? instanceId.Value : DBNull.Value),
new SqliteParameter("@search", (object?)searchPattern ?? DBNull.Value),
new SqliteParameter("@cutoff", (object?)cutoff ?? DBNull.Value),
};
var allHistory = await query
.Where(h => h.RecordedAt >= DateTime.UtcNow.AddDays(-days))
.OrderByDescending(h => h.RecordedAt)
string listSql =
$"""
{upgradesCte}
SELECT * FROM upgrades
{filterClause}
ORDER BY {orderByClause}, upgraded_at DESC
LIMIT @take OFFSET @skip
""";
SqliteParameter[] listParams =
[
..BuildCommonParameters(),
new("@take", pageSize),
new("@skip", (page - 1) * pageSize),
];
var rows = await _dataContext.Database
.SqlQueryRaw<UpgradeSqlRow>(listSql, listParams)
.ToListAsync();
var upgrades = new List<CustomFormatScoreUpgradeResponse>();
string countSql = $"{upgradesCte} SELECT COUNT(*) AS value FROM upgrades {filterClause}";
int totalCount = await _dataContext.Database
.SqlQueryRaw<int>(countSql, BuildCommonParameters())
.FirstAsync();
// Group by (ArrInstanceId, ExternalItemId, EpisodeId) and find score increases
var grouped = allHistory
.GroupBy(h => new { h.ArrInstanceId, h.ExternalItemId, h.EpisodeId });
foreach (var group in grouped)
var paged = rows.Select(r => new CustomFormatScoreUpgradeResponse
{
var entries = group.OrderBy(h => h.RecordedAt).ToList();
for (int i = 1; i < entries.Count; i++)
{
if (entries[i].Score > entries[i - 1].Score)
{
upgrades.Add(new CustomFormatScoreUpgradeResponse
{
ArrInstanceId = entries[i].ArrInstanceId,
ExternalItemId = entries[i].ExternalItemId,
EpisodeId = entries[i].EpisodeId,
ItemType = entries[i].ItemType,
Title = entries[i].Title,
PreviousScore = entries[i - 1].Score,
NewScore = entries[i].Score,
CutoffScore = entries[i].CutoffScore,
UpgradedAt = entries[i].RecordedAt,
});
}
}
}
// Sort by most recent upgrade first
upgrades = upgrades.OrderByDescending(u => u.UpgradedAt).ToList();
int totalCount = upgrades.Count;
var paged = upgrades
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
ArrInstanceId = r.ArrInstanceId,
ExternalItemId = r.ExternalItemId,
EpisodeId = r.EpisodeId,
ItemType = Enum.Parse<InstanceType>(r.ItemType, ignoreCase: true),
Title = r.Title,
PreviousScore = r.PreviousScore,
NewScore = r.NewScore,
CutoffScore = r.CutoffScore,
UpgradedAt = DateTime.SpecifyKind(r.UpgradedAt, DateTimeKind.Utc),
}).ToList();
return Ok(new
{
@@ -183,13 +303,52 @@ public sealed class CustomFormatScoreController : ControllerBase
});
}
private static bool DefaultAscendingForUpgradeSortBy(CfUpgradesSortBy sortBy)
{
return sortBy switch
{
CfUpgradesSortBy.Title => true,
_ => false,
};
}
private static string BuildUpgradeOrderByClause(CfUpgradesSortBy sortBy, bool ascending)
{
string column = sortBy switch
{
CfUpgradesSortBy.Title => "LOWER(title)",
CfUpgradesSortBy.NewScore => "new_score",
CfUpgradesSortBy.PreviousScore => "previous_score",
CfUpgradesSortBy.ScoreDelta => "(new_score - previous_score)",
CfUpgradesSortBy.CutoffScore => "cutoff_score",
_ => "upgraded_at",
};
return $"{column} {(ascending ? "ASC" : "DESC")}";
}
private sealed class UpgradeSqlRow
{
public Guid ArrInstanceId { get; set; }
public long ExternalItemId { get; set; }
public long EpisodeId { get; set; }
public string ItemType { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public int PreviousScore { get; set; }
public int NewScore { get; set; }
public int CutoffScore { get; set; }
public DateTime UpgradedAt { get; set; }
}
/// <summary>
/// Gets the *arr instances that currently have tracked CF scores, along with
/// the set of quality profile names observed for each instance. Used to
/// populate instance and profile filter controls.
/// </summary>
[HttpGet("instances")]
public async Task<IActionResult> GetInstances()
{
var instances = await _dataContext.CustomFormatScoreEntries
var raw = await _dataContext.CustomFormatScoreEntries
.AsNoTracking()
.Select(e => new { e.ArrInstanceId, e.ItemType })
.Distinct()
.Join(
_dataContext.ArrInstances.AsNoTracking(),
e => e.ArrInstanceId,
@@ -199,10 +358,28 @@ public sealed class CustomFormatScoreController : ControllerBase
Id = e.ArrInstanceId,
a.Name,
e.ItemType,
e.QualityProfileName,
})
.OrderBy(x => x.Name)
.Distinct()
.ToListAsync();
var instances = raw
.GroupBy(x => new { x.Id, x.Name, x.ItemType })
.Select(g => new
{
g.Key.Id,
g.Key.Name,
ItemType = g.Key.ItemType,
QualityProfiles = g
.Select(x => x.QualityProfileName)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
.ToList(),
})
.OrderBy(x => x.Name)
.ToList();
return Ok(new { Instances = instances });
}

View File

@@ -3,6 +3,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Cleanuparr.Persistence.Models.State;
using AppEvent = Cleanuparr.Persistence.Models.Events.AppEvent;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -117,15 +118,34 @@ public sealed class SearchStatsController : ControllerBase
}
/// <summary>
/// Gets paginated search-triggered events
/// Gets paginated search-triggered events with optional filtering and sorting.
/// Results default to newest-first by timestamp. Ties on non-timestamp sort keys
/// fall back to <c>Timestamp</c> descending for stable ordering.
/// </summary>
/// <param name="page">1-based page number. Clamped to at least 1.</param>
/// <param name="pageSize">Rows per page. Clamped to the inclusive range [1, 100]; defaults to 50.</param>
/// <param name="instanceId">When set, restricts results to events produced by this *arr instance.</param>
/// <param name="cycleId">When set, restricts results to events from this seeker cycle.</param>
/// <param name="search">Case-insensitive substring match against the stored item title.</param>
/// <param name="sortBy">Primary sort column. Defaults to <see cref="SearchEventsSortBy.Timestamp"/>.</param>
/// <param name="sortDirection">Sort direction for the primary column. Defaults to descending.</param>
/// <param name="searchStatus">When supplied, keeps only events whose <see cref="SearchCommandStatus"/> appears in this list.</param>
/// <param name="searchType">When supplied, keeps only events matching this <see cref="SeekerSearchType"/>.</param>
/// <param name="searchReason">When supplied, keeps only events matching this <see cref="SeekerSearchReason"/>.</param>
/// <param name="grabbed">When <c>true</c>, keeps only events that recorded at least one grabbed item; when <c>false</c>, keeps only events with none.</param>
[HttpGet("events")]
public async Task<IActionResult> GetEvents(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] Guid? instanceId = null,
[FromQuery] Guid? cycleId = null,
[FromQuery] string? search = null)
[FromQuery] string? search = null,
[FromQuery] SearchEventsSortBy sortBy = SearchEventsSortBy.Timestamp,
[FromQuery] SortDirection sortDirection = SortDirection.Desc,
[FromQuery] SearchCommandStatus[]? searchStatus = null,
[FromQuery] SeekerSearchType? searchType = null,
[FromQuery] SeekerSearchReason? searchReason = null,
[FromQuery] bool? grabbed = null)
{
if (page < 1)
{
@@ -167,10 +187,65 @@ public sealed class SearchStatsController : ControllerBase
&& EF.Functions.Like(e.SearchEventData.ItemTitle, pattern));
}
// Filter by search status (multi-valued)
if (searchStatus is { Length: > 0 })
{
SearchCommandStatus[] statuses = searchStatus.Distinct().ToArray();
query = query.Where(e => e.SearchStatus.HasValue && statuses.Contains(e.SearchStatus.Value));
}
if (searchType.HasValue)
{
SeekerSearchType typeValue = searchType.Value;
query = query.Where(e => e.SearchEventData != null && e.SearchEventData.SearchType == typeValue);
}
if (searchReason.HasValue)
{
SeekerSearchReason reasonValue = searchReason.Value;
query = query.Where(e => e.SearchEventData != null && e.SearchEventData.SearchReason == reasonValue);
}
// Filter by grabbed-result presence
if (grabbed.HasValue)
{
if (grabbed.Value)
{
query = query.Where(e => e.SearchEventData != null && e.SearchEventData.GrabbedItems.Count > 0);
}
else
{
query = query.Where(e => e.SearchEventData == null || e.SearchEventData.GrabbedItems.Count == 0);
}
}
int totalCount = await query.CountAsync();
var rawEvents = await query
.OrderByDescending(e => e.Timestamp)
bool ascending = sortDirection == SortDirection.Asc;
IOrderedQueryable<AppEvent> ordered = sortBy switch
{
SearchEventsSortBy.Title => ascending
? query.OrderBy(e => e.SearchEventData != null ? e.SearchEventData.ItemTitle : string.Empty)
: query.OrderByDescending(e => e.SearchEventData != null ? e.SearchEventData.ItemTitle : string.Empty),
SearchEventsSortBy.Status => ascending
? query.OrderBy(e => e.SearchStatus)
: query.OrderByDescending(e => e.SearchStatus),
SearchEventsSortBy.Type => ascending
? query.OrderBy(e => e.SearchEventData != null ? (int)e.SearchEventData.SearchType : 0)
: query.OrderByDescending(e => e.SearchEventData != null ? (int)e.SearchEventData.SearchType : 0),
_ => ascending
? query.OrderBy(e => e.Timestamp)
: query.OrderByDescending(e => e.Timestamp),
};
// Secondary sort by timestamp desc for stable ordering when primary ties
if (sortBy != SearchEventsSortBy.Timestamp)
{
ordered = ordered.ThenByDescending(e => e.Timestamp);
}
var rawEvents = await ordered
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();

View File

@@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
/// <summary>
/// Sorting fields available for custom format score listings.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CfScoresSortBy
{
/// <summary>Sort by item title.</summary>
Title,
/// <summary>Sort by the item's current custom format score.</summary>
CurrentScore,
/// <summary>Sort by the quality profile's configured cutoff score.</summary>
CutoffScore,
/// <summary>Sort by quality profile name.</summary>
QualityProfile,
/// <summary>Sort by the timestamp of the last score sync.</summary>
LastSyncedAt,
/// <summary>Sort by the timestamp of the most recent score upgrade.</summary>
LastUpgradedAt,
}

View File

@@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
/// <summary>
/// Sorting fields available for custom format score upgrades.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CfUpgradesSortBy
{
/// <summary>Sort by the timestamp at which the upgrade was recorded.</summary>
UpgradedAt,
/// <summary>Sort by item title.</summary>
Title,
/// <summary>Sort by the score recorded after the upgrade.</summary>
NewScore,
/// <summary>Sort by the score recorded immediately before the upgrade.</summary>
PreviousScore,
/// <summary>Sort by the difference between the new and previous scores.</summary>
ScoreDelta,
/// <summary>Sort by the quality profile's configured cutoff score.</summary>
CutoffScore,
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
/// <summary>
/// Filters custom format score rows by their relation to the configured quality cutoff.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CutoffFilter
{
/// <summary>Include all items regardless of cutoff status.</summary>
All,
/// <summary>Include only items whose current score is below the cutoff.</summary>
Below,
/// <summary>Include only items whose current score meets or exceeds the cutoff.</summary>
Met,
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
/// <summary>
/// Filters items by their monitored state in the source *arr application.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum MonitoredFilter
{
/// <summary>Include both monitored and unmonitored items.</summary>
All,
/// <summary>Include only monitored items.</summary>
Monitored,
/// <summary>Include only unmonitored items.</summary>
Unmonitored,
}

View File

@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
/// <summary>
/// Sorting fields available for search event listings.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SearchEventsSortBy
{
/// <summary>Sort by the event timestamp.</summary>
Timestamp,
/// <summary>Sort by the item title associated with the search.</summary>
Title,
/// <summary>Sort by the search command status.</summary>
Status,
/// <summary>Sort by the search type (proactive, replacement, etc.).</summary>
Type,
}

View File

@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
/// <summary>
/// Direction used when ordering a result set.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SortDirection
{
/// <summary>Ascending order.</summary>
Asc,
/// <summary>Descending order.</summary>
Desc,
}

View File

@@ -344,6 +344,11 @@ public sealed class CustomFormatScoreSyncer : IHandler
CutoffScore = cutoffScore,
RecordedAt = now,
});
if (cfScore > existing.CurrentScore)
{
existing.LastUpgradedAt = now;
}
}
existing.CurrentScore = cfScore;

View File

@@ -277,8 +277,10 @@ public class DataContext : DbContext
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(s => new { s.ArrInstanceId, s.ExternalItemId, s.EpisodeId }).IsUnique();
entity.HasIndex(s => s.LastUpgradedAt);
entity.Property(s => s.LastSyncedAt).HasConversion(new UtcDateTimeConverter());
entity.Property(s => s.LastUpgradedAt).HasConversion(new UtcDateTimeConverter());
});
modelBuilder.Entity<CustomFormatScoreHistory>(entity =>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddLastUpgradedAtToCfScoreEntry : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "last_upgraded_at",
table: "custom_format_score_entries",
type: "TEXT",
nullable: true);
// Backfill last_upgraded_at from existing history: per item, the most recent
// recorded_at at which the score strictly exceeded the preceding score.
migrationBuilder.Sql(@"
WITH scored AS (
SELECT
arr_instance_id,
external_item_id,
episode_id,
score,
recorded_at,
LAG(score) OVER (
PARTITION BY arr_instance_id, external_item_id, episode_id
ORDER BY recorded_at
) AS prev_score
FROM custom_format_score_history
),
upgrades AS (
SELECT
arr_instance_id,
external_item_id,
episode_id,
MAX(recorded_at) AS last_upgraded_at
FROM scored
WHERE prev_score IS NOT NULL AND score > prev_score
GROUP BY arr_instance_id, external_item_id, episode_id
)
UPDATE custom_format_score_entries
SET last_upgraded_at = (
SELECT last_upgraded_at FROM upgrades u
WHERE u.arr_instance_id = custom_format_score_entries.arr_instance_id
AND u.external_item_id = custom_format_score_entries.external_item_id
AND u.episode_id = custom_format_score_entries.episode_id
);
");
migrationBuilder.CreateIndex(
name: "ix_custom_format_score_entries_last_upgraded_at",
table: "custom_format_score_entries",
column: "last_upgraded_at");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_custom_format_score_entries_last_upgraded_at",
table: "custom_format_score_entries");
migrationBuilder.DropColumn(
name: "last_upgraded_at",
table: "custom_format_score_entries");
}
}
}

View File

@@ -1569,6 +1569,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("last_synced_at");
b.Property<DateTime?>("LastUpgradedAt")
.HasColumnType("TEXT")
.HasColumnName("last_upgraded_at");
b.Property<string>("QualityProfileName")
.IsRequired()
.HasColumnType("TEXT")
@@ -1582,6 +1586,9 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.HasKey("Id")
.HasName("pk_custom_format_score_entries");
b.HasIndex("LastUpgradedAt")
.HasDatabaseName("ix_custom_format_score_entries_last_upgraded_at");
b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId")
.IsUnique()
.HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id");

View File

@@ -74,4 +74,10 @@ public sealed record CustomFormatScoreEntry
/// When this entry was last synced from the arr API
/// </summary>
public DateTime LastSyncedAt { get; set; }
/// <summary>
/// When this item last saw a score upgrade (current score strictly exceeded the prior recorded score).
/// Null when the item has no recorded upgrades.
/// </summary>
public DateTime? LastUpgradedAt { get; set; }
}