mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-05-09 07:13:59 -04:00
Add sorting and filters for Seeker stats (#576)
This commit is contained in:
@@ -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
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
23
code/backend/Cleanuparr.Domain/Enums/CfScoresSortBy.cs
Normal file
23
code/backend/Cleanuparr.Domain/Enums/CfScoresSortBy.cs
Normal 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,
|
||||
}
|
||||
23
code/backend/Cleanuparr.Domain/Enums/CfUpgradesSortBy.cs
Normal file
23
code/backend/Cleanuparr.Domain/Enums/CfUpgradesSortBy.cs
Normal 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,
|
||||
}
|
||||
17
code/backend/Cleanuparr.Domain/Enums/CutoffFilter.cs
Normal file
17
code/backend/Cleanuparr.Domain/Enums/CutoffFilter.cs
Normal 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,
|
||||
}
|
||||
17
code/backend/Cleanuparr.Domain/Enums/MonitoredFilter.cs
Normal file
17
code/backend/Cleanuparr.Domain/Enums/MonitoredFilter.cs
Normal 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,
|
||||
}
|
||||
19
code/backend/Cleanuparr.Domain/Enums/SearchEventsSortBy.cs
Normal file
19
code/backend/Cleanuparr.Domain/Enums/SearchEventsSortBy.cs
Normal 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,
|
||||
}
|
||||
15
code/backend/Cleanuparr.Domain/Enums/SortDirection.cs
Normal file
15
code/backend/Cleanuparr.Domain/Enums/SortDirection.cs
Normal 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,
|
||||
}
|
||||
@@ -344,6 +344,11 @@ public sealed class CustomFormatScoreSyncer : IHandler
|
||||
CutoffScore = cutoffScore,
|
||||
RecordedAt = now,
|
||||
});
|
||||
|
||||
if (cfScore > existing.CurrentScore)
|
||||
{
|
||||
existing.LastUpgradedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
existing.CurrentScore = cfScore;
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user