diff --git a/README.md b/README.md
index 9b2343d1..f6d27e80 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,9 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
> - Remove and block known malware based on patterns found by the community.
> - Automatically trigger a search for downloads removed from the arrs.
+> - Proactively search for **missing** items across your Radarr and Sonarr libraries.
+> - Search for **quality upgrades** for items that haven't met their quality profile's cutoff (a.k.a. **Cutoff Unmet**).
+> - Search for **custom format score upgrades** with automatic score tracking.
> - Clean up downloads that have been **seeding** for a certain amount of time.
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
> - Notify on strike or download removal.
diff --git a/code/backend/Cleanuparr.Api.Tests/Cleanuparr.Api.Tests.csproj b/code/backend/Cleanuparr.Api.Tests/Cleanuparr.Api.Tests.csproj
index c8e2aa6c..ac19d037 100644
--- a/code/backend/Cleanuparr.Api.Tests/Cleanuparr.Api.Tests.csproj
+++ b/code/backend/Cleanuparr.Api.Tests/Cleanuparr.Api.Tests.csproj
@@ -11,6 +11,7 @@
+
diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Auth/LoginTimingTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Auth/LoginTimingTests.cs
index 351ca5df..01002cf0 100644
--- a/code/backend/Cleanuparr.Api.Tests/Features/Auth/LoginTimingTests.cs
+++ b/code/backend/Cleanuparr.Api.Tests/Features/Auth/LoginTimingTests.cs
@@ -1,6 +1,9 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
+using Cleanuparr.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Auth;
@@ -83,14 +86,14 @@ public class LoginTimingTests : IClassFixture
[Fact, TestPriority(4)]
public async Task Login_LockedOutUser_StillCallsPasswordVerification()
{
- // Trigger lockout by making several failed login attempts
- for (var i = 0; i < 5; i++)
+ // Set lockout state directly in the database to avoid timing sensitivity
+ using (var scope = _factory.Services.CreateScope())
{
- await _client.PostAsJsonAsync("/api/auth/login", new
- {
- username = "timingtest",
- password = "WrongPassword!"
- });
+ var context = scope.ServiceProvider.GetRequiredService();
+ var user = await context.Users.FirstAsync();
+ user.FailedLoginAttempts = 5;
+ user.LockoutEnd = DateTime.UtcNow.AddMinutes(5);
+ await context.SaveChangesAsync();
}
_factory.TrackingPasswordService.Reset();
@@ -103,6 +106,16 @@ public class LoginTimingTests : IClassFixture
response.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
+
+ // Reset lockout for subsequent tests
+ using (var scope = _factory.Services.CreateScope())
+ {
+ var context = scope.ServiceProvider.GetRequiredService();
+ var user = await context.Users.FirstAsync();
+ user.FailedLoginAttempts = 0;
+ user.LockoutEnd = null;
+ await context.SaveChangesAsync();
+ }
}
[Fact, TestPriority(5)]
diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/CustomFormatScoreControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/CustomFormatScoreControllerTests.cs
new file mode 100644
index 00000000..f5ecc457
--- /dev/null
+++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/CustomFormatScoreControllerTests.cs
@@ -0,0 +1,359 @@
+using System.Text.Json;
+using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
+using Cleanuparr.Api.Features.Seeker.Controllers;
+using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
+using Cleanuparr.Domain.Enums;
+using Cleanuparr.Persistence;
+using Cleanuparr.Persistence.Models.State;
+using Microsoft.AspNetCore.Mvc;
+using Shouldly;
+
+namespace Cleanuparr.Api.Tests.Features.Seeker;
+
+public class CustomFormatScoreControllerTests : IDisposable
+{
+ private readonly DataContext _dataContext;
+ private readonly CustomFormatScoreController _controller;
+
+ public CustomFormatScoreControllerTests()
+ {
+ _dataContext = SeekerTestDataFactory.CreateDataContext();
+ _controller = new CustomFormatScoreController(_dataContext);
+ }
+
+ public void Dispose()
+ {
+ _dataContext.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ private static JsonElement GetResponseBody(IActionResult result)
+ {
+ var okResult = result.ShouldBeOfType();
+ var json = JsonSerializer.Serialize(okResult.Value);
+ return JsonDocument.Parse(json).RootElement;
+ }
+
+ #region GetCustomFormatScores Tests
+
+ [Fact]
+ public async Task GetCustomFormatScores_WithPageBelowMinimum_ClampsToOne()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
+ AddScoreEntry(radarr.Id, 2, "Movie B", currentScore: 200, cutoffScore: 500);
+
+ var result = await _controller.GetCustomFormatScores(page: -5, pageSize: 50);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("Page").GetInt32().ShouldBe(1);
+ body.GetProperty("Items").GetArrayLength().ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task GetCustomFormatScores_WithPageSizeAboveMaximum_ClampsToHundred()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
+
+ var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 999);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("PageSize").GetInt32().ShouldBe(100);
+ }
+
+ [Fact]
+ public async Task GetCustomFormatScores_WithHideMetTrue_ExcludesItemsAtOrAboveCutoff()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "Below Cutoff", currentScore: 100, cutoffScore: 500);
+ 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 body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
+ body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Below Cutoff");
+ }
+
+ [Fact]
+ public async Task GetCustomFormatScores_WithSearchFilter_ReturnsMatchingTitlesOnly()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "The Matrix", currentScore: 100, cutoffScore: 500);
+ AddScoreEntry(radarr.Id, 2, "Inception", currentScore: 200, cutoffScore: 500);
+ AddScoreEntry(radarr.Id, 3, "The Matrix Reloaded", currentScore: 300, cutoffScore: 500);
+
+ var result = await _controller.GetCustomFormatScores(search: "matrix");
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task GetCustomFormatScores_WithSortByDate_OrdersByLastSyncedDescending()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "Older", currentScore: 100, cutoffScore: 500,
+ lastSynced: DateTime.UtcNow.AddHours(-2));
+ AddScoreEntry(radarr.Id, 2, "Newer", currentScore: 200, cutoffScore: 500,
+ lastSynced: DateTime.UtcNow.AddHours(-1));
+
+ var result = await _controller.GetCustomFormatScores(sortBy: "date");
+ var body = GetResponseBody(result);
+
+ body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Newer");
+ body.GetProperty("Items")[1].GetProperty("Title").GetString().ShouldBe("Older");
+ }
+
+ [Fact]
+ public async Task GetCustomFormatScores_WithInstanceIdFilter_ReturnsOnlyThatInstance()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 100, cutoffScore: 500);
+ AddScoreEntry(sonarr.Id, 2, "Series", currentScore: 200, cutoffScore: 500,
+ itemType: InstanceType.Sonarr);
+
+ var result = await _controller.GetCustomFormatScores(instanceId: radarr.Id);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
+ body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Movie");
+ }
+
+ [Fact]
+ public async Task GetCustomFormatScores_ReturnsCorrectTotalPagesCalculation()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ for (int i = 1; i <= 7; i++)
+ {
+ AddScoreEntry(radarr.Id, i, $"Movie {i}", currentScore: 100, cutoffScore: 500);
+ }
+
+ var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 3);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(7);
+ body.GetProperty("TotalPages").GetInt32().ShouldBe(3); // ceil(7/3) = 3
+ body.GetProperty("Items").GetArrayLength().ShouldBe(3);
+ }
+
+ #endregion
+
+ #region GetRecentUpgrades Tests
+
+ [Fact]
+ public async Task GetRecentUpgrades_WithNoHistory_ReturnsEmptyList()
+ {
+ var result = await _controller.GetRecentUpgrades();
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
+ body.GetProperty("Items").GetArrayLength().ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetRecentUpgrades_WithSingleEntryPerItem_ReturnsNoUpgrades()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-1));
+
+ var result = await _controller.GetRecentUpgrades();
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetRecentUpgrades_WithScoreIncrease_DetectsUpgrade()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-1));
+
+ var result = await _controller.GetRecentUpgrades();
+ 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(250);
+ }
+
+ [Fact]
+ public async Task GetRecentUpgrades_WithScoreDecrease_DoesNotCountAsUpgrade()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-2));
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-1));
+
+ var result = await _controller.GetRecentUpgrades();
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetRecentUpgrades_WithMultipleUpgradesInSameGroup_CountsEach()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ // 100 -> 200 -> 300 = two upgrades for the same item
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
+
+ var result = await _controller.GetRecentUpgrades();
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task GetRecentUpgrades_WithDaysFilter_ExcludesOlderHistory()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ // Old upgrade (outside 7-day window)
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-20));
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-15));
+ // Recent upgrade (inside 7-day window)
+ AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
+ AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
+
+ var result = await _controller.GetRecentUpgrades(days: 7);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetRecentUpgrades_ReturnsSortedByMostRecentFirst()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ // Item 1: upgrade happened 5 days ago
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-6));
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-5));
+ // Item 2: upgrade happened 1 day ago
+ AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
+ AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
+
+ var result = await _controller.GetRecentUpgrades();
+ var body = GetResponseBody(result);
+
+ var items = body.GetProperty("Items");
+ items.GetArrayLength().ShouldBe(2);
+ // Most recent upgrade (item 2) should be first
+ items[0].GetProperty("NewScore").GetInt32().ShouldBe(300);
+ items[1].GetProperty("NewScore").GetInt32().ShouldBe(200);
+ }
+
+ #endregion
+
+ #region GetStats Tests
+
+ [Fact]
+ public async Task GetStats_WithNoEntries_ReturnsZeroes()
+ {
+ var result = await _controller.GetStats();
+ var okResult = result.ShouldBeOfType();
+ var stats = okResult.Value.ShouldBeOfType();
+
+ stats.TotalTracked.ShouldBe(0);
+ stats.BelowCutoff.ShouldBe(0);
+ stats.AtOrAboveCutoff.ShouldBe(0);
+ stats.RecentUpgrades.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetStats_CorrectlyCategorizesBelowAndAboveCutoff()
+ {
+ 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.GetStats();
+ var okResult = result.ShouldBeOfType();
+ var stats = okResult.Value.ShouldBeOfType();
+
+ stats.TotalTracked.ShouldBe(3);
+ stats.BelowCutoff.ShouldBe(1);
+ stats.AtOrAboveCutoff.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task GetStats_CountsRecentUpgradesFromLast7Days()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 300, cutoffScore: 500);
+
+ // Upgrade within 7 days
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
+
+ // Upgrade outside 7 days (should not be counted)
+ AddHistoryEntry(radarr.Id, externalItemId: 2, score: 50, recordedAt: DateTime.UtcNow.AddDays(-20));
+ AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-15));
+
+ var result = await _controller.GetStats();
+ var okResult = result.ShouldBeOfType();
+ var stats = okResult.Value.ShouldBeOfType();
+
+ stats.RecentUpgrades.ShouldBe(1);
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private void AddScoreEntry(
+ Guid arrInstanceId,
+ long externalItemId,
+ string title,
+ int currentScore,
+ int cutoffScore,
+ InstanceType itemType = InstanceType.Radarr,
+ DateTime? lastSynced = null)
+ {
+ _dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
+ {
+ ArrInstanceId = arrInstanceId,
+ ExternalItemId = externalItemId,
+ EpisodeId = 0,
+ ItemType = itemType,
+ Title = title,
+ FileId = externalItemId * 10,
+ CurrentScore = currentScore,
+ CutoffScore = cutoffScore,
+ QualityProfileName = "HD",
+ LastSyncedAt = lastSynced ?? DateTime.UtcNow
+ });
+ _dataContext.SaveChanges();
+ }
+
+ private void AddHistoryEntry(
+ Guid arrInstanceId,
+ long externalItemId,
+ int score,
+ DateTime recordedAt,
+ long episodeId = 0,
+ int cutoffScore = 500,
+ InstanceType itemType = InstanceType.Radarr)
+ {
+ _dataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
+ {
+ ArrInstanceId = arrInstanceId,
+ ExternalItemId = externalItemId,
+ EpisodeId = episodeId,
+ ItemType = itemType,
+ Title = $"Item {externalItemId}",
+ Score = score,
+ CutoffScore = cutoffScore,
+ RecordedAt = recordedAt
+ });
+ _dataContext.SaveChanges();
+ }
+
+ #endregion
+}
diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs
new file mode 100644
index 00000000..4369afc3
--- /dev/null
+++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs
@@ -0,0 +1,221 @@
+using System.Text.Json;
+using Cleanuparr.Api.Features.Seeker.Controllers;
+using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
+using Cleanuparr.Domain.Enums;
+using Cleanuparr.Persistence;
+using Cleanuparr.Persistence.Models.Events;
+using Microsoft.AspNetCore.Mvc;
+using Shouldly;
+
+namespace Cleanuparr.Api.Tests.Features.Seeker;
+
+public class SearchStatsControllerTests : IDisposable
+{
+ private readonly DataContext _dataContext;
+ private readonly EventsContext _eventsContext;
+ private readonly SearchStatsController _controller;
+
+ public SearchStatsControllerTests()
+ {
+ _dataContext = SeekerTestDataFactory.CreateDataContext();
+ _eventsContext = SeekerTestDataFactory.CreateEventsContext();
+ _controller = new SearchStatsController(_dataContext, _eventsContext);
+ }
+
+ public void Dispose()
+ {
+ _dataContext.Dispose();
+ _eventsContext.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ private static JsonElement GetResponseBody(IActionResult result)
+ {
+ var okResult = result.ShouldBeOfType();
+ var json = JsonSerializer.Serialize(okResult.Value);
+ return JsonDocument.Parse(json).RootElement;
+ }
+
+ #region ParseEventData (tested via GetEvents)
+
+ [Fact]
+ public async Task GetEvents_WithNullEventData_ReturnsUnknownDefaults()
+ {
+ AddSearchEvent(data: null);
+
+ var result = await _controller.GetEvents();
+ var body = GetResponseBody(result);
+
+ var item = body.GetProperty("Items")[0];
+ item.GetProperty("InstanceName").GetString().ShouldBe("Unknown");
+ item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
+ item.GetProperty("Items").GetArrayLength().ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetEvents_WithValidFullJson_ParsesAllFields()
+ {
+ var data = JsonSerializer.Serialize(new
+ {
+ InstanceName = "My Radarr",
+ ItemCount = 3,
+ Items = new[] { "Movie A", "Movie B", "Movie C" },
+ SearchType = "Proactive",
+ GrabbedItems = new[] { new { Title = "Movie A", Quality = "Bluray-1080p" } }
+ });
+ AddSearchEvent(data: data);
+
+ var result = await _controller.GetEvents();
+ var body = GetResponseBody(result);
+
+ var item = body.GetProperty("Items")[0];
+ item.GetProperty("InstanceName").GetString().ShouldBe("My Radarr");
+ item.GetProperty("ItemCount").GetInt32().ShouldBe(3);
+ item.GetProperty("Items").GetArrayLength().ShouldBe(3);
+ item.GetProperty("Items")[0].GetString().ShouldBe("Movie A");
+ item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Proactive));
+ }
+
+ [Fact]
+ public async Task GetEvents_WithPartialJson_ReturnsDefaultsForMissingFields()
+ {
+ // Only InstanceName is present, other fields missing
+ var data = JsonSerializer.Serialize(new { InstanceName = "Partial Instance" });
+ AddSearchEvent(data: data);
+
+ var result = await _controller.GetEvents();
+ var body = GetResponseBody(result);
+
+ var item = body.GetProperty("Items")[0];
+ item.GetProperty("InstanceName").GetString().ShouldBe("Partial Instance");
+ item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
+ item.GetProperty("Items").GetArrayLength().ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetEvents_WithMalformedJson_ReturnsUnknownDefaults()
+ {
+ AddSearchEvent(data: "not valid json {{{");
+
+ var result = await _controller.GetEvents();
+ var body = GetResponseBody(result);
+
+ var item = body.GetProperty("Items")[0];
+ item.GetProperty("InstanceName").GetString().ShouldBe("Unknown");
+ item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetEvents_WithSearchTypeReplacement_ParsesCorrectEnum()
+ {
+ var data = JsonSerializer.Serialize(new
+ {
+ InstanceName = "Sonarr",
+ SearchType = "Replacement"
+ });
+ AddSearchEvent(data: data);
+
+ var result = await _controller.GetEvents();
+ var body = GetResponseBody(result);
+
+ var item = body.GetProperty("Items")[0];
+ item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Replacement));
+ }
+
+ #endregion
+
+ #region GetEvents Filtering
+
+ [Fact]
+ public async Task GetEvents_WithInstanceIdFilter_FiltersViaInstanceUrl()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
+
+ // Event matching radarr's URL
+ AddSearchEvent(instanceUrl: radarr.Url.ToString(), instanceType: InstanceType.Radarr,
+ data: JsonSerializer.Serialize(new { InstanceName = "Radarr Event" }));
+ // Event matching sonarr's URL
+ AddSearchEvent(instanceUrl: sonarr.Url.ToString(), instanceType: InstanceType.Sonarr,
+ data: JsonSerializer.Serialize(new { InstanceName = "Sonarr Event" }));
+
+ var result = await _controller.GetEvents(instanceId: radarr.Id);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
+ body.GetProperty("Items")[0].GetProperty("InstanceName").GetString().ShouldBe("Radarr Event");
+ }
+
+ [Fact]
+ public async Task GetEvents_WithCycleIdFilter_ReturnsOnlyMatchingCycle()
+ {
+ var cycleA = Guid.NewGuid();
+ var cycleB = Guid.NewGuid();
+
+ AddSearchEvent(cycleId: cycleA, data: JsonSerializer.Serialize(new { InstanceName = "Cycle A" }));
+ AddSearchEvent(cycleId: cycleB, data: JsonSerializer.Serialize(new { InstanceName = "Cycle B" }));
+
+ var result = await _controller.GetEvents(cycleId: cycleA);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
+ body.GetProperty("Items")[0].GetProperty("InstanceName").GetString().ShouldBe("Cycle A");
+ }
+
+ [Fact]
+ public async Task GetEvents_WithSearchFilter_FiltersOnDataField()
+ {
+ AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = "Radarr", Items = new[] { "The Matrix" } }));
+ AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = "Sonarr", Items = new[] { "Breaking Bad" } }));
+
+ var result = await _controller.GetEvents(search: "matrix");
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetEvents_WithPagination_ReturnsCorrectPageAndCount()
+ {
+ for (int i = 0; i < 5; i++)
+ {
+ AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = $"Event {i}" }));
+ }
+
+ var result = await _controller.GetEvents(page: 2, pageSize: 2);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(5);
+ body.GetProperty("TotalPages").GetInt32().ShouldBe(3); // ceil(5/2) = 3
+ body.GetProperty("Page").GetInt32().ShouldBe(2);
+ body.GetProperty("Items").GetArrayLength().ShouldBe(2);
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private void AddSearchEvent(
+ string? data = null,
+ string? instanceUrl = null,
+ InstanceType? instanceType = null,
+ Guid? cycleId = null,
+ SearchCommandStatus? searchStatus = null)
+ {
+ _eventsContext.Events.Add(new AppEvent
+ {
+ EventType = EventType.SearchTriggered,
+ Message = "Search triggered",
+ Severity = EventSeverity.Information,
+ Data = data,
+ InstanceUrl = instanceUrl,
+ InstanceType = instanceType,
+ CycleId = cycleId,
+ SearchStatus = searchStatus,
+ Timestamp = DateTime.UtcNow
+ });
+ _eventsContext.SaveChanges();
+ }
+
+ #endregion
+}
diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerConfigControllerTests.cs
new file mode 100644
index 00000000..6f1a4893
--- /dev/null
+++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerConfigControllerTests.cs
@@ -0,0 +1,324 @@
+using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
+using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
+using Cleanuparr.Api.Features.Seeker.Controllers;
+using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
+using Cleanuparr.Domain.Enums;
+using Cleanuparr.Infrastructure.Services.Interfaces;
+using Cleanuparr.Persistence;
+using Cleanuparr.Persistence.Models.Configuration.Seeker;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Shouldly;
+using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
+
+namespace Cleanuparr.Api.Tests.Features.Seeker;
+
+public class SeekerConfigControllerTests : IDisposable
+{
+ private readonly DataContext _dataContext;
+ private readonly ILogger _logger;
+ private readonly IJobManagementService _jobManagementService;
+ private readonly SeekerConfigController _controller;
+
+ public SeekerConfigControllerTests()
+ {
+ _dataContext = SeekerTestDataFactory.CreateDataContext();
+ _logger = Substitute.For>();
+ _jobManagementService = Substitute.For();
+ _controller = new SeekerConfigController(_logger, _dataContext, _jobManagementService);
+ }
+
+ public void Dispose()
+ {
+ _dataContext.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ #region GetSeekerConfig Tests
+
+ [Fact]
+ public async Task GetSeekerConfig_WithNoSeekerInstanceConfigs_ReturnsDefaults()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+
+ var result = await _controller.GetSeekerConfig();
+ var okResult = result.ShouldBeOfType();
+ var response = okResult.Value.ShouldBeOfType();
+
+ var instance = response.Instances.ShouldHaveSingleItem();
+ instance.ArrInstanceId.ShouldBe(radarr.Id);
+ instance.Enabled.ShouldBeFalse();
+ instance.SkipTags.ShouldBeEmpty();
+ instance.ActiveDownloadLimit.ShouldBe(3);
+ instance.MinCycleTimeDays.ShouldBe(7);
+ }
+
+ [Fact]
+ public async Task GetSeekerConfig_OnlyReturnsSonarrAndRadarrInstances()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
+ var lidarr = SeekerTestDataFactory.AddLidarrInstance(_dataContext);
+
+ var result = await _controller.GetSeekerConfig();
+ var okResult = result.ShouldBeOfType();
+ var response = okResult.Value.ShouldBeOfType();
+
+ response.Instances.Count.ShouldBe(2);
+ response.Instances.ShouldContain(i => i.ArrInstanceId == radarr.Id);
+ response.Instances.ShouldContain(i => i.ArrInstanceId == sonarr.Id);
+ response.Instances.ShouldNotContain(i => i.ArrInstanceId == lidarr.Id);
+ }
+
+ #endregion
+
+ #region UpdateSeekerConfig Tests
+
+ [Fact]
+ public async Task UpdateSeekerConfig_WithProactiveEnabledAndNoInstancesEnabled_ThrowsValidationException()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ var request = new UpdateSeekerConfigRequest
+ {
+ SearchEnabled = true,
+ SearchInterval = 5,
+ ProactiveSearchEnabled = true,
+ Instances =
+ [
+ new UpdateSeekerInstanceConfigRequest
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = false // No instances enabled
+ }
+ ]
+ };
+
+ await Should.ThrowAsync(() => _controller.UpdateSeekerConfig(request));
+ }
+
+ [Fact]
+ public async Task UpdateSeekerConfig_WhenIntervalChanges_ReschedulesSeeker()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ _dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = true
+ });
+ await _dataContext.SaveChangesAsync();
+
+ // Default interval is 3, change to 5
+ var request = new UpdateSeekerConfigRequest
+ {
+ SearchEnabled = true,
+ SearchInterval = 5,
+ ProactiveSearchEnabled = true,
+ Instances =
+ [
+ new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
+ ]
+ };
+
+ await _controller.UpdateSeekerConfig(request);
+
+ await _jobManagementService.Received(1)
+ .StartJob(JobType.Seeker, null, Arg.Any());
+ }
+
+ [Fact]
+ public async Task UpdateSeekerConfig_WhenIntervalUnchanged_DoesNotReschedule()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ _dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = true
+ });
+ await _dataContext.SaveChangesAsync();
+
+ // Keep interval at default (3)
+ var request = new UpdateSeekerConfigRequest
+ {
+ SearchEnabled = true,
+ SearchInterval = 3,
+ ProactiveSearchEnabled = true,
+ Instances =
+ [
+ new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
+ ]
+ };
+
+ await _controller.UpdateSeekerConfig(request);
+
+ await _jobManagementService.DidNotReceive()
+ .StartJob(Arg.Any(), null, Arg.Any());
+ }
+
+ [Fact]
+ public async Task UpdateSeekerConfig_WhenCustomFormatScoreEnabled_StartsAndTriggersSyncerJob()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ _dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = true
+ });
+ await _dataContext.SaveChangesAsync();
+
+ // UseCustomFormatScore was false (default), now enable it
+ var request = new UpdateSeekerConfigRequest
+ {
+ SearchEnabled = true,
+ SearchInterval = 3,
+ ProactiveSearchEnabled = true,
+ UseCustomFormatScore = true,
+ Instances =
+ [
+ new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
+ ]
+ };
+
+ await _controller.UpdateSeekerConfig(request);
+
+ await _jobManagementService.Received(1)
+ .StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any());
+ await _jobManagementService.Received(1)
+ .TriggerJobOnce(JobType.CustomFormatScoreSyncer);
+ }
+
+ [Fact]
+ public async Task UpdateSeekerConfig_WhenCustomFormatScoreDisabled_StopsSyncerJob()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ _dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = true
+ });
+ await _dataContext.SaveChangesAsync();
+
+ // First enable CF score
+ var config = await _dataContext.SeekerConfigs.FirstAsync();
+ config.UseCustomFormatScore = true;
+ await _dataContext.SaveChangesAsync();
+
+ // Now disable it
+ var request = new UpdateSeekerConfigRequest
+ {
+ SearchEnabled = true,
+ SearchInterval = 3,
+ ProactiveSearchEnabled = true,
+ UseCustomFormatScore = false,
+ Instances =
+ [
+ new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
+ ]
+ };
+
+ await _controller.UpdateSeekerConfig(request);
+
+ await _jobManagementService.Received(1)
+ .StopJob(JobType.CustomFormatScoreSyncer);
+ }
+
+ [Fact]
+ public async Task UpdateSeekerConfig_WhenSearchReenabledWithCustomFormatActive_TriggersSyncerOnce()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ _dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = true
+ });
+ await _dataContext.SaveChangesAsync();
+
+ // Set up state: CF score already enabled, search currently disabled
+ var config = await _dataContext.SeekerConfigs.FirstAsync();
+ config.UseCustomFormatScore = true;
+ config.SearchEnabled = false;
+ await _dataContext.SaveChangesAsync();
+
+ // Re-enable search
+ var request = new UpdateSeekerConfigRequest
+ {
+ SearchEnabled = true,
+ SearchInterval = 3,
+ ProactiveSearchEnabled = false,
+ UseCustomFormatScore = true,
+ Instances =
+ [
+ new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
+ ]
+ };
+
+ await _controller.UpdateSeekerConfig(request);
+
+ await _jobManagementService.Received(1)
+ .TriggerJobOnce(JobType.CustomFormatScoreSyncer);
+ }
+
+ [Fact]
+ public async Task UpdateSeekerConfig_SyncsExistingAndCreatesNewInstanceConfigs()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
+
+ // Radarr already has a config
+ _dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = false,
+ SkipTags = ["old-tag"],
+ ActiveDownloadLimit = 2,
+ MinCycleTimeDays = 5
+ });
+ await _dataContext.SaveChangesAsync();
+
+ var request = new UpdateSeekerConfigRequest
+ {
+ SearchEnabled = true,
+ SearchInterval = 3,
+ ProactiveSearchEnabled = true,
+ Instances =
+ [
+ // Update existing radarr config
+ new UpdateSeekerInstanceConfigRequest
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = true,
+ SkipTags = ["new-tag"],
+ ActiveDownloadLimit = 5,
+ MinCycleTimeDays = 14
+ },
+ // Create new sonarr config
+ new UpdateSeekerInstanceConfigRequest
+ {
+ ArrInstanceId = sonarr.Id,
+ Enabled = true,
+ SkipTags = ["sonarr-tag"],
+ ActiveDownloadLimit = 3,
+ MinCycleTimeDays = 7
+ }
+ ]
+ };
+
+ await _controller.UpdateSeekerConfig(request);
+
+ var configs = await _dataContext.SeekerInstanceConfigs.ToListAsync();
+ configs.Count.ShouldBe(2);
+
+ var radarrConfig = configs.First(c => c.ArrInstanceId == radarr.Id);
+ radarrConfig.Enabled.ShouldBeTrue();
+ radarrConfig.SkipTags.ShouldContain("new-tag");
+ radarrConfig.ActiveDownloadLimit.ShouldBe(5);
+ radarrConfig.MinCycleTimeDays.ShouldBe(14);
+
+ var sonarrConfig = configs.First(c => c.ArrInstanceId == sonarr.Id);
+ sonarrConfig.Enabled.ShouldBeTrue();
+ sonarrConfig.SkipTags.ShouldContain("sonarr-tag");
+ }
+
+ #endregion
+}
diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/TestHelpers/SeekerTestDataFactory.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/TestHelpers/SeekerTestDataFactory.cs
new file mode 100644
index 00000000..7379b2e8
--- /dev/null
+++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/TestHelpers/SeekerTestDataFactory.cs
@@ -0,0 +1,166 @@
+using Cleanuparr.Domain.Enums;
+using Cleanuparr.Persistence;
+using Cleanuparr.Persistence.Models.Configuration;
+using Cleanuparr.Persistence.Models.Configuration.Arr;
+using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
+using Cleanuparr.Persistence.Models.Configuration.General;
+using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
+using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
+using Cleanuparr.Persistence.Models.Configuration.Seeker;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+namespace Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
+
+///
+/// Factory for creating SQLite in-memory contexts for Seeker controller tests
+///
+public static class SeekerTestDataFactory
+{
+ public static DataContext CreateDataContext()
+ {
+ var connection = new SqliteConnection("DataSource=:memory:");
+ connection.Open();
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(connection)
+ .Options;
+
+ var context = new DataContext(options);
+ context.Database.EnsureCreated();
+
+ SeedDefaultData(context);
+ return context;
+ }
+
+ public static EventsContext CreateEventsContext()
+ {
+ var connection = new SqliteConnection("DataSource=:memory:");
+ connection.Open();
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(connection)
+ .Options;
+
+ var context = new EventsContext(options);
+ context.Database.EnsureCreated();
+ return context;
+ }
+
+ private static void SeedDefaultData(DataContext context)
+ {
+ context.GeneralConfigs.Add(new GeneralConfig
+ {
+ Id = Guid.NewGuid(),
+ DryRun = false,
+ IgnoredDownloads = [],
+ Log = new LoggingConfig()
+ });
+
+ context.ArrConfigs.AddRange(
+ new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
+ new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
+ new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
+ new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
+ new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
+ );
+
+ context.QueueCleanerConfigs.Add(new QueueCleanerConfig
+ {
+ Id = Guid.NewGuid(),
+ IgnoredDownloads = [],
+ FailedImport = new FailedImportConfig()
+ });
+
+ context.ContentBlockerConfigs.Add(new ContentBlockerConfig
+ {
+ Id = Guid.NewGuid(),
+ IgnoredDownloads = [],
+ DeletePrivate = false,
+ Sonarr = new BlocklistSettings { Enabled = false },
+ Radarr = new BlocklistSettings { Enabled = false },
+ Lidarr = new BlocklistSettings { Enabled = false },
+ Readarr = new BlocklistSettings { Enabled = false },
+ Whisparr = new BlocklistSettings { Enabled = false }
+ });
+
+ context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
+ {
+ Id = Guid.NewGuid(),
+ IgnoredDownloads = [],
+ Categories = [],
+ UnlinkedEnabled = false,
+ UnlinkedTargetCategory = "",
+ UnlinkedCategories = []
+ });
+
+ context.SeekerConfigs.Add(new SeekerConfig
+ {
+ Id = Guid.NewGuid(),
+ SearchEnabled = true,
+ ProactiveSearchEnabled = false
+ });
+
+ context.SaveChanges();
+ }
+
+ public static ArrInstance AddSonarrInstance(DataContext context, bool enabled = true)
+ {
+ var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Sonarr);
+ var instance = new ArrInstance
+ {
+ Id = Guid.NewGuid(),
+ Name = "Test Sonarr",
+ Url = new Uri("http://sonarr:8989"),
+ ApiKey = "test-api-key",
+ Enabled = enabled,
+ ArrConfigId = arrConfig.Id,
+ ArrConfig = arrConfig
+ };
+
+ arrConfig.Instances.Add(instance);
+ context.ArrInstances.Add(instance);
+ context.SaveChanges();
+ return instance;
+ }
+
+ public static ArrInstance AddRadarrInstance(DataContext context, bool enabled = true)
+ {
+ var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Radarr);
+ var instance = new ArrInstance
+ {
+ Id = Guid.NewGuid(),
+ Name = "Test Radarr",
+ Url = new Uri("http://radarr:7878"),
+ ApiKey = "test-api-key",
+ Enabled = enabled,
+ ArrConfigId = arrConfig.Id,
+ ArrConfig = arrConfig
+ };
+
+ arrConfig.Instances.Add(instance);
+ context.ArrInstances.Add(instance);
+ context.SaveChanges();
+ return instance;
+ }
+
+ public static ArrInstance AddLidarrInstance(DataContext context, bool enabled = true)
+ {
+ var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Lidarr);
+ var instance = new ArrInstance
+ {
+ Id = Guid.NewGuid(),
+ Name = "Test Lidarr",
+ Url = new Uri("http://lidarr:8686"),
+ ApiKey = "test-api-key",
+ Enabled = enabled,
+ ArrConfigId = arrConfig.Id,
+ ArrConfig = arrConfig
+ };
+
+ arrConfig.Instances.Add(instance);
+ context.ArrInstances.Add(instance);
+ context.SaveChanges();
+ return instance;
+ }
+}
diff --git a/code/backend/Cleanuparr.Api/Controllers/JobsController.cs b/code/backend/Cleanuparr.Api/Controllers/JobsController.cs
index 193dfa66..1bbf477e 100644
--- a/code/backend/Cleanuparr.Api/Controllers/JobsController.cs
+++ b/code/backend/Cleanuparr.Api/Controllers/JobsController.cs
@@ -57,8 +57,13 @@ public class JobsController : ControllerBase
}
[HttpPost("{jobType}/start")]
- public async Task StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest = null)
+ public async Task StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
{
+ if (jobType == JobType.Seeker)
+ {
+ return BadRequest("The Seeker job cannot be manually controlled");
+ }
+
try
{
// Get the schedule from the request body if provided
@@ -82,6 +87,11 @@ public class JobsController : ControllerBase
[HttpPost("{jobType}/trigger")]
public async Task TriggerJob(JobType jobType)
{
+ if (jobType == JobType.Seeker)
+ {
+ return BadRequest("The Seeker job cannot be manually triggered");
+ }
+
try
{
var result = await _jobManagementService.TriggerJobOnce(jobType);
@@ -102,6 +112,11 @@ public class JobsController : ControllerBase
[HttpPut("{jobType}/schedule")]
public async Task UpdateJobSchedule(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
{
+ if (jobType == JobType.Seeker)
+ {
+ return BadRequest("The Seeker job schedule cannot be manually modified");
+ }
+
if (scheduleRequest?.Schedule == null)
{
return BadRequest("Schedule is required");
diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs
index 5c5d603f..05e95463 100644
--- a/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs
+++ b/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs
@@ -1,6 +1,5 @@
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr;
-using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
@@ -30,9 +29,6 @@ public static class MainDI
config.AddConsumer>();
config.AddConsumer>();
- config.AddConsumer>();
- config.AddConsumer>();
-
config.AddConsumer>();
config.AddConsumer>();
config.AddConsumer>();
@@ -60,14 +56,6 @@ public static class MainDI
e.PrefetchCount = 1;
});
- cfg.ReceiveEndpoint("download-hunter-queue", e =>
- {
- e.ConfigureConsumer>(context);
- e.ConfigureConsumer>(context);
- e.ConcurrentMessageLimit = 1;
- e.PrefetchCount = 1;
- });
-
cfg.ReceiveEndpoint("notification-queue", e =>
{
e.ConfigureConsumer>(context);
diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs
index 71010716..0f45e384 100644
--- a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs
+++ b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs
@@ -5,8 +5,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Auth;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.DownloadClient;
-using Cleanuparr.Infrastructure.Features.DownloadHunter;
-using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
@@ -49,8 +47,9 @@ public static class ServicesDI
.AddScoped()
.AddScoped()
.AddScoped()
+ .AddScoped()
+ .AddScoped()
.AddScoped()
- .AddScoped()
.AddScoped()
.AddScoped()
.AddScoped()
@@ -67,5 +66,6 @@ public static class ServicesDI
.AddSingleton()
.AddSingleton(TimeProvider.System)
.AddSingleton()
- .AddHostedService();
+ .AddHostedService()
+ .AddHostedService();
}
\ No newline at end of file
diff --git a/code/backend/Cleanuparr.Api/Features/General/Contracts/Requests/UpdateGeneralConfigRequest.cs b/code/backend/Cleanuparr.Api/Features/General/Contracts/Requests/UpdateGeneralConfigRequest.cs
index d89d992b..7098cc7d 100644
--- a/code/backend/Cleanuparr.Api/Features/General/Contracts/Requests/UpdateGeneralConfigRequest.cs
+++ b/code/backend/Cleanuparr.Api/Features/General/Contracts/Requests/UpdateGeneralConfigRequest.cs
@@ -2,7 +2,6 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Persistence.Models.Configuration.General;
-using Cleanuparr.Shared.Helpers;
using Serilog.Events;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
@@ -20,10 +19,6 @@ public sealed record UpdateGeneralConfigRequest
public CertificateValidationType HttpCertificateValidation { get; init; } = CertificateValidationType.Enabled;
- public bool SearchEnabled { get; init; } = true;
-
- public ushort SearchDelay { get; init; } = Constants.DefaultSearchDelaySeconds;
-
public bool StatusCheckEnabled { get; init; } = true;
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
@@ -43,8 +38,6 @@ public sealed record UpdateGeneralConfigRequest
existingConfig.HttpMaxRetries = HttpMaxRetries;
existingConfig.HttpTimeout = HttpTimeout;
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
- existingConfig.SearchEnabled = SearchEnabled;
- existingConfig.SearchDelay = SearchDelay;
existingConfig.StatusCheckEnabled = StatusCheckEnabled;
existingConfig.EncryptionKey = EncryptionKey;
existingConfig.IgnoredDownloads = IgnoredDownloads;
diff --git a/code/backend/Cleanuparr.Api/Features/General/Controllers/GeneralConfigController.cs b/code/backend/Cleanuparr.Api/Features/General/Controllers/GeneralConfigController.cs
index 62922f84..d987fbdc 100644
--- a/code/backend/Cleanuparr.Api/Features/General/Controllers/GeneralConfigController.cs
+++ b/code/backend/Cleanuparr.Api/Features/General/Controllers/GeneralConfigController.cs
@@ -80,10 +80,14 @@ public sealed class GeneralConfigController : ControllerBase
.Where(d => !d.Strikes.Any())
.ExecuteDeleteAsync();
+ var deletedHistory = await _dataContext.SeekerHistory
+ .Where(h => h.IsDryRun)
+ .ExecuteDeleteAsync();
+
_logger.LogWarning(
- "Dry run disabled — purged dry-run data: {Strikes} strikes, {Events} events, {ManualEvents} manual events, {Items} orphaned download items removed",
- deletedStrikes, deletedEvents, deletedManualEvents, deletedItems);
-
+ "Dry run disabled — purged dry-run data: {Strikes} strikes, {Events} events, {ManualEvents} manual events, {Items} orphaned download items, {History} search history entries removed",
+ deletedStrikes, deletedEvents, deletedManualEvents, deletedItems, deletedHistory);
+
await transaction.CommitAsync();
}
catch
diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/CreateNotificationProviderRequestBase.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/CreateNotificationProviderRequestBase.cs
index f4f33283..fded2887 100644
--- a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/CreateNotificationProviderRequestBase.cs
+++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/CreateNotificationProviderRequestBase.cs
@@ -17,4 +17,6 @@ public abstract record CreateNotificationProviderRequestBase
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
+
+ public bool OnSearchTriggered { get; init; }
}
diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/UpdateNotificationProviderRequestBase.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/UpdateNotificationProviderRequestBase.cs
index 637d9eec..f1006933 100644
--- a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/UpdateNotificationProviderRequestBase.cs
+++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/UpdateNotificationProviderRequestBase.cs
@@ -17,4 +17,6 @@ public abstract record UpdateNotificationProviderRequestBase
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
+
+ public bool OnSearchTriggered { get; init; }
}
diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs
index 884f4f99..5bbf6547 100644
--- a/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs
+++ b/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs
@@ -74,7 +74,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = p.OnSlowStrike,
OnQueueItemDeleted = p.OnQueueItemDeleted,
OnDownloadCleaned = p.OnDownloadCleaned,
- OnCategoryChanged = p.OnCategoryChanged
+ OnCategoryChanged = p.OnCategoryChanged,
+ OnSearchTriggered = p.OnSearchTriggered
},
Configuration = p.Type switch
{
@@ -153,6 +154,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
+ OnSearchTriggered = newProvider.OnSearchTriggered,
NotifiarrConfiguration = notifiarrConfig
};
@@ -223,6 +225,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
+ OnSearchTriggered = newProvider.OnSearchTriggered,
AppriseConfiguration = appriseConfig
};
@@ -300,6 +303,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
+ OnSearchTriggered = newProvider.OnSearchTriggered,
NtfyConfiguration = ntfyConfig
};
@@ -368,6 +372,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
+ OnSearchTriggered = newProvider.OnSearchTriggered,
TelegramConfiguration = telegramConfig
};
@@ -447,6 +452,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
+ OnSearchTriggered = updatedProvider.OnSearchTriggered,
NotifiarrConfiguration = notifiarrConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -533,6 +539,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
+ OnSearchTriggered = updatedProvider.OnSearchTriggered,
AppriseConfiguration = appriseConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -622,6 +629,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
+ OnSearchTriggered = updatedProvider.OnSearchTriggered,
NtfyConfiguration = ntfyConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -705,6 +713,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
+ OnSearchTriggered = updatedProvider.OnSearchTriggered,
TelegramConfiguration = telegramConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -815,7 +824,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
- OnCategoryChanged = false
+ OnCategoryChanged = false,
+ OnSearchTriggered = false
},
Configuration = notifiarrConfig
};
@@ -882,7 +892,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
- OnCategoryChanged = false
+ OnCategoryChanged = false,
+ OnSearchTriggered = false
},
Configuration = appriseConfig
};
@@ -956,7 +967,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
- OnCategoryChanged = false
+ OnCategoryChanged = false,
+ OnSearchTriggered = false
},
Configuration = ntfyConfig
};
@@ -1013,7 +1025,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
- OnCategoryChanged = false
+ OnCategoryChanged = false,
+ OnSearchTriggered = false
},
Configuration = telegramConfig
};
@@ -1048,7 +1061,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = provider.OnSlowStrike,
OnQueueItemDeleted = provider.OnQueueItemDeleted,
OnDownloadCleaned = provider.OnDownloadCleaned,
- OnCategoryChanged = provider.OnCategoryChanged
+ OnCategoryChanged = provider.OnCategoryChanged,
+ OnSearchTriggered = provider.OnSearchTriggered
},
Configuration = provider.Type switch
{
@@ -1105,6 +1119,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
+ OnSearchTriggered = newProvider.OnSearchTriggered,
DiscordConfiguration = discordConfig
};
@@ -1185,6 +1200,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
+ OnSearchTriggered = updatedProvider.OnSearchTriggered,
DiscordConfiguration = discordConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -1254,7 +1270,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
- OnCategoryChanged = false
+ OnCategoryChanged = false,
+ OnSearchTriggered = false
},
Configuration = discordConfig
};
@@ -1325,6 +1342,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
+ OnSearchTriggered = newProvider.OnSearchTriggered,
PushoverConfiguration = pushoverConfig
};
@@ -1412,6 +1430,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
+ OnSearchTriggered = updatedProvider.OnSearchTriggered,
PushoverConfiguration = pushoverConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -1495,7 +1514,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
- OnCategoryChanged = false
+ OnCategoryChanged = false,
+ OnSearchTriggered = false
},
Configuration = pushoverConfig
};
@@ -1551,6 +1571,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
+ OnSearchTriggered = newProvider.OnSearchTriggered,
GotifyConfiguration = gotifyConfig
};
@@ -1631,6 +1652,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
+ OnSearchTriggered = updatedProvider.OnSearchTriggered,
GotifyConfiguration = gotifyConfig,
UpdatedAt = DateTime.UtcNow
};
@@ -1698,7 +1720,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
- OnCategoryChanged = false
+ OnCategoryChanged = false,
+ OnSearchTriggered = false
},
Configuration = gotifyConfig
};
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Requests/UpdateSeekerConfigRequest.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Requests/UpdateSeekerConfigRequest.cs
new file mode 100644
index 00000000..9a3f8833
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Requests/UpdateSeekerConfigRequest.cs
@@ -0,0 +1,42 @@
+using Cleanuparr.Domain.Enums;
+using Cleanuparr.Persistence.Models.Configuration.Seeker;
+
+namespace Cleanuparr.Api.Features.Seeker.Contracts.Requests;
+
+public sealed record UpdateSeekerConfigRequest
+{
+ public bool SearchEnabled { get; init; } = true;
+
+ public ushort SearchInterval { get; init; } = 3;
+
+ public bool ProactiveSearchEnabled { get; init; }
+
+ public SelectionStrategy SelectionStrategy { get; init; } = SelectionStrategy.BalancedWeighted;
+
+ public bool MonitoredOnly { get; init; } = true;
+
+ public bool UseCutoff { get; init; }
+
+ public bool UseCustomFormatScore { get; init; }
+
+ public bool UseRoundRobin { get; init; } = true;
+
+ public int PostReleaseGraceHours { get; init; } = 6;
+
+ public List Instances { get; init; } = [];
+
+ public SeekerConfig ApplyTo(SeekerConfig config)
+ {
+ config.SearchEnabled = SearchEnabled;
+ config.SearchInterval = SearchInterval;
+ config.ProactiveSearchEnabled = ProactiveSearchEnabled;
+ config.SelectionStrategy = SelectionStrategy;
+ config.MonitoredOnly = MonitoredOnly;
+ config.UseCutoff = UseCutoff;
+ config.UseCustomFormatScore = UseCustomFormatScore;
+ config.UseRoundRobin = UseRoundRobin;
+ config.PostReleaseGraceHours = PostReleaseGraceHours;
+
+ return config;
+ }
+}
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Requests/UpdateSeekerInstanceConfigRequest.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Requests/UpdateSeekerInstanceConfigRequest.cs
new file mode 100644
index 00000000..7aa1be7d
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Requests/UpdateSeekerInstanceConfigRequest.cs
@@ -0,0 +1,14 @@
+namespace Cleanuparr.Api.Features.Seeker.Contracts.Requests;
+
+public sealed record UpdateSeekerInstanceConfigRequest
+{
+ public Guid ArrInstanceId { get; init; }
+
+ public bool Enabled { get; init; } = true;
+
+ public List SkipTags { get; init; } = [];
+
+ public int ActiveDownloadLimit { get; init; } = 3;
+
+ public int MinCycleTimeDays { get; init; } = 7;
+}
\ No newline at end of file
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreEntryResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreEntryResponse.cs
new file mode 100644
index 00000000..13042286
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreEntryResponse.cs
@@ -0,0 +1,20 @@
+using Cleanuparr.Domain.Enums;
+
+namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
+
+public sealed record CustomFormatScoreEntryResponse
+{
+ public Guid Id { get; init; }
+ public Guid ArrInstanceId { get; init; }
+ public long ExternalItemId { get; init; }
+ public long EpisodeId { get; init; }
+ public InstanceType ItemType { get; init; }
+ public string Title { get; init; } = string.Empty;
+ public long FileId { get; init; }
+ public int CurrentScore { get; init; }
+ public int CutoffScore { get; init; }
+ public string QualityProfileName { get; init; } = string.Empty;
+ public bool IsBelowCutoff { get; init; }
+ public bool IsMonitored { get; init; }
+ public DateTime LastSyncedAt { get; init; }
+}
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreHistoryEntryResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreHistoryEntryResponse.cs
new file mode 100644
index 00000000..9cbc50fa
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreHistoryEntryResponse.cs
@@ -0,0 +1,8 @@
+namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
+
+public sealed record CustomFormatScoreHistoryEntryResponse
+{
+ public int Score { get; init; }
+ public int CutoffScore { get; init; }
+ public DateTime RecordedAt { get; init; }
+}
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreStatsResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreStatsResponse.cs
new file mode 100644
index 00000000..9d1362ba
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreStatsResponse.cs
@@ -0,0 +1,25 @@
+namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
+
+public sealed record CustomFormatScoreStatsResponse
+{
+ public int TotalTracked { get; init; }
+ public int BelowCutoff { get; init; }
+ public int AtOrAboveCutoff { get; init; }
+ public int Monitored { get; init; }
+ public int Unmonitored { get; init; }
+ public int RecentUpgrades { get; init; }
+ public List PerInstanceStats { get; init; } = [];
+}
+
+public sealed record InstanceCfScoreStat
+{
+ public Guid InstanceId { get; init; }
+ public string InstanceName { get; init; } = string.Empty;
+ public string InstanceType { get; init; } = string.Empty;
+ public int TotalTracked { get; init; }
+ public int BelowCutoff { get; init; }
+ public int AtOrAboveCutoff { get; init; }
+ public int Monitored { get; init; }
+ public int Unmonitored { get; init; }
+ public int RecentUpgrades { get; init; }
+}
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreUpgradeResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreUpgradeResponse.cs
new file mode 100644
index 00000000..446c1c23
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreUpgradeResponse.cs
@@ -0,0 +1,16 @@
+using Cleanuparr.Domain.Enums;
+
+namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
+
+public sealed record CustomFormatScoreUpgradeResponse
+{
+ public Guid ArrInstanceId { get; init; }
+ public long ExternalItemId { get; init; }
+ public long EpisodeId { get; init; }
+ public InstanceType ItemType { get; init; }
+ public string Title { get; init; } = string.Empty;
+ public int PreviousScore { get; init; }
+ public int NewScore { get; init; }
+ public int CutoffScore { get; init; }
+ public DateTime UpgradedAt { get; init; }
+}
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/InstanceSearchStat.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/InstanceSearchStat.cs
new file mode 100644
index 00000000..8e2d796c
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/InstanceSearchStat.cs
@@ -0,0 +1,16 @@
+namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
+
+public sealed record InstanceSearchStat
+{
+ public Guid InstanceId { get; init; }
+ public string InstanceName { get; init; } = string.Empty;
+ public string InstanceType { get; init; } = string.Empty;
+ public int ItemsTracked { get; init; }
+ public int TotalSearchCount { get; init; }
+ public DateTime? LastSearchedAt { get; init; }
+ public DateTime? LastProcessedAt { get; init; }
+ public Guid? CurrentCycleId { get; init; }
+ public int CycleItemsSearched { get; init; }
+ public int CycleItemsTotal { get; init; }
+ public DateTime? CycleStartedAt { get; init; }
+}
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchEventResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchEventResponse.cs
new file mode 100644
index 00000000..318bee68
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchEventResponse.cs
@@ -0,0 +1,19 @@
+using Cleanuparr.Domain.Enums;
+
+namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
+
+public sealed record SearchEventResponse
+{
+ public Guid Id { get; init; }
+ public DateTime Timestamp { get; init; }
+ public string InstanceName { get; init; } = string.Empty;
+ public string? InstanceType { get; init; }
+ public int ItemCount { get; init; }
+ public List Items { get; init; } = [];
+ public SeekerSearchType SearchType { get; init; }
+ public SearchCommandStatus? SearchStatus { get; init; }
+ public DateTime? CompletedAt { get; init; }
+ public object? GrabbedItems { get; init; }
+ public Guid? CycleId { get; init; }
+ public bool IsDryRun { get; init; }
+}
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchStatsSummaryResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchStatsSummaryResponse.cs
new file mode 100644
index 00000000..174aaf44
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchStatsSummaryResponse.cs
@@ -0,0 +1,12 @@
+namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
+
+public sealed record SearchStatsSummaryResponse
+{
+ public int TotalSearchesAllTime { get; init; }
+ public int SearchesLast7Days { get; init; }
+ public int SearchesLast30Days { get; init; }
+ public int UniqueItemsSearched { get; init; }
+ public int PendingReplacementSearches { get; init; }
+ public int EnabledInstances { get; init; }
+ public List PerInstanceStats { get; init; } = [];
+}
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SeekerConfigResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SeekerConfigResponse.cs
new file mode 100644
index 00000000..59e3caad
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SeekerConfigResponse.cs
@@ -0,0 +1,26 @@
+using Cleanuparr.Domain.Enums;
+
+namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
+
+public sealed record SeekerConfigResponse
+{
+ public bool SearchEnabled { get; init; }
+
+ public ushort SearchInterval { get; init; }
+
+ public bool ProactiveSearchEnabled { get; init; }
+
+ public SelectionStrategy SelectionStrategy { get; init; }
+
+ public bool MonitoredOnly { get; init; }
+
+ public bool UseCutoff { get; init; }
+
+ public bool UseCustomFormatScore { get; init; }
+
+ public bool UseRoundRobin { get; init; }
+
+ public int PostReleaseGraceHours { get; init; }
+
+ public List Instances { get; init; } = [];
+}
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SeekerInstanceConfigResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SeekerInstanceConfigResponse.cs
new file mode 100644
index 00000000..57b7ede6
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SeekerInstanceConfigResponse.cs
@@ -0,0 +1,24 @@
+using Cleanuparr.Domain.Enums;
+
+namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
+
+public sealed record SeekerInstanceConfigResponse
+{
+ public Guid ArrInstanceId { get; init; }
+
+ public string InstanceName { get; init; } = string.Empty;
+
+ public InstanceType InstanceType { get; init; }
+
+ public bool Enabled { get; init; }
+
+ public List SkipTags { get; init; } = [];
+
+ public DateTime? LastProcessedAt { get; init; }
+
+ public bool ArrInstanceEnabled { get; init; }
+
+ public int ActiveDownloadLimit { get; init; }
+
+ public int MinCycleTimeDays { get; init; }
+}
\ No newline at end of file
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/CustomFormatScoreController.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/CustomFormatScoreController.cs
new file mode 100644
index 00000000..64b02507
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/CustomFormatScoreController.cs
@@ -0,0 +1,311 @@
+using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
+using Cleanuparr.Persistence;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace Cleanuparr.Api.Features.Seeker.Controllers;
+
+[ApiController]
+[Route("api/seeker/cf-scores")]
+[Authorize]
+public sealed class CustomFormatScoreController : ControllerBase
+{
+ private readonly DataContext _dataContext;
+
+ public CustomFormatScoreController(DataContext dataContext)
+ {
+ _dataContext = dataContext;
+ }
+
+ ///
+ /// Gets current CF scores with pagination, optionally filtered by instance.
+ ///
+ [HttpGet]
+ public async Task GetCustomFormatScores(
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 50,
+ [FromQuery] Guid? instanceId = null,
+ [FromQuery] string? search = null,
+ [FromQuery] string sortBy = "title",
+ [FromQuery] bool hideMet = false)
+ {
+ if (page < 1) page = 1;
+ if (pageSize < 1) pageSize = 50;
+ if (pageSize > 100) pageSize = 100;
+
+ var query = _dataContext.CustomFormatScoreEntries
+ .AsNoTracking()
+ .AsQueryable();
+
+ if (instanceId.HasValue)
+ {
+ query = query.Where(e => e.ArrInstanceId == instanceId.Value);
+ }
+
+ if (!string.IsNullOrWhiteSpace(search))
+ {
+ query = query.Where(e => e.Title.ToLower().Contains(search.ToLower()));
+ }
+
+ if (hideMet)
+ {
+ query = query.Where(e => e.CurrentScore < e.CutoffScore);
+ }
+
+ int totalCount = await query.CountAsync();
+
+ var items = await (sortBy == "date"
+ ? query.OrderByDescending(e => e.LastSyncedAt)
+ : query.OrderBy(e => e.Title))
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .Select(e => new CustomFormatScoreEntryResponse
+ {
+ Id = e.Id,
+ ArrInstanceId = e.ArrInstanceId,
+ ExternalItemId = e.ExternalItemId,
+ EpisodeId = e.EpisodeId,
+ ItemType = e.ItemType,
+ Title = e.Title,
+ FileId = e.FileId,
+ CurrentScore = e.CurrentScore,
+ CutoffScore = e.CutoffScore,
+ QualityProfileName = e.QualityProfileName,
+ IsBelowCutoff = e.CurrentScore < e.CutoffScore,
+ IsMonitored = e.IsMonitored,
+ LastSyncedAt = e.LastSyncedAt,
+ })
+ .ToListAsync();
+
+ return Ok(new
+ {
+ Items = items,
+ Page = page,
+ PageSize = pageSize,
+ TotalCount = totalCount,
+ TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
+ });
+ }
+
+ ///
+ /// Gets recent CF score upgrades (where score improved in history).
+ ///
+ [HttpGet("upgrades")]
+ public async Task GetRecentUpgrades(
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 20,
+ [FromQuery] Guid? instanceId = null,
+ [FromQuery] int days = 30)
+ {
+ if (page < 1) page = 1;
+ if (pageSize < 1) pageSize = 20;
+ if (pageSize > 100) pageSize = 100;
+
+ // 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();
+
+ if (instanceId.HasValue)
+ {
+ query = query.Where(h => h.ArrInstanceId == instanceId.Value);
+ }
+
+ var allHistory = await query
+ .Where(h => h.RecordedAt >= DateTime.UtcNow.AddDays(-days))
+ .OrderByDescending(h => h.RecordedAt)
+ .ToListAsync();
+
+ var upgrades = new List();
+
+ // 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 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();
+
+ return Ok(new
+ {
+ Items = paged,
+ Page = page,
+ PageSize = pageSize,
+ TotalCount = totalCount,
+ TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
+ });
+ }
+
+ [HttpGet("instances")]
+ public async Task GetInstances()
+ {
+ var instances = await _dataContext.CustomFormatScoreEntries
+ .AsNoTracking()
+ .Select(e => new { e.ArrInstanceId, e.ItemType })
+ .Distinct()
+ .Join(
+ _dataContext.ArrInstances.AsNoTracking(),
+ e => e.ArrInstanceId,
+ a => a.Id,
+ (e, a) => new
+ {
+ Id = e.ArrInstanceId,
+ a.Name,
+ e.ItemType,
+ })
+ .OrderBy(x => x.Name)
+ .ToListAsync();
+
+ return Ok(new { Instances = instances });
+ }
+
+ ///
+ /// Gets summary statistics for CF score tracking.
+ ///
+ [HttpGet("stats")]
+ public async Task GetStats()
+ {
+ var entries = await _dataContext.CustomFormatScoreEntries
+ .AsNoTracking()
+ .ToListAsync();
+
+ int totalTracked = entries.Count;
+ int belowCutoff = entries.Count(e => e.CurrentScore < e.CutoffScore);
+ int atOrAboveCutoff = totalTracked - belowCutoff;
+ int monitored = entries.Count(e => e.IsMonitored);
+ int unmonitored = totalTracked - monitored;
+
+ // Count upgrades in the last 7 days
+ var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
+ var recentHistory = await _dataContext.CustomFormatScoreHistory
+ .AsNoTracking()
+ .Where(h => h.RecordedAt >= sevenDaysAgo)
+ .OrderBy(h => h.RecordedAt)
+ .ToListAsync();
+
+ int recentUpgrades = 0;
+ var recentGrouped = recentHistory
+ .GroupBy(h => new { h.ArrInstanceId, h.ExternalItemId, h.EpisodeId });
+
+ foreach (var group in recentGrouped)
+ {
+ var ordered = group.OrderBy(h => h.RecordedAt).ToList();
+ for (int i = 1; i < ordered.Count; i++)
+ {
+ if (ordered[i].Score > ordered[i - 1].Score)
+ recentUpgrades++;
+ }
+ }
+
+ // Per-instance stats
+ var instanceIds = entries.Select(e => e.ArrInstanceId).Distinct().ToList();
+ var instances = await _dataContext.ArrInstances
+ .AsNoTracking()
+ .Include(a => a.ArrConfig)
+ .Where(a => instanceIds.Contains(a.Id))
+ .ToListAsync();
+
+ var perInstanceStats = instanceIds.Select(instanceId =>
+ {
+ var instanceEntries = entries.Where(e => e.ArrInstanceId == instanceId).ToList();
+ int instTracked = instanceEntries.Count;
+ int instBelow = instanceEntries.Count(e => e.CurrentScore < e.CutoffScore);
+ int instMonitored = instanceEntries.Count(e => e.IsMonitored);
+
+ int instUpgrades = 0;
+ var instHistory = recentGrouped
+ .Where(g => g.Key.ArrInstanceId == instanceId);
+ foreach (var group in instHistory)
+ {
+ var ordered = group.OrderBy(h => h.RecordedAt).ToList();
+ for (int i = 1; i < ordered.Count; i++)
+ {
+ if (ordered[i].Score > ordered[i - 1].Score)
+ instUpgrades++;
+ }
+ }
+
+ var instance = instances.FirstOrDefault(a => a.Id == instanceId);
+ return new InstanceCfScoreStat
+ {
+ InstanceId = instanceId,
+ InstanceName = instance?.Name ?? "Unknown",
+ InstanceType = instance?.ArrConfig.Type.ToString() ?? "Unknown",
+ TotalTracked = instTracked,
+ BelowCutoff = instBelow,
+ AtOrAboveCutoff = instTracked - instBelow,
+ Monitored = instMonitored,
+ Unmonitored = instTracked - instMonitored,
+ RecentUpgrades = instUpgrades,
+ };
+ }).OrderBy(s => s.InstanceName).ToList();
+
+ return Ok(new CustomFormatScoreStatsResponse
+ {
+ TotalTracked = totalTracked,
+ BelowCutoff = belowCutoff,
+ AtOrAboveCutoff = atOrAboveCutoff,
+ Monitored = monitored,
+ Unmonitored = unmonitored,
+ RecentUpgrades = recentUpgrades,
+ PerInstanceStats = perInstanceStats,
+ });
+ }
+
+ ///
+ /// Gets CF score history for a specific item.
+ ///
+ [HttpGet("{instanceId}/{itemId}/history")]
+ public async Task GetItemHistory(
+ Guid instanceId,
+ long itemId,
+ [FromQuery] long episodeId = 0)
+ {
+ var history = await _dataContext.CustomFormatScoreHistory
+ .AsNoTracking()
+ .Where(h => h.ArrInstanceId == instanceId
+ && h.ExternalItemId == itemId
+ && h.EpisodeId == episodeId)
+ .OrderByDescending(h => h.RecordedAt)
+ .Select(h => new CustomFormatScoreHistoryEntryResponse
+ {
+ Score = h.Score,
+ CutoffScore = h.CutoffScore,
+ RecordedAt = h.RecordedAt,
+ })
+ .ToListAsync();
+
+ return Ok(new { Entries = history });
+ }
+}
+
diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs
new file mode 100644
index 00000000..e1781384
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs
@@ -0,0 +1,250 @@
+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;
+ }
+
+ ///
+ /// Gets aggregate search statistics across all instances.
+ ///
+ [HttpGet("summary")]
+ public async Task 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 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 currentCycleIds = instanceConfigs.Select(ic => ic.CurrentCycleId).ToList();
+ var cycleItemsByInstance = await _dataContext.SeekerHistory
+ .AsNoTracking()
+ .Where(h => currentCycleIds.Contains(h.CycleId))
+ .GroupBy(h => h.ArrInstanceId)
+ .Select(g => new
+ {
+ InstanceId = g.Key,
+ CycleItemsSearched = g.Select(h => h.ExternalItemId).Distinct().Count(),
+ CycleStartedAt = (DateTime?)g.Min(h => h.LastSearchedAt),
+ })
+ .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,
+ CurrentCycleId = ic.CurrentCycleId,
+ CycleItemsSearched = cycleProgress?.CycleItemsSearched ?? 0,
+ CycleItemsTotal = ic.TotalEligibleItems,
+ CycleStartedAt = cycleProgress?.CycleStartedAt,
+ };
+ }).ToList();
+
+ return Ok(new SearchStatsSummaryResponse
+ {
+ TotalSearchesAllTime = totalSearchesAllTime,
+ SearchesLast7Days = searchesLast7Days,
+ SearchesLast30Days = searchesLast30Days,
+ UniqueItemsSearched = uniqueItemsSearched,
+ PendingReplacementSearches = pendingReplacementSearches,
+ EnabledInstances = instanceConfigs.Count,
+ PerInstanceStats = perInstanceStats,
+ });
+ }
+
+ ///
+ /// Gets paginated search-triggered events with decoded data.
+ /// Supports optional text search across item names in event data.
+ ///
+ [HttpGet("events")]
+ public async Task GetEvents(
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 50,
+ [FromQuery] Guid? instanceId = null,
+ [FromQuery] Guid? cycleId = null,
+ [FromQuery] string? search = 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 ID
+ if (cycleId.HasValue)
+ {
+ query = query.Where(e => e.CycleId == cycleId.Value);
+ }
+
+ // Pre-filter by search term on the JSON data field
+ if (!string.IsNullOrWhiteSpace(search))
+ {
+ query = query.Where(e => e.Data != null && e.Data.ToLower().Contains(search.ToLower()));
+ }
+
+ 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,
+ CycleId = e.CycleId,
+ IsDryRun = e.IsDryRun,
+ };
+ }).ToList();
+
+ return Ok(new
+ {
+ Items = items,
+ Page = page,
+ PageSize = pageSize,
+ TotalCount = totalCount,
+ TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
+ });
+ }
+
+ private static (string InstanceName, int ItemCount, List 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();
+ 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(typeEl.GetString(), out var parsed)
+ ? parsed
+ : SeekerSearchType.Proactive;
+
+ object? grabbedItems = root.TryGetProperty("GrabbedItems", out var grabbedEl)
+ ? JsonSerializer.Deserialize